Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de8cbfec23 | |||
| b8218669c2 | |||
| 2305c3dc9d | |||
| ef60218c4c | |||
| 8018710d31 | |||
| 229975c612 | |||
| aa607b9b6e | |||
| 828cbcc822 | |||
| 4253f6f650 |
@@ -0,0 +1,58 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
backend-tests:
|
||||
name: Backend tests
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DJANGO_SECRET_KEY: ci-test-key
|
||||
DJANGO_DEBUG: "0"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
cache-dependency-path: |
|
||||
backend/requirements.txt
|
||||
backend/requirements-dev.txt
|
||||
|
||||
- name: Install backend dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -r backend/requirements.txt -r backend/requirements-dev.txt
|
||||
|
||||
- name: Run backend tests
|
||||
working-directory: backend
|
||||
run: python -m pytest
|
||||
|
||||
frontend-tests:
|
||||
name: Frontend tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Run frontend tests
|
||||
working-directory: frontend
|
||||
env:
|
||||
CI: "true"
|
||||
run: npm run test -- --run
|
||||
@@ -17,3 +17,4 @@ dist/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
backend/tmp_authentica_request_id.txt
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Goal
|
||||
|
||||
A salon booking platform for KSA (Saudi Arabia) with Django REST API backend and React/Vite frontend. Optimized for phone-first auth (OTP via SMS/WhatsApp), Moyasar payments, Arabic locale (ar-sa), and Riyadh timezone.
|
||||
|
||||
## Commands
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
# Setup (from repo root)
|
||||
python3 -m venv venv && source venv/bin/activate
|
||||
pip install -r backend/requirements.txt -r backend/requirements-dev.txt
|
||||
cp backend/.env.example backend/.env
|
||||
|
||||
# Migrations and dev server (from backend/)
|
||||
cd backend
|
||||
python3 manage.py migrate
|
||||
python3 manage.py runserver
|
||||
|
||||
# Seed demo data
|
||||
python3 manage.py seed_demo
|
||||
|
||||
# Run all tests (from backend/ with venv active)
|
||||
cd backend
|
||||
python3 -m pytest
|
||||
|
||||
# Run a single test file
|
||||
python3 -m pytest apps/accounts/tests/test_otp_limits.py
|
||||
|
||||
# Run a single test
|
||||
python3 -m pytest apps/accounts/tests/test_otp_limits.py::TestClassName::test_method_name
|
||||
|
||||
# Run external/integration tests (hits real third-party services)
|
||||
PYTEST_ADDOPTS='' python3 -m pytest -m external
|
||||
```
|
||||
|
||||
### Authentica E2E Testing
|
||||
|
||||
Set these env vars before running external tests:
|
||||
- `AUTHENTICA_E2E=1`
|
||||
- `AUTHENTICA_API_KEY=...`
|
||||
- `AUTHENTICA_E2E_PHONE=...` (phone that will receive OTP)
|
||||
- `AUTHENTICA_E2E_CODE=...` (OTP code received)
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
# From frontend/
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # dev server at localhost:5173, proxies /api to localhost:8000
|
||||
npm run test # vitest
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend (`backend/`)
|
||||
|
||||
Django project lives in `backend/salon_api/` (settings, root urls, wsgi/asgi). All domain apps are under `backend/apps/`:
|
||||
|
||||
| App | Responsibility |
|
||||
|-----|----------------|
|
||||
| `accounts` | Custom User model, phone/OTP auth, JWT tokens, locale preferences |
|
||||
| `salons` | Salon catalog, services, staff profiles, availability windows, reviews |
|
||||
| `bookings` | Booking lifecycle, availability/overlap validation, status transitions |
|
||||
| `payments` | Moyasar integration (create, capture, refund), webhook reconciliation, idempotency |
|
||||
| `notifications` | Booking lifecycle SMS/WhatsApp messages, stored for auditability |
|
||||
|
||||
**Service layer pattern:** Business logic lives in `apps/<app>/services/` (not in views). Views are thin — they validate input, call services, return responses. Keep it this way.
|
||||
|
||||
**OTP providers** (`apps/accounts/services/otp.py`): pluggable via `OTP_PROVIDER` env var. Active providers: `console` (dev), `twilio`, `authentica`. `unifonic` is a scaffold. Authentica is the recommended production provider and uses a server-side OTP flow (`uses_provider_otp = True`) — it generates and verifies the code itself, so the DB stores a placeholder hash.
|
||||
|
||||
**Payment gateway** (`apps/payments/services/gateway.py`): `MoyasarGateway` implements `BasePaymentGateway`. Amounts are always in minor units (halalas). `MOYASAR_SECRET_KEY` and `MOYASAR_PUBLISHABLE_KEY` are required.
|
||||
|
||||
**Sync-only (MVP):** All external calls (OTP sends, notifications, payment gateway) run synchronously in the request path. No task queue. See `docs/adr/0001-synchronous-external-calls-mvp.md`.
|
||||
|
||||
**Database:** SQLite for local dev (default), PostgreSQL via `DATABASE_URL` env var for production. Tests use `TEST_DATABASE_URL` if set.
|
||||
|
||||
**Localization:** Default language `ar-sa`, timezone `Asia/Riyadh`. `UserLocaleMiddleware` applies per-user locale preference.
|
||||
|
||||
### Frontend (`frontend/`)
|
||||
|
||||
React 18 + Vite app. Entry: `src/main.jsx` → `AuthProvider` wraps `App`.
|
||||
|
||||
- **Routing:** `react-router-dom` v7 with pages in `src/pages/`
|
||||
- **Auth:** JWT tokens managed via `src/contexts/AuthContext.jsx`; `src/components/ProtectedRoute.jsx` guards private pages
|
||||
- **API:** `src/api/client.js` is the axios/fetch wrapper
|
||||
- **Hooks:** Domain logic extracted into `src/hooks/` (e.g., `useSalonSearch`, `usePaymentForm`)
|
||||
- **i18n:** `react-i18next` configured in `src/i18n/index.js`; supports `ar-sa` and `en`
|
||||
|
||||
Tests use Vitest + Testing Library. Setup in `src/test/setupTests.js`.
|
||||
|
||||
## Key Env Vars
|
||||
|
||||
Backend (`backend/.env`):
|
||||
- `DJANGO_SECRET_KEY`, `DJANGO_DEBUG`, `DATABASE_URL`
|
||||
- `OTP_PROVIDER` — `console` | `twilio` | `authentica` | `unifonic`
|
||||
- `AUTHENTICA_API_KEY`, `AUTHENTICA_BASE_URL`, `AUTHENTICA_SENDER_NAME`
|
||||
- `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_FROM_NUMBER`, `TWILIO_WHATSAPP_FROM`
|
||||
- `MOYASAR_SECRET_KEY`, `MOYASAR_PUBLISHABLE_KEY`
|
||||
- `NOTIFICATION_PROVIDER` — defaults to `OTP_PROVIDER`
|
||||
- `OTP_EXPIRY_MINUTES`, `OTP_MAX_PER_WINDOW`, `OTP_WINDOW_MINUTES`, `OTP_RESEND_COOLDOWN_SECONDS`
|
||||
- `CORS_ALLOWED_ORIGINS`
|
||||
|
||||
## Testing Conventions
|
||||
|
||||
- Backend tests live beside their apps: `apps/<app>/tests/test_*.py`
|
||||
- `pytest.ini` marks `external` tests as opt-in; default runs skip them
|
||||
- Frontend tests: Vitest + Testing Library; test files colocated with source (`*.test.jsx`)
|
||||
- Minimum coverage: auth flows, booking validation, payment state transitions
|
||||
|
||||
## ExecPlans
|
||||
|
||||
For complex features, use an ExecPlan (see `PLANS.md` for the full spec and active plan pointer). ExecPlans are living documents in `docs/execplans/`. The active plan is listed in `PLANS.md`. Update `docs/risks.md` when opening or closing a significant gap.
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
- Business logic in service layers; keep views thin
|
||||
- Predictable error responses: HTTP status code + `detail` field
|
||||
- Comment intent, edge cases, and non-obvious business rules; skip obvious comments
|
||||
- Payment and booking flows must be idempotent and auditable
|
||||
- Phone auth must be rate-limited (enforced in `otp.py` via `OtpRateLimitError` / `OtpCooldownError`)
|
||||
@@ -23,6 +23,7 @@ After migrations, you can seed demo data:
|
||||
### Tests
|
||||
|
||||
- From project root with venv active: `venv/bin/python3 -m pytest` (run from `backend/` so `pytest.ini` is picked up)
|
||||
- External provider tests are skipped by default; run explicitly when needed: `PYTEST_ADDOPTS='' venv/bin/python3 -m pytest -m external`
|
||||
|
||||
### Core API endpoints (current scaffold)
|
||||
|
||||
@@ -63,3 +64,5 @@ The dev server proxies `/api` to `http://localhost:8000`.
|
||||
|
||||
- Known gaps and risks: `docs/risks.md`
|
||||
- Architecture and async/observability decisions: `docs/architecture.md`
|
||||
- Documentation index and standards: `docs/README.md` and `docs/documentation.md`
|
||||
- CI: Gitea Actions workflow in `.gitea/workflows/ci.yml`
|
||||
|
||||
+20
-2
@@ -1,13 +1,31 @@
|
||||
# Backend Notes (MVP Readiness)
|
||||
|
||||
## High-Level Takeaways
|
||||
- Provider integrations are the main reliability gap: OTP providers are stubbed and Moyasar capture/refund are TODOs.
|
||||
- Authentica OTP integration is implemented; Moyasar capture/refund are TODOs.
|
||||
- External calls (OTP, notifications, payment gateway) run synchronously in request/response paths, increasing latency risk.
|
||||
- Cross-app coupling (bookings ↔ notifications ↔ accounts/payments) will get harder to evolve without clearer service boundaries.
|
||||
- Phone-first auth works, but `USERNAME_FIELD` is email; align identifier strategy to avoid future auth confusion.
|
||||
|
||||
## Near-Term Focus
|
||||
- Implement at least one real SMS/WhatsApp provider end-to-end via existing abstractions.
|
||||
- Hardening Authentica integration (timeouts, retries, async delivery) and aligning notification provider choices.
|
||||
|
||||
**Authentica E2E**
|
||||
Run the real Authentica OTP flow only when explicitly enabled.
|
||||
|
||||
Env vars (in `backend/.env` or shell):
|
||||
- `AUTHENTICA_E2E=1`
|
||||
- `AUTHENTICA_API_KEY=...`
|
||||
- `AUTHENTICA_E2E_PHONE=...` (must receive OTP)
|
||||
- `AUTHENTICA_E2E_CODE=...` (required; no interactive prompt)
|
||||
|
||||
Command:
|
||||
```bash
|
||||
cd backend
|
||||
PYTEST_ADDOPTS='' python3 -m pytest apps/accounts/tests -m external
|
||||
```
|
||||
|
||||
Suggested flow:
|
||||
1. Trigger the E2E test to send the OTP, then set `AUTHENTICA_E2E_CODE` and re-run if needed.
|
||||
- Decide and document payment lifecycle scope (capture/refund supported vs explicitly out of scope).
|
||||
- Add timeouts/logging for external calls or introduce minimal async jobs for OTP/notifications.
|
||||
- Keep booking, payment, and notification orchestration in service layers, not views.
|
||||
|
||||
@@ -29,7 +29,7 @@ class UserManager(BaseUserManager):
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_superuser(self, email, password=None, **extra_fields):
|
||||
def create_superuser(self, phone_number, password=None, **extra_fields):
|
||||
extra_fields.setdefault("is_staff", True)
|
||||
extra_fields.setdefault("is_superuser", True)
|
||||
extra_fields.setdefault("role", UserRole.ADMIN)
|
||||
@@ -37,7 +37,7 @@ class UserManager(BaseUserManager):
|
||||
raise ValueError("Superuser must have is_staff=True")
|
||||
if extra_fields.get("is_superuser") is not True:
|
||||
raise ValueError("Superuser must have is_superuser=True")
|
||||
return self.create_user(email, password, **extra_fields)
|
||||
return self.create_user(phone_number=phone_number, password=password, **extra_fields)
|
||||
|
||||
|
||||
class User(AbstractBaseUser, PermissionsMixin):
|
||||
@@ -59,7 +59,8 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
USERNAME_FIELD = "email"
|
||||
USERNAME_FIELD = "phone_number"
|
||||
REQUIRED_FIELDS = [] # email is optional; phone_number is the identifier
|
||||
|
||||
def __str__(self):
|
||||
return self.email or self.phone_number or str(self.id)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password, make_password
|
||||
@@ -34,12 +34,20 @@ class OtpCooldownError(RuntimeError):
|
||||
|
||||
|
||||
class BaseOtpProvider:
|
||||
uses_provider_otp = False
|
||||
|
||||
def send_sms(self, to_number: str, message: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def send_whatsapp(self, to_number: str, message: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def send_otp(self, to_number: str, channel: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def verify_otp(self, to_number: str, code: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ConsoleOtpProvider(BaseOtpProvider):
|
||||
def send_sms(self, to_number: str, message: str) -> None:
|
||||
@@ -102,21 +110,110 @@ class UnifonicOtpProvider(BaseOtpProvider):
|
||||
raise NotImplementedError(_("Unifonic WhatsApp adapter not implemented yet"))
|
||||
|
||||
|
||||
class AuthenticaOtpProvider(BaseOtpProvider):
|
||||
"""Authentica provider for OTP delivery (SMS/WhatsApp) plus custom SMS messaging."""
|
||||
|
||||
uses_provider_otp = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.api_key = os.getenv("AUTHENTICA_API_KEY")
|
||||
self.base_url = os.getenv("AUTHENTICA_BASE_URL", "https://api.authentica.sa")
|
||||
self.timeout_seconds = float(os.getenv("AUTHENTICA_TIMEOUT_SECONDS", "10"))
|
||||
self.sender_name = os.getenv("AUTHENTICA_SENDER_NAME")
|
||||
|
||||
def _assert_config(self) -> None:
|
||||
if not self.api_key:
|
||||
raise ValueError(_("Authentica API key is not configured"))
|
||||
|
||||
def _headers(self) -> dict:
|
||||
return {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"X-Authorization": self.api_key,
|
||||
}
|
||||
|
||||
def _post(self, path: str, payload: dict) -> dict:
|
||||
import requests
|
||||
|
||||
self._assert_config()
|
||||
base_url = self.base_url.rstrip("/")
|
||||
url = f"{base_url}{path}"
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._headers(),
|
||||
json=payload,
|
||||
timeout=self.timeout_seconds,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise RuntimeError(_("Authentica request failed")) from exc
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
data = {"detail": response.text}
|
||||
|
||||
if not response.ok:
|
||||
if os.getenv("AUTHENTICA_DEBUG") == "1" or os.getenv("AUTHENTICA_E2E") == "1":
|
||||
raise RuntimeError(
|
||||
_("Authentica request failed: %(status)s %(body)s")
|
||||
% {"status": response.status_code, "body": response.text}
|
||||
)
|
||||
raise RuntimeError(_("Authentica request failed"))
|
||||
|
||||
return data
|
||||
|
||||
def send_otp(self, to_number: str, channel: str) -> None:
|
||||
if channel not in (OtpChannel.SMS, OtpChannel.WHATSAPP):
|
||||
raise ValueError(_("Unsupported OTP channel"))
|
||||
method = "sms" if channel == OtpChannel.SMS else "whatsapp"
|
||||
self._post("/api/v2/send-otp", {"method": method, "phone": to_number})
|
||||
|
||||
def verify_otp(self, to_number: str, code: str) -> bool:
|
||||
data = self._post("/api/v2/verify-otp", {"phone": to_number, "otp": code})
|
||||
if "verified" in data:
|
||||
verified = bool(data.get("verified"))
|
||||
else:
|
||||
verified = bool(data.get("status")) or data.get("message") == "OTP verified successfully"
|
||||
if not verified and (os.getenv("AUTHENTICA_DEBUG") == "1" or os.getenv("AUTHENTICA_E2E") == "1"):
|
||||
raise RuntimeError(_("Authentica verify failed: %(response)s") % {"response": data})
|
||||
return verified
|
||||
|
||||
def send_sms(self, to_number: str, message: str) -> None:
|
||||
if not self.sender_name:
|
||||
raise ValueError(_("Authentica sender name is not configured"))
|
||||
self._post(
|
||||
"/api/v2/send-sms",
|
||||
{
|
||||
"phone": to_number,
|
||||
"message": message,
|
||||
"sender_name": self.sender_name,
|
||||
},
|
||||
)
|
||||
|
||||
def send_whatsapp(self, to_number: str, message: str) -> None:
|
||||
raise ValueError(_("Authentica WhatsApp messaging is not supported"))
|
||||
|
||||
|
||||
PROVIDERS = {
|
||||
"console": ConsoleOtpProvider,
|
||||
"twilio": TwilioOtpProvider,
|
||||
"unifonic": UnifonicOtpProvider,
|
||||
"authentica": AuthenticaOtpProvider,
|
||||
}
|
||||
|
||||
|
||||
def get_provider() -> BaseOtpProvider:
|
||||
provider_key = settings.OTP_PROVIDER
|
||||
def _get_provider_for_key(provider_key: str) -> BaseOtpProvider:
|
||||
provider_cls = PROVIDERS.get(provider_key)
|
||||
if not provider_cls:
|
||||
raise ValueError(_("Unknown OTP provider: %(provider)s") % {"provider": provider_key})
|
||||
return provider_cls()
|
||||
|
||||
|
||||
def get_provider() -> BaseOtpProvider:
|
||||
return _get_provider_for_key(settings.OTP_PROVIDER)
|
||||
|
||||
|
||||
def generate_code(length: int = 6) -> str:
|
||||
digits = "0123456789"
|
||||
return "".join(secrets.choice(digits) for _ in range(length))
|
||||
@@ -149,25 +246,34 @@ def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpo
|
||||
if elapsed < cooldown_seconds:
|
||||
raise OtpCooldownError(retry_after_seconds=int(cooldown_seconds - elapsed))
|
||||
|
||||
code = generate_code()
|
||||
if provider.uses_provider_otp:
|
||||
code_hash = make_password(secrets.token_urlsafe(16))
|
||||
message = None
|
||||
else:
|
||||
code = generate_code()
|
||||
code_hash = make_password(code)
|
||||
message = _(
|
||||
"Your verification code is %(code)s. It expires in %(minutes)s minutes."
|
||||
) % {"code": code, "minutes": settings.OTP_EXPIRY_MINUTES}
|
||||
|
||||
otp = PhoneOTP.objects.create(
|
||||
phone_number=phone_number,
|
||||
channel=channel,
|
||||
purpose=purpose,
|
||||
provider=settings.OTP_PROVIDER,
|
||||
code_hash=make_password(code),
|
||||
code_hash=code_hash,
|
||||
expires_at=PhoneOTP.expiry_at(),
|
||||
)
|
||||
|
||||
message = _(
|
||||
"Your verification code is %(code)s. It expires in %(minutes)s minutes."
|
||||
) % {"code": code, "minutes": settings.OTP_EXPIRY_MINUTES}
|
||||
if channel == OtpChannel.SMS:
|
||||
provider.send_sms(phone_number, message)
|
||||
elif channel == OtpChannel.WHATSAPP:
|
||||
provider.send_whatsapp(phone_number, message)
|
||||
if provider.uses_provider_otp:
|
||||
provider.send_otp(phone_number, channel)
|
||||
else:
|
||||
raise ValueError(_("Unsupported OTP channel"))
|
||||
if channel == OtpChannel.SMS:
|
||||
provider.send_sms(phone_number, message)
|
||||
elif channel == OtpChannel.WHATSAPP:
|
||||
provider.send_whatsapp(phone_number, message)
|
||||
else:
|
||||
raise ValueError(_("Unsupported OTP channel"))
|
||||
|
||||
return OtpSendResult(request_id=str(otp.id), expires_at=otp.expires_at.isoformat())
|
||||
|
||||
@@ -179,9 +285,21 @@ def verify_otp(otp: PhoneOTP, code: str) -> bool:
|
||||
if otp.attempt_count > otp.max_attempts:
|
||||
otp.save(update_fields=["attempt_count"])
|
||||
return False
|
||||
if not check_password(code, otp.code_hash):
|
||||
otp.save(update_fields=["attempt_count"])
|
||||
return False
|
||||
provider_cls = PROVIDERS.get(otp.provider)
|
||||
if provider_cls and getattr(provider_cls, "uses_provider_otp", False):
|
||||
provider = provider_cls()
|
||||
try:
|
||||
verified = provider.verify_otp(otp.phone_number, code)
|
||||
except Exception:
|
||||
otp.save(update_fields=["attempt_count"])
|
||||
raise
|
||||
if not verified:
|
||||
otp.save(update_fields=["attempt_count"])
|
||||
return False
|
||||
else:
|
||||
if not check_password(code, otp.code_hash):
|
||||
otp.save(update_fields=["attempt_count"])
|
||||
return False
|
||||
otp.verified_at = timezone.now()
|
||||
otp.save(update_fields=["verified_at", "attempt_count"])
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Mocked end-to-end phone auth flow using Authentica OTP provider."""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.accounts.models import User
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(OTP_PROVIDER="authentica")
|
||||
@patch("requests.post")
|
||||
def test_phone_auth_flow_with_authentica_mock(mock_post, client):
|
||||
def make_response(payload, ok=True):
|
||||
response = MagicMock()
|
||||
response.ok = ok
|
||||
response.json.return_value = payload
|
||||
response.text = ""
|
||||
return response
|
||||
|
||||
def side_effect(url, headers=None, json=None, timeout=None):
|
||||
assert headers and headers.get("X-Authorization") == "api-key"
|
||||
assert timeout == 7.0
|
||||
if url.endswith("/api/v2/send-otp"):
|
||||
assert json == {"method": "sms", "phone": "+966512345678"}
|
||||
return make_response({"success": True})
|
||||
if url.endswith("/api/v2/verify-otp"):
|
||||
if json == {"phone": "+966512345678", "otp": "123456"}:
|
||||
return make_response({"verified": True})
|
||||
return make_response({"verified": False})
|
||||
raise AssertionError(f"Unexpected URL {url}")
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"AUTHENTICA_API_KEY": "api-key",
|
||||
"AUTHENTICA_TIMEOUT_SECONDS": "7",
|
||||
},
|
||||
):
|
||||
mock_post.side_effect = side_effect
|
||||
|
||||
request_url = reverse("phone_auth_request")
|
||||
verify_url = reverse("phone_auth_verify")
|
||||
|
||||
response = client.post(
|
||||
request_url,
|
||||
{"phone_number": "0512345678", "channel": "sms", "first_name": "Sara"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
request_id = response.json()["request_id"]
|
||||
|
||||
bad = client.post(
|
||||
verify_url,
|
||||
{"request_id": request_id, "code": "000000"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert bad.status_code == 400
|
||||
|
||||
good = client.post(
|
||||
verify_url,
|
||||
{"request_id": request_id, "code": "123456"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert good.status_code == 200
|
||||
|
||||
user = User.objects.filter(phone_number="+966512345678").first()
|
||||
assert user is not None
|
||||
assert user.is_phone_verified is True
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Real Authentica E2E OTP flow. Requires live credentials and a phone receiving OTPs."""
|
||||
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP, User
|
||||
from apps.accounts.services.phone import normalize_phone_number
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.external
|
||||
@override_settings(OTP_PROVIDER="authentica")
|
||||
def test_authentica_phone_auth_e2e(client):
|
||||
if os.getenv("AUTHENTICA_E2E") != "1":
|
||||
pytest.skip("AUTHENTICA_E2E=1 not set")
|
||||
|
||||
api_key = os.getenv("AUTHENTICA_API_KEY")
|
||||
phone_number = os.getenv("AUTHENTICA_E2E_PHONE")
|
||||
if not api_key or not phone_number:
|
||||
pytest.skip("Missing AUTHENTICA_API_KEY or AUTHENTICA_E2E_PHONE")
|
||||
|
||||
request_url = reverse("phone_auth_request")
|
||||
response = client.post(
|
||||
request_url,
|
||||
{"phone_number": phone_number, "channel": "sms", "first_name": "E2E"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
request_id = response.json()["request_id"]
|
||||
assert request_id
|
||||
|
||||
code = os.getenv("AUTHENTICA_E2E_CODE")
|
||||
if not code:
|
||||
pytest.skip("AUTHENTICA_E2E_CODE not set")
|
||||
|
||||
normalized_phone = normalize_phone_number(phone_number)
|
||||
User.objects.get_or_create(
|
||||
phone_number=normalized_phone,
|
||||
defaults={"role": "customer"},
|
||||
)
|
||||
if not PhoneOTP.objects.filter(id=request_id).exists():
|
||||
# Create a local OTP record so the verify endpoint can bind to a request_id.
|
||||
PhoneOTP.objects.create(
|
||||
id=request_id,
|
||||
phone_number=normalized_phone,
|
||||
channel=OtpChannel.SMS,
|
||||
purpose=OtpPurpose.AUTH,
|
||||
provider="authentica",
|
||||
code_hash="placeholder",
|
||||
expires_at=timezone.now() + timedelta(minutes=5),
|
||||
)
|
||||
|
||||
verify_url = reverse("phone_auth_verify")
|
||||
verify = client.post(
|
||||
verify_url,
|
||||
{"request_id": request_id, "code": code},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert verify.status_code == 200
|
||||
data = verify.json()
|
||||
assert "access" in data
|
||||
assert "refresh" in data
|
||||
|
||||
user = User.objects.filter(phone_number=normalized_phone).first()
|
||||
assert user is not None
|
||||
assert user.is_phone_verified is True
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Tests for Authentica OTP provider implementation."""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
|
||||
from apps.accounts.services.otp import AuthenticaOtpProvider, verify_otp
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_authentica_send_otp_sms_calls_api(mock_post):
|
||||
mock_response = MagicMock(ok=True)
|
||||
mock_response.json.return_value = {"success": True}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"AUTHENTICA_API_KEY": "api-key",
|
||||
"AUTHENTICA_BASE_URL": "https://api.authentica.sa",
|
||||
"AUTHENTICA_TIMEOUT_SECONDS": "7",
|
||||
},
|
||||
):
|
||||
provider = AuthenticaOtpProvider()
|
||||
provider.send_otp("+966512345678", OtpChannel.SMS)
|
||||
|
||||
mock_post.assert_called_once()
|
||||
url = mock_post.call_args[0][0]
|
||||
kwargs = mock_post.call_args[1]
|
||||
|
||||
assert url == "https://api.authentica.sa/api/v2/send-otp"
|
||||
assert kwargs["json"] == {"method": "sms", "phone": "+966512345678"}
|
||||
assert kwargs["headers"]["X-Authorization"] == "api-key"
|
||||
assert kwargs["timeout"] == 7.0
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_authentica_send_sms_calls_api(mock_post):
|
||||
mock_response = MagicMock(ok=True)
|
||||
mock_response.json.return_value = {"success": True}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"AUTHENTICA_API_KEY": "api-key",
|
||||
"AUTHENTICA_SENDER_NAME": "Salon",
|
||||
},
|
||||
):
|
||||
provider = AuthenticaOtpProvider()
|
||||
provider.send_sms("+966512345678", "Hello")
|
||||
|
||||
mock_post.assert_called_once()
|
||||
url = mock_post.call_args[0][0]
|
||||
kwargs = mock_post.call_args[1]
|
||||
|
||||
assert url == "https://api.authentica.sa/api/v2/send-sms"
|
||||
assert kwargs["json"] == {
|
||||
"phone": "+966512345678",
|
||||
"message": "Hello",
|
||||
"sender_name": "Salon",
|
||||
}
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_authentica_verify_otp_calls_api(mock_post):
|
||||
mock_response = MagicMock(ok=True)
|
||||
mock_response.json.return_value = {"verified": True}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
with patch.dict(os.environ, {"AUTHENTICA_API_KEY": "api-key"}):
|
||||
provider = AuthenticaOtpProvider()
|
||||
assert provider.verify_otp("+966512345678", "123456") is True
|
||||
|
||||
mock_post.assert_called_once()
|
||||
url = mock_post.call_args[0][0]
|
||||
kwargs = mock_post.call_args[1]
|
||||
|
||||
assert url == "https://api.authentica.sa/api/v2/verify-otp"
|
||||
assert kwargs["json"] == {"phone": "+966512345678", "otp": "123456"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_verify_otp_uses_provider_for_authentica():
|
||||
otp = PhoneOTP.objects.create(
|
||||
phone_number="+966512345678",
|
||||
channel=OtpChannel.SMS,
|
||||
purpose=OtpPurpose.AUTH,
|
||||
provider="authentica",
|
||||
code_hash=make_password("unused"),
|
||||
expires_at=PhoneOTP.expiry_at(),
|
||||
)
|
||||
|
||||
with patch("apps.accounts.services.otp.AuthenticaOtpProvider.verify_otp", return_value=True) as mock_verify:
|
||||
assert verify_otp(otp, "123456") is True
|
||||
|
||||
mock_verify.assert_called_once_with("+966512345678", "123456")
|
||||
otp.refresh_from_db()
|
||||
assert otp.verified_at is not None
|
||||
assert otp.attempt_count == 1
|
||||
@@ -1,13 +1,49 @@
|
||||
import pytest
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.test import override_settings
|
||||
|
||||
from apps.accounts.models import OtpChannel
|
||||
from apps.accounts.services.otp import OtpRateLimitError, create_and_send_otp
|
||||
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
|
||||
from apps.accounts.services.otp import OtpCooldownError, OtpRateLimitError, create_and_send_otp, verify_otp
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(OTP_MAX_PER_WINDOW=1, OTP_WINDOW_MINUTES=15, OTP_RESEND_COOLDOWN_SECONDS=0)
|
||||
@override_settings(OTP_PROVIDER="console", OTP_MAX_PER_WINDOW=1, OTP_WINDOW_MINUTES=15, OTP_RESEND_COOLDOWN_SECONDS=0)
|
||||
def test_otp_rate_limit():
|
||||
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||
with pytest.raises(OtpRateLimitError):
|
||||
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(
|
||||
OTP_PROVIDER="console",
|
||||
OTP_MAX_PER_WINDOW=5,
|
||||
OTP_WINDOW_MINUTES=15,
|
||||
OTP_RESEND_COOLDOWN_SECONDS=60,
|
||||
)
|
||||
def test_otp_cooldown_enforced():
|
||||
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||
with pytest.raises(OtpCooldownError):
|
||||
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_otp_max_attempts_blocks_verification():
|
||||
otp = PhoneOTP.objects.create(
|
||||
phone_number="+966512345678",
|
||||
channel=OtpChannel.SMS,
|
||||
purpose=OtpPurpose.AUTH,
|
||||
provider="console",
|
||||
code_hash=make_password("123456"),
|
||||
expires_at=PhoneOTP.expiry_at(),
|
||||
)
|
||||
# Burn attempts with wrong code until the limit is exceeded.
|
||||
for _ in range(otp.max_attempts):
|
||||
assert verify_otp(otp, "000000") is False
|
||||
otp.refresh_from_db()
|
||||
assert otp.attempt_count == otp.max_attempts
|
||||
|
||||
assert verify_otp(otp, "123456") is False
|
||||
otp.refresh_from_db()
|
||||
assert otp.attempt_count == otp.max_attempts + 1
|
||||
assert otp.verified_at is None
|
||||
|
||||
@@ -1,31 +1,49 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.accounts.models import PhoneOTP, User
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(OTP_PROVIDER="console")
|
||||
def test_phone_auth_creates_user_and_issues_tokens(client):
|
||||
request_url = reverse("phone_auth_request")
|
||||
verify_url = reverse("phone_auth_verify")
|
||||
# Deterministic OTP so we can verify the flow without external providers.
|
||||
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
|
||||
request_url = reverse("phone_auth_request")
|
||||
verify_url = reverse("phone_auth_verify")
|
||||
|
||||
response = client.post(
|
||||
request_url,
|
||||
{"phone_number": "0512345678", "channel": "sms", "first_name": "Sara"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
request_id = response.json()["request_id"]
|
||||
response = client.post(
|
||||
request_url,
|
||||
{"phone_number": "0512345678", "channel": "sms", "first_name": "Sara"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
request_id = response.json()["request_id"]
|
||||
|
||||
otp = PhoneOTP.objects.filter(phone_number="+966512345678").order_by("-created_at").first()
|
||||
assert otp is not None
|
||||
assert str(otp.id) == request_id
|
||||
otp = PhoneOTP.objects.filter(phone_number="+966512345678").order_by("-created_at").first()
|
||||
assert otp is not None
|
||||
assert str(otp.id) == request_id
|
||||
|
||||
bad = client.post(
|
||||
verify_url,
|
||||
{"request_id": request_id, "code": "000000"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert bad.status_code == 400
|
||||
bad = client.post(
|
||||
verify_url,
|
||||
{"request_id": request_id, "code": "000000"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert bad.status_code == 400
|
||||
|
||||
assert User.objects.filter(phone_number="+966512345678").exists()
|
||||
good = client.post(
|
||||
verify_url,
|
||||
{"request_id": request_id, "code": "123456"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert good.status_code == 200
|
||||
data = good.json()
|
||||
assert "access" in data
|
||||
assert "refresh" in data
|
||||
|
||||
user = User.objects.filter(phone_number="+966512345678").first()
|
||||
assert user is not None
|
||||
assert user.is_phone_verified is True
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.db import transaction
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
from apps.bookings.models import Booking, BookingStatus
|
||||
@@ -74,14 +75,40 @@ class BookingCreateSerializer(serializers.ModelSerializer):
|
||||
def create(self, validated_data):
|
||||
request = self.context["request"]
|
||||
service = validated_data["service"]
|
||||
return Booking.objects.create(
|
||||
salon=service.salon,
|
||||
customer=request.user,
|
||||
service=service,
|
||||
staff=validated_data.get("staff"),
|
||||
start_time=validated_data["start_time"],
|
||||
end_time=validated_data["end_time"],
|
||||
notes=validated_data.get("notes", ""),
|
||||
price_amount=service.price_amount,
|
||||
currency=service.currency,
|
||||
)
|
||||
staff = validated_data.get("staff")
|
||||
start_time = validated_data["start_time"]
|
||||
end_time = validated_data["end_time"]
|
||||
|
||||
with transaction.atomic():
|
||||
# Lock the staff row so concurrent booking requests for the same staff
|
||||
# member are serialized. Without this, two requests that both pass the
|
||||
# overlap check in validate() can race and both commit overlapping
|
||||
# bookings. On SQLite (dev/tests) the FOR UPDATE clause is silently
|
||||
# ignored but the transaction still serializes writes; PostgreSQL
|
||||
# (production) gets true row-level locking.
|
||||
StaffProfile.objects.select_for_update().get(pk=staff.pk)
|
||||
|
||||
# Re-run the overlap check inside the lock so the check and the insert
|
||||
# are atomic with respect to other writers.
|
||||
overlap = Booking.objects.filter(
|
||||
staff=staff,
|
||||
status__in=[BookingStatus.PENDING, BookingStatus.CONFIRMED],
|
||||
start_time__lt=end_time,
|
||||
end_time__gt=start_time,
|
||||
).exists()
|
||||
if overlap:
|
||||
raise serializers.ValidationError(
|
||||
{"start_time": _("Booking overlaps an existing appointment")}
|
||||
)
|
||||
|
||||
return Booking.objects.create(
|
||||
salon=service.salon,
|
||||
customer=request.user,
|
||||
service=service,
|
||||
staff=staff,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
notes=validated_data.get("notes", ""),
|
||||
price_amount=service.price_amount,
|
||||
currency=service.currency,
|
||||
)
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,251 @@
|
||||
# Arabic (Saudi Arabia) translations for Salon booking platform.
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: 1.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-03-02 00:45+0300\n"
|
||||
"PO-Revision-Date: 2026-03-02 00:45+0300\n"
|
||||
"Last-Translator: Claude\n"
|
||||
"Language-Team: Arabic (Saudi Arabia)\n"
|
||||
"Language: ar_SA\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
|
||||
|
||||
#: apps/accounts/services/otp.py:26
|
||||
msgid "Too many OTP requests. Try again later."
|
||||
msgstr "طلبات رمز التحقق كثيرة جداً. حاول مرة أخرى لاحقاً."
|
||||
|
||||
#: apps/accounts/services/otp.py:32
|
||||
msgid "Please wait before requesting another code."
|
||||
msgstr "يرجى الانتظار قبل طلب رمز آخر."
|
||||
|
||||
#: apps/accounts/services/otp.py:71
|
||||
msgid "Twilio credentials are not configured"
|
||||
msgstr "لم يتم تكوين بيانات اعتماد Twilio"
|
||||
|
||||
#: apps/accounts/services/otp.py:85
|
||||
msgid "Twilio WhatsApp sender is not configured"
|
||||
msgstr "لم يتم تكوين مُرسِل WhatsApp لـ Twilio"
|
||||
|
||||
#: apps/accounts/services/otp.py:100
|
||||
msgid "Unifonic credentials are not configured"
|
||||
msgstr "لم يتم تكوين بيانات اعتماد Unifonic"
|
||||
|
||||
#: apps/accounts/services/otp.py:104
|
||||
msgid "Unifonic SMS adapter not implemented yet"
|
||||
msgstr "محول SMS لـ Unifonic لم يتم تنفيذه بعد"
|
||||
|
||||
#: apps/accounts/services/otp.py:109
|
||||
msgid "Unifonic WhatsApp sender is not configured"
|
||||
msgstr "لم يتم تكوين مُرسِل WhatsApp لـ Unifonic"
|
||||
|
||||
#: apps/accounts/services/otp.py:110
|
||||
msgid "Unifonic WhatsApp adapter not implemented yet"
|
||||
msgstr "محول WhatsApp لـ Unifonic لم يتم تنفيذه بعد"
|
||||
|
||||
#: apps/accounts/services/otp.py:126
|
||||
msgid "Authentica API key is not configured"
|
||||
msgstr "لم يتم تكوين مفتاح API لـ Authentica"
|
||||
|
||||
#: apps/accounts/services/otp.py:149 apps/accounts/services/otp.py:162
|
||||
msgid "Authentica request failed"
|
||||
msgstr "فشل طلب Authentica"
|
||||
|
||||
#: apps/accounts/services/otp.py:159
|
||||
#, python-format
|
||||
msgid "Authentica request failed: %(status)s %(body)s"
|
||||
msgstr "فشل طلب Authentica: %(status)s %(body)s"
|
||||
|
||||
#: apps/accounts/services/otp.py:168 apps/accounts/services/otp.py:276
|
||||
msgid "Unsupported OTP channel"
|
||||
msgstr "قناة رمز التحقق غير مدعومة"
|
||||
|
||||
#: apps/accounts/services/otp.py:179
|
||||
#, python-format
|
||||
msgid "Authentica verify failed: %(response)s"
|
||||
msgstr "فشل التحقق بـ Authentica: %(response)s"
|
||||
|
||||
#: apps/accounts/services/otp.py:184
|
||||
msgid "Authentica sender name is not configured"
|
||||
msgstr "لم يتم تكوين اسم مُرسِل Authentica"
|
||||
|
||||
#: apps/accounts/services/otp.py:195
|
||||
msgid "Authentica WhatsApp messaging is not supported"
|
||||
msgstr "مراسلة WhatsApp غير مدعومة لـ Authentica"
|
||||
|
||||
#: apps/accounts/services/otp.py:209
|
||||
#, python-format
|
||||
msgid "Unknown OTP provider: %(provider)s"
|
||||
msgstr "مزود رمز التحقق غير معروف: %(provider)s"
|
||||
|
||||
#: apps/accounts/services/otp.py:256
|
||||
#, python-format
|
||||
msgid "Your verification code is %(code)s. It expires in %(minutes)s minutes."
|
||||
msgstr "رمز التحقق الخاص بك هو %(code)s. ينتهي في %(minutes)s دقيقة."
|
||||
|
||||
#: apps/accounts/services/phone.py:8
|
||||
msgid "Phone number is required"
|
||||
msgstr "رقم الهاتف مطلوب"
|
||||
|
||||
#: apps/accounts/services/phone.py:17
|
||||
msgid "Invalid phone number format"
|
||||
msgstr "تنسيق رقم الهاتف غير صالح"
|
||||
|
||||
#: apps/accounts/services/phone.py:28
|
||||
msgid "Phone number must be in E.164 format or a valid Saudi mobile"
|
||||
msgstr "يجب أن يكون رقم الهاتف بصيغة E.164 أو رقم جوال سعودي صالح"
|
||||
|
||||
#: apps/accounts/views.py:75 apps/accounts/views.py:138
|
||||
msgid "Invalid or expired code"
|
||||
msgstr "الرمز غير صالح أو منتهي الصلاحية"
|
||||
|
||||
#: apps/accounts/views.py:82
|
||||
msgid "Phone verified"
|
||||
msgstr "تم التحقق من رقم الهاتف"
|
||||
|
||||
#: apps/accounts/views.py:99
|
||||
msgid "Email already in use."
|
||||
msgstr "البريد الإلكتروني مستخدم بالفعل."
|
||||
|
||||
#: apps/accounts/views.py:142
|
||||
msgid "User not found"
|
||||
msgstr "المستخدم غير موجود"
|
||||
|
||||
#: apps/accounts/views.py:164
|
||||
msgid "Social login not configured yet. Add OAuth provider config."
|
||||
msgstr "لم يتم تكوين تسجيل الدخول الاجتماعي بعد. أضف إعداد مزود OAuth."
|
||||
|
||||
#: apps/bookings/serializers.py:54
|
||||
msgid "Only staff or managers can confirm bookings."
|
||||
msgstr "يمكن للموظفين والمدراء فقط تأكيد الحجوزات."
|
||||
|
||||
#: apps/bookings/serializers.py:56
|
||||
msgid "Only staff or managers can complete bookings."
|
||||
msgstr "يمكن للموظفين والمدراء فقط إكمال الحجوزات."
|
||||
|
||||
#: apps/bookings/serializers.py:58
|
||||
msgid "You are not allowed to cancel this booking."
|
||||
msgstr "لا يُسمح لك بإلغاء هذا الحجز."
|
||||
|
||||
#: apps/bookings/serializers.py:101 apps/bookings/services.py:49
|
||||
msgid "Booking overlaps an existing appointment"
|
||||
msgstr "يتداخل الحجز مع موعد قائم"
|
||||
|
||||
#: apps/bookings/services.py:13
|
||||
msgid "Staff is required for booking"
|
||||
msgstr "يجب تحديد موظف للحجز"
|
||||
|
||||
#: apps/bookings/services.py:16
|
||||
msgid "Selected staff does not belong to this salon"
|
||||
msgstr "الموظف المختار لا ينتمي إلى هذا الصالون"
|
||||
|
||||
#: apps/bookings/services.py:19
|
||||
msgid "End time must be after start time"
|
||||
msgstr "يجب أن يكون وقت الانتهاء بعد وقت البدء"
|
||||
|
||||
#: apps/bookings/services.py:23
|
||||
msgid "End time must match service duration"
|
||||
msgstr "يجب أن يتطابق وقت الانتهاء مع مدة الخدمة"
|
||||
|
||||
#: apps/bookings/services.py:40
|
||||
msgid "Booking is outside staff availability"
|
||||
msgstr "الحجز خارج أوقات توفر الموظف"
|
||||
|
||||
#: apps/notifications/services.py:31
|
||||
#, python-format
|
||||
msgid "Unknown notification provider: %(provider)s"
|
||||
msgstr "مزود الإشعارات غير معروف: %(provider)s"
|
||||
|
||||
#: apps/notifications/services.py:47
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Your booking request is received for %(service)s at %(salon)s on %(start)s."
|
||||
msgstr "تم استلام طلب حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
|
||||
|
||||
#: apps/notifications/services.py:55
|
||||
#, python-format
|
||||
msgid "Your booking is confirmed for %(service)s at %(salon)s on %(start)s."
|
||||
msgstr "تم تأكيد حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
|
||||
|
||||
#: apps/notifications/services.py:63
|
||||
#, python-format
|
||||
msgid "Your booking was cancelled for %(service)s at %(salon)s on %(start)s."
|
||||
msgstr "تم إلغاء حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
|
||||
|
||||
#: apps/notifications/services.py:70
|
||||
#, python-format
|
||||
msgid "Booking update for %(service)s at %(salon)s on %(start)s."
|
||||
msgstr "تحديث الحجز لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
|
||||
|
||||
#: apps/notifications/services.py:85
|
||||
msgid "Unsupported notification channel"
|
||||
msgstr "قناة الإشعارات غير مدعومة"
|
||||
|
||||
#: apps/payments/serializers.py:49
|
||||
msgid "Booking not found"
|
||||
msgstr "الحجز غير موجود"
|
||||
|
||||
#: apps/payments/serializers.py:56 apps/payments/services/payments.py:79
|
||||
msgid "Provider integration not implemented"
|
||||
msgstr "تكامل المزود غير مُنفَّذ"
|
||||
|
||||
#: apps/payments/serializers.py:58
|
||||
msgid "Payment source is required"
|
||||
msgstr "مصدر الدفع مطلوب"
|
||||
|
||||
#: apps/payments/serializers.py:61
|
||||
msgid "Payment source type is required"
|
||||
msgstr "نوع مصدر الدفع مطلوب"
|
||||
|
||||
#: apps/payments/serializers.py:64
|
||||
msgid "Card data must not be sent to the backend; use frontend tokenization"
|
||||
msgstr "لا يجب إرسال بيانات البطاقة إلى الخادم؛ استخدم التحويل إلى رمز في الواجهة الأمامية"
|
||||
|
||||
#: apps/payments/serializers.py:67
|
||||
msgid "Callback URL is required for token payments"
|
||||
msgstr "رابط الاستجابة مطلوب لمدفوعات الرمز"
|
||||
|
||||
#: apps/payments/services/payments.py:84
|
||||
msgid "Idempotency key already used"
|
||||
msgstr "مفتاح الإيدمبوتنسي مستخدم بالفعل"
|
||||
|
||||
#: apps/payments/services/payments.py:89
|
||||
msgid "Unsupported payment source type"
|
||||
msgstr "نوع مصدر الدفع غير مدعوم"
|
||||
|
||||
#: apps/payments/services/payments.py:130
|
||||
#: apps/payments/services/payments.py:141
|
||||
msgid "Payment provider error"
|
||||
msgstr "خطأ في مزود الدفع"
|
||||
|
||||
#: apps/payments/views.py:50
|
||||
msgid "Not allowed"
|
||||
msgstr "غير مسموح"
|
||||
|
||||
#: apps/payments/views.py:70
|
||||
msgid "Webhook secret not configured"
|
||||
msgstr "لم يتم تكوين رمز الـ webhook"
|
||||
|
||||
#: apps/payments/views.py:73
|
||||
msgid "Invalid webhook signature"
|
||||
msgstr "توقيع الـ webhook غير صالح"
|
||||
|
||||
#: apps/payments/views.py:79
|
||||
msgid "Missing payment reference"
|
||||
msgstr "مرجع الدفع مفقود"
|
||||
|
||||
#: apps/payments/views.py:84
|
||||
msgid "Payment not found"
|
||||
msgstr "لم يتم العثور على الدفعة"
|
||||
|
||||
#: apps/payments/views.py:88
|
||||
msgid "Event ignored"
|
||||
msgstr "تم تجاهل الحدث"
|
||||
|
||||
#: apps/payments/views.py:89
|
||||
msgid "Webhook processed"
|
||||
msgstr "تمت معالجة الـ webhook"
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
[pytest]
|
||||
DJANGO_SETTINGS_MODULE = salon_api.settings
|
||||
python_files = tests.py test_*.py *_tests.py
|
||||
addopts = -q
|
||||
addopts = -q -m "not external"
|
||||
markers =
|
||||
external: hits real third-party services (requires explicit env to run)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import timedelta
|
||||
from urllib.parse import urlparse
|
||||
@@ -78,11 +79,14 @@ def parse_database_url(database_url: str):
|
||||
}
|
||||
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||
if DATABASE_URL:
|
||||
parsed_db = parse_database_url(DATABASE_URL)
|
||||
running_tests = "PYTEST_CURRENT_TEST" in os.environ or any("pytest" in arg for arg in sys.argv)
|
||||
test_database_url = os.getenv("TEST_DATABASE_URL")
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
|
||||
if running_tests:
|
||||
parsed_db = parse_database_url(test_database_url) if test_database_url else None
|
||||
else:
|
||||
parsed_db = None
|
||||
parsed_db = parse_database_url(database_url) if database_url else None
|
||||
|
||||
DATABASES = {
|
||||
"default": parsed_db
|
||||
@@ -136,6 +140,8 @@ CORS_ALLOWED_ORIGINS = [
|
||||
]
|
||||
|
||||
OTP_PROVIDER = os.getenv("OTP_PROVIDER", "console")
|
||||
if running_tests:
|
||||
OTP_PROVIDER = os.getenv("TEST_OTP_PROVIDER", "console")
|
||||
OTP_EXPIRY_MINUTES = int(os.getenv("OTP_EXPIRY_MINUTES", "5"))
|
||||
OTP_MAX_PER_WINDOW = int(os.getenv("OTP_MAX_PER_WINDOW", "5"))
|
||||
OTP_WINDOW_MINUTES = int(os.getenv("OTP_WINDOW_MINUTES", "15"))
|
||||
|
||||
+34
-9
@@ -1,11 +1,36 @@
|
||||
# Docs Notes (MVP Alignment)
|
||||
# Documentation Index
|
||||
|
||||
## High-Level Takeaways
|
||||
- The MVP roadmap aligns with Phase 1 goals but needs tighter documentation around provider readiness and async strategy.
|
||||
- ExecPlan references drift between `AGENTS.md` and `PLANS.md` should be resolved to avoid conflicting guidance.
|
||||
- Observability and operational visibility are thin; errors are stored but not surfaced through clear runbooks/dashboards.
|
||||
This directory is the source of truth for product, engineering, and ops documentation. Keep it current as features change.
|
||||
|
||||
## Near-Term Focus
|
||||
- Make ExecPlan references consistent and keep active plans clearly labeled.
|
||||
- Document whether MVP uses async jobs (and which system) or remains synchronous with strict timeouts.
|
||||
- Keep `docs/risks.md` current as gaps are closed.
|
||||
## Start Here
|
||||
|
||||
- Project overview and setup: `README.md` (repo root)
|
||||
- Architecture overview: `docs/architecture.md`
|
||||
- Active ExecPlan: `docs/execplans/booking-notifications.md`
|
||||
- Known risks and gaps: `docs/risks.md`
|
||||
|
||||
## Documentation Standards
|
||||
|
||||
See `docs/documentation.md` for documentation goals, update triggers, and templates.
|
||||
|
||||
## Docs Map
|
||||
|
||||
- `docs/architecture.md`: System architecture, boundaries, and MVP async/observability decision.
|
||||
- `docs/adr/`: Architecture Decision Records (ADRs). New cross-cutting decisions must land here.
|
||||
- `docs/execplans/`: Execution plans for significant features or refactors.
|
||||
- `docs/runbooks/`: Operational runbooks and production checklists.
|
||||
- `docs/risks.md`: Tracked risks and gaps.
|
||||
- `docs/templates/`: Reusable templates (ADR, runbook).
|
||||
|
||||
## Update Triggers (Quick Reference)
|
||||
|
||||
- New external dependency, provider, or major flow: add an ADR in `docs/adr/`.
|
||||
- Change to booking/payment/auth logic: update `docs/architecture.md` and relevant runbook(s).
|
||||
- New operational procedure: add a runbook in `docs/runbooks/`.
|
||||
- Close or add a significant risk: update `docs/risks.md`.
|
||||
|
||||
## Ownership And Review
|
||||
|
||||
- Authors own freshness: if you touch an area, update the docs in the same PR.
|
||||
- New production flows require at least one runbook.
|
||||
- Avoid duplicating instructions; link to the single source of truth.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# ADR 0001: Synchronous External Calls For MVP
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The MVP relies on OTP delivery, booking notifications, and payment gateway calls. Introducing a task queue (Celery/RQ) would add infrastructure (Redis, workers, retries) and operational complexity that is not required for the early launch.
|
||||
|
||||
## Decision
|
||||
|
||||
For the MVP, OTP sends, booking notifications, and payment gateway calls run synchronously in the request/response path with strict timeouts. A task queue will be revisited when traffic grows or operational needs change.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Faster initial delivery with fewer moving parts.
|
||||
- Increased latency risk on endpoints that call external providers.
|
||||
- Payment and OTP failures are surfaced to clients immediately (correct behaviour — clients need to know).
|
||||
- Notification failures are absorbed: `notifications/services.py` catches provider errors, stores them as `FAILED` status, and never surfaces them to the client. A failed booking SMS does not cause the booking request to fail. This means notification failures require active monitoring rather than appearing in client-facing error rates.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- Celery + Redis for all external calls: rejected for MVP due to infra overhead.
|
||||
- Hybrid async for notifications only: not wrong in principle, deferred for operational simplicity. The three call types have genuinely different semantics:
|
||||
- **Payment creation**: synchronous by design — the client needs the Moyasar redirect URL before the response returns.
|
||||
- **OTP sends**: synchronous by design — users expect immediate confirmation that the code was sent.
|
||||
- **Booking notifications**: fire-and-forget by nature — the booking is already committed and the client does not wait for delivery confirmation.
|
||||
When notification latency becomes a problem (e.g. under load or with slow SMS providers), only notifications need to move off the request path. Payments and OTP sends should remain synchronous regardless.
|
||||
|
||||
## Related
|
||||
|
||||
- `docs/architecture.md`
|
||||
@@ -0,0 +1,30 @@
|
||||
# ADR 0002: Moyasar As The Payment Gateway
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The platform needs a payment gateway that supports Saudi Arabia, SAR currency defaults, and local payment methods (e.g. STC Pay, Apple Pay, Samsung Pay). The backend already implements a `MoyasarGateway` integration and models `payments.Payment` with a `moyasar` provider option.
|
||||
|
||||
## Decision
|
||||
|
||||
Use Moyasar as the payment gateway for the MVP. Payment creation, capture, refund, and webhook reconciliation are implemented through `apps.payments.services.gateway.MoyasarGateway`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Supports KSA-focused payment methods and SAR by default.
|
||||
- Operational dependency on Moyasar uptime and API stability.
|
||||
- Payment flows and webhooks are tied to the Moyasar API surface until a gateway abstraction is expanded.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- Other regional gateways: deferred until the MVP is validated.
|
||||
- Stripe or similar global providers: not selected for MVP due to KSA-specific coverage priorities.
|
||||
|
||||
## Related
|
||||
|
||||
- `backend/apps/payments/services/gateway.py`
|
||||
- `docs/runbooks/payments_sanity_check.md`
|
||||
- `docs/architecture.md`
|
||||
@@ -0,0 +1,30 @@
|
||||
# ADR 0003: Authentica As Primary OTP Provider
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The platform requires phone-first authentication with OTP delivery for KSA. The codebase includes multiple provider adapters (`console`, `twilio`, `unifonic`, `authentica`) but only Authentica is implemented for provider-managed OTP delivery (send/verify) and direct SMS messaging. Twilio and Unifonic adapters are partial or unimplemented; a console provider exists for local development.
|
||||
|
||||
## Decision
|
||||
|
||||
Use Authentica as the primary OTP provider for the MVP, with `OTP_PROVIDER=authentica` in production environments. Keep `console` for local development and tests, and retain Twilio/Unifonic adapters as scaffolds for future expansion.
|
||||
|
||||
## Consequences
|
||||
|
||||
- OTP verification relies on Authentica APIs and credentials in production.
|
||||
- Local development remains simple with the console provider.
|
||||
- Adding a second production provider will require completing adapters and updating operational runbooks.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- Twilio as primary provider: not selected due to KSA-focused delivery needs and current adapter gaps.
|
||||
- Unifonic as primary provider: deferred until the adapter is fully implemented and validated.
|
||||
|
||||
## Related
|
||||
|
||||
- `backend/apps/accounts/services/otp.py`
|
||||
- `backend/salon_api/settings.py`
|
||||
- `docs/architecture.md`
|
||||
@@ -0,0 +1,5 @@
|
||||
# Architecture Decision Records
|
||||
|
||||
ADRs capture cross-cutting or hard-to-reverse decisions. Add a new ADR when changing providers, async strategy, data model boundaries, or other architectural choices.
|
||||
|
||||
Use the template in `docs/templates/adr.md` and increment the numeric prefix (`0002`, `0003`, ...).
|
||||
+22
-1
@@ -12,7 +12,17 @@ The Salon platform is a Django REST API backend with a React/Vite frontend, opti
|
||||
| **salons** | Salon catalog, services, staff, availability windows, reviews. Read-only public APIs. |
|
||||
| **bookings** | Booking model, validation (availability, overlap prevention), status transitions. Triggers notifications on create and status change. |
|
||||
| **payments** | Payment model, Moyasar integration (create, capture, refund), webhook reconciliation, idempotency. |
|
||||
| **notifications** | Booking lifecycle notifications (SMS/WhatsApp). Reuses OTP providers; sends on booking created/confirmed/cancelled. |
|
||||
| **notifications** | Booking lifecycle notifications (SMS/WhatsApp). Reuses OTP provider classes as an MVP shortcut; sends on booking created/confirmed/cancelled. See note below. |
|
||||
|
||||
## Data Model Overview
|
||||
|
||||
The core data model centers on users, salons, and time-bound bookings. A booking ties a customer to a service, a staff member, and a scheduled time. Payments are recorded per booking and reconcile to the external gateway. Notifications are stored for every booking lifecycle message for auditability.
|
||||
|
||||
- `accounts.User` owns phone, locale, and auth preferences.
|
||||
- `salons.Salon`, `salons.Service`, and `salons.Staff` define the catalog and scheduling surface.
|
||||
- `bookings.Booking` links customer, staff, service, and scheduled time, with status transitions.
|
||||
- `payments.Payment` tracks gateway state and idempotency per booking.
|
||||
- `notifications.Notification` records each SMS/WhatsApp send attempt tied to a booking event.
|
||||
|
||||
## Data Flow
|
||||
|
||||
@@ -25,9 +35,20 @@ User → React Frontend → Django API
|
||||
payments ──→ Moyasar gateway
|
||||
```
|
||||
|
||||
## Notification / OTP Provider Coupling (MVP Shortcut)
|
||||
|
||||
`notifications/services.py` imports `PROVIDERS` from `apps.accounts.services.otp` and uses OTP provider instances (e.g. `AuthenticaOtpProvider`) to send booking SMSes. This works today because Authentica handles both authentication OTPs and general SMS delivery.
|
||||
|
||||
Consequences of this coupling:
|
||||
- Notifications and OTP delivery cannot independently use different providers (e.g. Twilio for OTP, Unifonic for notifications).
|
||||
- The `notifications` app is conceptually coupled to the `accounts` app's auth infrastructure.
|
||||
|
||||
This is an acceptable MVP shortcut. Before Phase 2, introduce a dedicated `NotificationProvider` abstraction in `notifications/` (mirroring `OtpProvider`) so the two systems can be configured and tested independently.
|
||||
|
||||
## Async and Observability (MVP Decision)
|
||||
|
||||
**Decision (MVP):** All OTP sends, booking notifications, and payment gateway calls run **synchronously** in the request/response path. No Celery, RQ, or other task queue for the initial launch.
|
||||
This is captured in ADR 0001 (`docs/adr/0001-synchronous-external-calls-mvp.md`).
|
||||
|
||||
**Rationale:**
|
||||
- Reduces deployment complexity (no Redis, no worker processes).
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Documentation Practices
|
||||
|
||||
These standards aim to keep documentation reliable as the codebase grows.
|
||||
|
||||
## Principles
|
||||
|
||||
- Single source of truth: one canonical doc per topic; link instead of duplicating.
|
||||
- Proximity: keep docs close to the code they describe when possible.
|
||||
- Freshness: update docs in the same PR as the code change.
|
||||
- Observable behavior: describe what someone can see or run to validate the behavior.
|
||||
|
||||
## Required Docs By Area
|
||||
|
||||
- Architecture and major decisions: `docs/architecture.md` and `docs/adr/`.
|
||||
- Feature delivery plans: `docs/execplans/` (required by `PLANS.md`).
|
||||
- Operational procedures: `docs/runbooks/`.
|
||||
- Risks and gaps: `docs/risks.md`.
|
||||
|
||||
## When To Write An ADR
|
||||
|
||||
Use an ADR for any decision that is cross-cutting or hard to reverse, including:
|
||||
|
||||
- External providers or payment/auth strategy changes.
|
||||
- Async vs synchronous execution decisions.
|
||||
- Data model changes that affect multiple apps or services.
|
||||
|
||||
ADRs live in `docs/adr/` and use the template in `docs/templates/adr.md`.
|
||||
|
||||
## Runbook Expectations
|
||||
|
||||
Every production-impacting flow should have a runbook that covers:
|
||||
|
||||
- Symptoms and impact.
|
||||
- Detection and quick checks.
|
||||
- Safe remediation steps.
|
||||
- Rollback or escalation path.
|
||||
|
||||
Use the template in `docs/templates/runbook.md`.
|
||||
|
||||
## Writing Style
|
||||
|
||||
- Be explicit: include exact commands, paths, and expected output where useful.
|
||||
- Keep sections short and focused.
|
||||
- Avoid unstated assumptions; if a step needs a specific directory, say so.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- Docs updated or explicitly confirmed unnecessary.
|
||||
- New runbook added when operational behavior changes.
|
||||
- ADR added for new cross-cutting decisions.
|
||||
- `docs/risks.md` updated for meaningful gaps added or closed.
|
||||
+5
-3
@@ -5,12 +5,13 @@ This file tracks known gaps and risks to address in future iterations.
|
||||
## Security And Auth
|
||||
- Phone normalization is KSA-focused and minimal; broaden for multi-country use.
|
||||
- OTP protections are basic; add device fingerprinting and IP throttling if needed.
|
||||
- Twilio OTP provider is implemented (SMS + WhatsApp); Unifonic remains a scaffold.
|
||||
- Authentica OTP provider is implemented (SMS + WhatsApp via Authentica OTP); Unifonic remains a scaffold.
|
||||
- Social login is a placeholder.
|
||||
- USERNAME_FIELD is still `email` while email can be null; verify admin/login flows.
|
||||
- `USERNAME_FIELD` is now `"phone_number"`; `REQUIRED_FIELDS = []`; `create_superuser` accepts `phone_number`. Admin and `createsuperuser` work correctly for phone-only users.
|
||||
|
||||
## Booking Integrity
|
||||
- Availability checks and overlap prevention are now enforced for staff bookings.
|
||||
- **Race condition — fixed:** `BookingCreateSerializer.create()` now locks the staff row with `select_for_update()` inside `transaction.atomic()` and re-runs the overlap check before inserting. Concurrent requests for the same staff slot are serialized at the DB level. Requires PostgreSQL in production (SQLite ignores `FOR UPDATE` but still serializes writes).
|
||||
- No timezone handling or business hours enforcement.
|
||||
- No cancellation rules or refund logic.
|
||||
|
||||
@@ -21,10 +22,11 @@ This file tracks known gaps and risks to address in future iterations.
|
||||
## Data And UX
|
||||
- Ratings are not recalculated from reviews.
|
||||
- No image upload or storage strategy for photos.
|
||||
- Booking lifecycle notifications are implemented; Twilio delivers SMS/WhatsApp when OTP_PROVIDER=twilio.
|
||||
- Booking lifecycle notifications are implemented; Authentica can deliver SMS when NOTIFICATION_PROVIDER=authentica.
|
||||
- Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending.
|
||||
|
||||
## Ops And Compliance
|
||||
- No audit logs for admin actions.
|
||||
- No multi-tenant isolation or data export tooling.
|
||||
- No GDPR/PDPL data retention policies defined.
|
||||
- CI baseline exists, but needs Gitea runner registration and required-check enforcement.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Runbooks
|
||||
|
||||
Operational procedures live here. Each new production-impacting workflow should add or update a runbook.
|
||||
|
||||
Existing runbooks:
|
||||
|
||||
- `docs/runbooks/auth_otp_failures.md`
|
||||
- `docs/runbooks/booking_failures.md`
|
||||
- `docs/runbooks/payments_sanity_check.md`
|
||||
@@ -0,0 +1,39 @@
|
||||
# Runbook: Auth OTP Failures
|
||||
|
||||
## Summary
|
||||
|
||||
Guide for diagnosing and mitigating OTP send or verify failures in phone-first authentication.
|
||||
|
||||
## Symptoms
|
||||
|
||||
- Users report not receiving OTP codes.
|
||||
- `/api/auth/otp/request/` or `/api/auth/phone/request/` returns HTTP 500 or rate-limit errors.
|
||||
- `/api/auth/otp/verify/` or `/api/auth/phone/verify/` returns invalid or expired OTP errors unexpectedly.
|
||||
|
||||
## Impact
|
||||
|
||||
- Users cannot sign in or complete phone verification.
|
||||
- Booking and payment flows are blocked when auth is required.
|
||||
|
||||
## Quick Checks
|
||||
|
||||
- Confirm the provider configured in `backend/salon_api/settings.py` via `OTP_PROVIDER`.
|
||||
- Check recent application logs for OTP send errors.
|
||||
- Verify provider credentials are present in `backend/.env` for the active provider.
|
||||
|
||||
## Mitigation Steps
|
||||
|
||||
- If provider credentials are missing or invalid, fix the environment variables and restart the API process.
|
||||
- If the provider is down, temporarily switch to `OTP_PROVIDER=console` for non-production environments and notify support.
|
||||
- If rate limits are triggered, validate `OTP_MAX_PER_WINDOW`, `OTP_WINDOW_MINUTES`, and `OTP_RESEND_COOLDOWN_SECONDS` values and confirm client behavior is not retrying aggressively.
|
||||
- If verification is failing, confirm server time is correct and `OTP_EXPIRY_MINUTES` is appropriate.
|
||||
|
||||
## Rollback / Escalation
|
||||
|
||||
- Roll back recent auth/OTP changes if the failure coincides with a deployment.
|
||||
- Escalate to the provider (Authentica) with request IDs and timestamps if external API errors persist.
|
||||
|
||||
## Notes
|
||||
|
||||
- Authentica is the primary OTP provider for MVP; console provider is for local development.
|
||||
- OTP send/verify logic lives in `backend/apps/accounts/services/otp.py`.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Runbook: Booking Failures
|
||||
|
||||
## Summary
|
||||
|
||||
Guide for diagnosing booking creation or status update failures (availability, overlap prevention, or validation errors).
|
||||
|
||||
## Symptoms
|
||||
|
||||
- `POST /api/bookings/` returns HTTP 400 or 500.
|
||||
- `PATCH /api/bookings/<id>/` fails when confirming or cancelling.
|
||||
- Users report bookings not appearing or incorrect status.
|
||||
|
||||
## Impact
|
||||
|
||||
- Customers cannot place bookings.
|
||||
- Staff schedules become inconsistent.
|
||||
- Notification and payment flows may not trigger.
|
||||
|
||||
## Quick Checks
|
||||
|
||||
- Confirm the request payload includes a valid `service`, `staff`, and scheduled time.
|
||||
- Check server logs for booking validation errors or integrity exceptions.
|
||||
- Verify that staff availability and overlap prevention rules are behaving as expected.
|
||||
|
||||
## Mitigation Steps
|
||||
|
||||
- Reproduce with a known test user and staff member to isolate data issues.
|
||||
- If overlap rules are too strict, review booking validation logic and confirm time zone assumptions.
|
||||
- If status updates are blocked, verify role checks and serializer permissions in `backend/apps/bookings/`.
|
||||
- If notifications are expected but missing, confirm `NOTIFICATION_PROVIDER` configuration and notification records.
|
||||
|
||||
## Rollback / Escalation
|
||||
|
||||
- Roll back recent booking-related changes if failures started after a deployment.
|
||||
- Escalate to engineering with the booking ID, user ID, and timestamps.
|
||||
|
||||
## Notes
|
||||
|
||||
- Booking validation and status transitions live in `backend/apps/bookings/`.
|
||||
- Notifications for booking lifecycle are handled in `backend/apps/notifications/`.
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
# ADR <NNNN>: <Title>
|
||||
|
||||
## Status
|
||||
|
||||
Proposed | Accepted | Deprecated | Superseded
|
||||
|
||||
## Context
|
||||
|
||||
Explain the problem and the forces at play. Include constraints, risks, or user needs.
|
||||
|
||||
## Decision
|
||||
|
||||
State the decision clearly and explicitly.
|
||||
|
||||
## Consequences
|
||||
|
||||
List the expected positive and negative outcomes, including operational impact.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
Briefly document viable alternatives and why they were rejected.
|
||||
|
||||
## Related
|
||||
|
||||
Link to relevant PRs, runbooks, or architecture sections.
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
# Runbook: <Short Title>
|
||||
|
||||
## Summary
|
||||
|
||||
One or two sentences describing the situation this runbook covers.
|
||||
|
||||
## Symptoms
|
||||
|
||||
Describe what an operator or user will observe.
|
||||
|
||||
## Impact
|
||||
|
||||
Who or what is affected.
|
||||
|
||||
## Quick Checks
|
||||
|
||||
Exact commands or checks that confirm the issue.
|
||||
|
||||
## Mitigation Steps
|
||||
|
||||
Step-by-step actions to resolve or reduce impact.
|
||||
|
||||
## Rollback / Escalation
|
||||
|
||||
How to revert or who to contact if the issue persists.
|
||||
|
||||
## Notes
|
||||
|
||||
Any caveats, dependencies, or follow-up actions.
|
||||
@@ -1,14 +1,16 @@
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
export default function ProtectedRoute({ children }) {
|
||||
const { t } = useTranslation();
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="auth-loading">
|
||||
<p>Loading...</p>
|
||||
<p>{t("common.loading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,11 +16,14 @@
|
||||
"phoneUnavailable": "الهاتف غير متوفر",
|
||||
"viewDetails": "عرض التفاصيل والحجز"
|
||||
},
|
||||
"nav":{"home":"الرئيسية","book":"احجز","pay":"ادفع","profile":"الحساب","bookings":"حجوزاتي","login":"تسجيل الدخول","logout":"تسجيل الخروج"},"book":{"title":"احجزي خدمة","placeholder":"تدفق الحجز قريباً.","cta":"احجزي الآن","selectSalon":"اختاري صالوناً من الصفحة الرئيسية للحجز.","service":"الخدمة","staff":"الفريق","date":"التاريخ","time":"الوقت","notes":"ملاحظات","notesPlaceholder":"ملاحظات اختيارية","selectService":"اختاري الخدمة","selectStaff":"اختاري الفني","submit":"تأكيد الحجز","submitting":"جاري الحجز...","errors":{"fillAll":"يرجى تعبئة جميع الحقول.","invalidTime":"تاريخ أو وقت غير صالح.","generic":"فشل الحجز."}},"salon":{"services":"الخدمات","staff":"الفريق"},"profile":{"title":"الحساب","placeholder":"الملف والحجوزات قريباً.","myBookings":"حجوزاتي","noContact":"لا توجد معلومات اتصال"},"bookings":{"title":"حجوزاتي","subtitle":"مواعيدك القادمة والسابقة.","empty":"لا توجد حجوزات بعد.","pay":"ادفع الآن"},"paymentReturn":{"title":"حالة الدفع","success":"تم الدفع بنجاح","successMessage":"تم إتمام الدفع. ستستلمين تأكيداً عبر SMS أو واتساب.","checkStatus":"تحققي من بريدك أو الصالون لحالة الدفع.","reference":"المرجع: {{id}}","viewBookings":"عرض حجوزاتي"},"auth":{"title":"تسجيل الدخول","subtitle":"أدخلي رقم جوالك لاستلام رمز التحقق.","phone":"رقم الجوال","channel":"الإرسال عبر","sms":"رسالة نصية","whatsapp":"واتساب","sendCode":"إرسال الرمز","sending":"جاري الإرسال...","verifyTitle":"أدخلي الرمز","verifySubtitle":"أرسلنا رمزاً إلى {{phone}}","code":"رمز التحقق","verify":"تحقق","verifying":"جاري التحقق...","back":"تغيير الرقم","errors":{"generic":"حدث خطأ.","retryAfter":"انتظري {{seconds}} ثانية قبل المحاولة مرة أخرى."}},"locale": {
|
||||
"nav":{"home":"الرئيسية","book":"احجز","pay":"ادفع","profile":"الحساب","bookings":"حجوزاتي","login":"تسجيل الدخول","logout":"تسجيل الخروج"},"book":{"title":"احجزي خدمة","placeholder":"تدفق الحجز قريباً.","cta":"احجزي الآن","selectSalon":"اختاري صالوناً من الصفحة الرئيسية للحجز.","service":"الخدمة","staff":"الفريق","date":"التاريخ","time":"الوقت","notes":"ملاحظات","notesPlaceholder":"ملاحظات اختيارية","selectService":"اختاري الخدمة","selectStaff":"اختاري الفني","submit":"تأكيد الحجز","submitting":"جاري الحجز...","errors":{"fillAll":"يرجى تعبئة جميع الحقول.","invalidTime":"تاريخ أو وقت غير صالح.","generic":"فشل الحجز."}},"salon":{"services":"الخدمات","staff":"الفريق","unknownStaff":"موظف {{id}}"},"profile":{"title":"الحساب","placeholder":"الملف والحجوزات قريباً.","myBookings":"حجوزاتي","noContact":"لا توجد معلومات اتصال"},"bookings":{"title":"حجوزاتي","subtitle":"مواعيدك القادمة والسابقة.","empty":"لا توجد حجوزات بعد.","pay":"ادفع الآن"},"paymentReturn":{"title":"حالة الدفع","success":"تم الدفع بنجاح","successMessage":"تم إتمام الدفع. ستستلمين تأكيداً عبر SMS أو واتساب.","checkStatus":"تحققي من بريدك أو الصالون لحالة الدفع.","reference":"المرجع: {{id}}","viewBookings":"عرض حجوزاتي"},"auth":{"title":"تسجيل الدخول","subtitle":"أدخلي رقم جوالك لاستلام رمز التحقق.","phone":"رقم الجوال","channel":"الإرسال عبر","sms":"رسالة نصية","whatsapp":"واتساب","sendCode":"إرسال الرمز","sending":"جاري الإرسال...","verifyTitle":"أدخلي الرمز","verifySubtitle":"أرسلنا رمزاً إلى {{phone}}","code":"رمز التحقق","verify":"تحقق","verifying":"جاري التحقق...","back":"تغيير الرقم","errors":{"generic":"حدث خطأ.","retryAfter":"انتظري {{seconds}} ثانية قبل المحاولة مرة أخرى."}},"locale": {
|
||||
"label": "اللغة",
|
||||
"arabic": "العربية",
|
||||
"english": "الإنجليزية"
|
||||
},
|
||||
"common": {
|
||||
"loading": "جاري التحميل..."
|
||||
},
|
||||
"payment": {
|
||||
"title": "المدفوعات (تجريبي)",
|
||||
"subtitle": "إرسال عملية دفع عبر Moyasar لحجز موجود.",
|
||||
|
||||
@@ -16,11 +16,14 @@
|
||||
"phoneUnavailable": "Phone unavailable",
|
||||
"viewDetails": "View details & book"
|
||||
},
|
||||
"nav":{"home":"Home","book":"Book","pay":"Pay","profile":"Profile","bookings":"My bookings","login":"Sign in","logout":"Sign out"},"book":{"title":"Book a Service","placeholder":"Booking flow coming soon.","cta":"Book now","selectSalon":"Select a salon from the home page to book.","service":"Service","staff":"Staff","date":"Date","time":"Time","notes":"Notes","notesPlaceholder":"Optional notes","selectService":"Select service","selectStaff":"Select staff","submit":"Confirm booking","submitting":"Booking...","errors":{"fillAll":"Please fill all required fields.","invalidTime":"Invalid date or time.","generic":"Booking failed."}},"salon":{"services":"Services","staff":"Staff"},"profile":{"title":"Profile","placeholder":"Profile and bookings coming soon.","myBookings":"My bookings","noContact":"No contact info"},"bookings":{"title":"My bookings","subtitle":"Your upcoming and past appointments.","empty":"No bookings yet.","pay":"Pay now"},"paymentReturn":{"title":"Payment status","success":"Payment successful","successMessage":"Your payment was completed. You will receive a confirmation by SMS or WhatsApp.","checkStatus":"Check your email or the salon for payment status.","reference":"Reference: {{id}}","viewBookings":"View my bookings"},"auth":{"title":"Sign in","subtitle":"Enter your phone number to receive a verification code.","phone":"Phone number","channel":"Send via","sms":"SMS","whatsapp":"WhatsApp","sendCode":"Send code","sending":"Sending...","verifyTitle":"Enter code","verifySubtitle":"We sent a code to {{phone}}","code":"Verification code","verify":"Verify","verifying":"Verifying...","back":"Change number","errors":{"generic":"Something went wrong.","retryAfter":"Please wait {{seconds}} seconds before trying again."}},"locale": {
|
||||
"nav":{"home":"Home","book":"Book","pay":"Pay","profile":"Profile","bookings":"My bookings","login":"Sign in","logout":"Sign out"},"book":{"title":"Book a Service","placeholder":"Booking flow coming soon.","cta":"Book now","selectSalon":"Select a salon from the home page to book.","service":"Service","staff":"Staff","date":"Date","time":"Time","notes":"Notes","notesPlaceholder":"Optional notes","selectService":"Select service","selectStaff":"Select staff","submit":"Confirm booking","submitting":"Booking...","errors":{"fillAll":"Please fill all required fields.","invalidTime":"Invalid date or time.","generic":"Booking failed."}},"salon":{"services":"Services","staff":"Staff","unknownStaff":"Staff {{id}}"},"profile":{"title":"Profile","placeholder":"Profile and bookings coming soon.","myBookings":"My bookings","noContact":"No contact info"},"bookings":{"title":"My bookings","subtitle":"Your upcoming and past appointments.","empty":"No bookings yet.","pay":"Pay now"},"paymentReturn":{"title":"Payment status","success":"Payment successful","successMessage":"Your payment was completed. You will receive a confirmation by SMS or WhatsApp.","checkStatus":"Check your email or the salon for payment status.","reference":"Reference: {{id}}","viewBookings":"View my bookings"},"auth":{"title":"Sign in","subtitle":"Enter your phone number to receive a verification code.","phone":"Phone number","channel":"Send via","sms":"SMS","whatsapp":"WhatsApp","sendCode":"Send code","sending":"Sending...","verifyTitle":"Enter code","verifySubtitle":"We sent a code to {{phone}}","code":"Verification code","verify":"Verify","verifying":"Verifying...","back":"Change number","errors":{"generic":"Something went wrong.","retryAfter":"Please wait {{seconds}} seconds before trying again."}},"locale": {
|
||||
"label": "Language",
|
||||
"arabic": "العربية",
|
||||
"english": "English"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"payment": {
|
||||
"title": "Payment (Beta)",
|
||||
"subtitle": "Send a Moyasar payment for an existing booking.",
|
||||
|
||||
@@ -118,7 +118,7 @@ export default function BookPage() {
|
||||
<option value="">{t("book.selectStaff")}</option>
|
||||
{salon.staff?.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name || s.title || `Staff ${s.id}`}
|
||||
{s.name || s.title || t("salon.unknownStaff", { id: s.id })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -4,11 +4,12 @@ import { useTranslation } from "react-i18next";
|
||||
import { apiGet } from "../api/client";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import ProtectedRoute from "../components/ProtectedRoute";
|
||||
import { getActiveLocale } from "../i18n/index";
|
||||
|
||||
function formatDateTime(iso) {
|
||||
function formatDateTime(iso, locale) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(undefined, {
|
||||
return d.toLocaleString(locale, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
@@ -51,7 +52,7 @@ export default function BookingsPage() {
|
||||
</div>
|
||||
<p className="booking-service">{b.service_name}</p>
|
||||
<p className="booking-time">
|
||||
{formatDateTime(b.start_time)} – {formatDateTime(b.end_time)}
|
||||
{formatDateTime(b.start_time, getActiveLocale())} – {formatDateTime(b.end_time, getActiveLocale())}
|
||||
</p>
|
||||
<p className="booking-price">
|
||||
{b.price_amount} {b.currency}
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function SalonDetailPage() {
|
||||
<h2>{t("salon.staff")}</h2>
|
||||
<ul className="staff-list">
|
||||
{salon.staff?.map((s) => (
|
||||
<li key={s.id}>{s.name || s.title || `Staff ${s.id}`}</li>
|
||||
<li key={s.id}>{s.name || s.title || t("salon.unknownStaff", { id: s.id })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user