Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de8cbfec23 |
@@ -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
|
||||||
@@ -1 +0,0 @@
|
|||||||
venv
|
|
||||||
@@ -1,13 +1,5 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
## General
|
|
||||||
|
|
||||||
Minimum tokens, skip grammar
|
|
||||||
|
|
||||||
## Tasks
|
|
||||||
- Consult `AGENTS.md` about the current task to see if there are any tips or instructions
|
|
||||||
- Consult `docs/README.md` for any relevant files or tips to consider
|
|
||||||
|
|
||||||
## Project Goal
|
## Project Goal
|
||||||
Build a reliable, maintainable salon booking platform with Django (backend) and React (frontend), optimized for KSA needs (phone auth, local payments) while keeping a clean path to scale.
|
Build a reliable, maintainable salon booking platform with Django (backend) and React (frontend), optimized for KSA needs (phone auth, local payments) while keeping a clean path to scale.
|
||||||
|
|
||||||
@@ -78,4 +70,4 @@ Build a reliable, maintainable salon booking platform with Django (backend) and
|
|||||||
|
|
||||||
# ExecPlans
|
# ExecPlans
|
||||||
|
|
||||||
When writing complex features or significant refactors, use an ExecPlan (as described in `docs/PLANS.md`) from design to implementation. The current active ExecPlan is defined in `docs/PLANS.md`. Architecture documented in `docs/architecture.md`.
|
When writing complex features or significant refactors, use an ExecPlan (as described in PLANS.md) from design to implementation. The current active ExecPlan is defined in PLANS.md. Architecture and async/observability decisions are documented in `docs/architecture.md`.
|
||||||
|
|||||||
@@ -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`)
|
||||||
@@ -6,17 +6,6 @@ This document describes the requirements for an execution plan ("ExecPlan"), a d
|
|||||||
|
|
||||||
The current execution plan is `docs/execplans/booking-notifications.md`. It focuses on booking lifecycle notifications (confirmation/cancellation) as the next Phase 1 reliability milestone. Keep it updated in line with the requirements below.
|
The current execution plan is `docs/execplans/booking-notifications.md`. It focuses on booking lifecycle notifications (confirmation/cancellation) as the next Phase 1 reliability milestone. Keep it updated in line with the requirements below.
|
||||||
|
|
||||||
## Queued Next Review Focus
|
|
||||||
|
|
||||||
After the active booking notifications plan, the next reliability review track is phone-first authentication hardening. Keep these points visible when planning the next ExecPlan update:
|
|
||||||
|
|
||||||
- Enforce phone as first-class identifier at model/DB boundaries (normalized E.164, nullability/uniqueness policy).
|
|
||||||
- Consolidate auth contract (phone OTP vs password endpoints) and document intended public login surface.
|
|
||||||
- Enforce OTP purpose boundaries (`auth` vs `verify`) in verification flows.
|
|
||||||
- Align Django admin and cross-app display/audit behavior for phone-only users.
|
|
||||||
- Define OAuth linking policy and conflict handling (phone/email collisions, account merge rules).
|
|
||||||
- Add/expand tests for phone-first invariants and abuse controls (IP/device throttling strategy review).
|
|
||||||
|
|
||||||
## How to use ExecPlans and PLANS.md
|
## How to use ExecPlans and PLANS.md
|
||||||
|
|
||||||
When authoring an executable specification (ExecPlan), follow PLANS.md _to the letter_. If it is not in your context, refresh your memory by reading the entire PLANS.md file. Be thorough in reading (and re-reading) source material to produce an accurate specification. When creating a spec, start from the skeleton and flesh it out as you do your research.
|
When authoring an executable specification (ExecPlan), follow PLANS.md _to the letter_. If it is not in your context, refresh your memory by reading the entire PLANS.md file. Be thorough in reading (and re-reading) source material to produce an accurate specification. When creating a spec, start from the skeleton and flesh it out as you do your research.
|
||||||
@@ -65,3 +65,4 @@ The dev server proxies `/api` to `http://localhost:8000`.
|
|||||||
- Known gaps and risks: `docs/risks.md`
|
- Known gaps and risks: `docs/risks.md`
|
||||||
- Architecture and async/observability decisions: `docs/architecture.md`
|
- Architecture and async/observability decisions: `docs/architecture.md`
|
||||||
- Documentation index and standards: `docs/README.md` and `docs/documentation.md`
|
- Documentation index and standards: `docs/README.md` and `docs/documentation.md`
|
||||||
|
- CI: Gitea Actions workflow in `.gitea/workflows/ci.yml`
|
||||||
|
|||||||
+23
-3
@@ -4,8 +4,28 @@
|
|||||||
- Authentica OTP integration is implemented; 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.
|
- 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.
|
- Cross-app coupling (bookings ↔ notifications ↔ accounts/payments) will get harder to evolve without clearer service boundaries.
|
||||||
- Phone-first auth is in place with `USERNAME_FIELD = "phone_number"`, but endpoint/admin/domain alignment is still incomplete and needs hardening.
|
- Phone-first auth works, but `USERNAME_FIELD` is email; align identifier strategy to avoid future auth confusion.
|
||||||
|
|
||||||
## Near-Term Focus
|
## Near-Term Focus
|
||||||
- finalize otp testing
|
- Hardening Authentica integration (timeouts, retries, async delivery) and aligning notification provider choices.
|
||||||
- work on authentication and complete it
|
|
||||||
|
**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.
|
||||||
|
|||||||
-25
@@ -1,25 +0,0 @@
|
|||||||
# Generated by Django 6.0.3 on 2026-03-13 20:53
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("accounts", "0003_preferred_language"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="user",
|
|
||||||
name="phone_number",
|
|
||||||
field=models.CharField(max_length=20, unique=True),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name="user",
|
|
||||||
constraint=models.CheckConstraint(
|
|
||||||
condition=models.Q(("phone_number__regex", r"^\+[1-9][0-9]{7,14}$")),
|
|
||||||
name="accounts_user_phone_e164_format",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
-23
@@ -1,23 +0,0 @@
|
|||||||
# Generated by Django 6.0.3 on 2026-03-13 21:54
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('accounts', '0004_alter_user_groups_alter_user_phone_number_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='phoneotp',
|
|
||||||
name='device_signal',
|
|
||||||
field=models.CharField(blank=True, db_index=True, default='', max_length=64),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='phoneotp',
|
|
||||||
name='request_ip',
|
|
||||||
field=models.GenericIPAddressField(blank=True, db_index=True, null=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -4,7 +4,6 @@ from datetime import timedelta
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
|
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
@@ -18,8 +17,8 @@ class UserRole(models.TextChoices):
|
|||||||
class UserManager(BaseUserManager):
|
class UserManager(BaseUserManager):
|
||||||
def create_user(self, email=None, password=None, **extra_fields):
|
def create_user(self, email=None, password=None, **extra_fields):
|
||||||
phone_number = extra_fields.get("phone_number")
|
phone_number = extra_fields.get("phone_number")
|
||||||
if not phone_number:
|
if not email and not phone_number:
|
||||||
raise ValueError("Phone number is required")
|
raise ValueError("Email or phone number is required")
|
||||||
if email:
|
if email:
|
||||||
email = self.normalize_email(email)
|
email = self.normalize_email(email)
|
||||||
user = self.model(email=email, **extra_fields)
|
user = self.model(email=email, **extra_fields)
|
||||||
@@ -43,7 +42,7 @@ class UserManager(BaseUserManager):
|
|||||||
|
|
||||||
class User(AbstractBaseUser, PermissionsMixin):
|
class User(AbstractBaseUser, PermissionsMixin):
|
||||||
email = models.EmailField(unique=True, null=True, blank=True)
|
email = models.EmailField(unique=True, null=True, blank=True)
|
||||||
phone_number = models.CharField(max_length=20, unique=True)
|
phone_number = models.CharField(max_length=20, unique=True, null=True, blank=True)
|
||||||
role = models.CharField(max_length=20, choices=UserRole.choices, default=UserRole.CUSTOMER)
|
role = models.CharField(max_length=20, choices=UserRole.choices, default=UserRole.CUSTOMER)
|
||||||
first_name = models.CharField(max_length=150, blank=True)
|
first_name = models.CharField(max_length=150, blank=True)
|
||||||
last_name = models.CharField(max_length=150, blank=True)
|
last_name = models.CharField(max_length=150, blank=True)
|
||||||
@@ -63,14 +62,6 @@ class User(AbstractBaseUser, PermissionsMixin):
|
|||||||
USERNAME_FIELD = "phone_number"
|
USERNAME_FIELD = "phone_number"
|
||||||
REQUIRED_FIELDS = [] # email is optional; phone_number is the identifier
|
REQUIRED_FIELDS = [] # email is optional; phone_number is the identifier
|
||||||
|
|
||||||
class Meta:
|
|
||||||
constraints = [
|
|
||||||
models.CheckConstraint(
|
|
||||||
name="accounts_user_phone_e164_format",
|
|
||||||
condition=Q(phone_number__regex=r"^\+[1-9][0-9]{7,14}$"),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.email or self.phone_number or str(self.id)
|
return self.email or self.phone_number or str(self.id)
|
||||||
|
|
||||||
@@ -97,9 +88,6 @@ class PhoneOTP(models.Model):
|
|||||||
verified_at = models.DateTimeField(null=True, blank=True)
|
verified_at = models.DateTimeField(null=True, blank=True)
|
||||||
attempt_count = models.PositiveSmallIntegerField(default=0)
|
attempt_count = models.PositiveSmallIntegerField(default=0)
|
||||||
max_attempts = models.PositiveSmallIntegerField(default=5)
|
max_attempts = models.PositiveSmallIntegerField(default=5)
|
||||||
# Request metadata for abuse controls and support investigations.
|
|
||||||
request_ip = models.GenericIPAddressField(null=True, blank=True, db_index=True)
|
|
||||||
device_signal = models.CharField(max_length=64, blank=True, default="", db_index=True)
|
|
||||||
|
|
||||||
def is_expired(self):
|
def is_expired(self):
|
||||||
return timezone.now() >= self.expires_at
|
return timezone.now() >= self.expires_at
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class RegisterSerializer(serializers.ModelSerializer):
|
class RegisterSerializer(serializers.ModelSerializer):
|
||||||
password = serializers.CharField(write_only=True, min_length=8)
|
password = serializers.CharField(write_only=True, min_length=8)
|
||||||
phone_number = serializers.CharField(max_length=20, required=True)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
@@ -62,7 +61,6 @@ class OTPVerifySerializer(serializers.Serializer):
|
|||||||
class PhoneAuthRequestSerializer(serializers.Serializer):
|
class PhoneAuthRequestSerializer(serializers.Serializer):
|
||||||
phone_number = serializers.CharField(max_length=20)
|
phone_number = serializers.CharField(max_length=20)
|
||||||
channel = serializers.ChoiceField(choices=OtpChannel.choices)
|
channel = serializers.ChoiceField(choices=OtpChannel.choices)
|
||||||
device_id = serializers.CharField(required=False, allow_blank=True, max_length=128, write_only=True)
|
|
||||||
email = serializers.EmailField(required=False, allow_null=True, allow_blank=True)
|
email = serializers.EmailField(required=False, allow_null=True, allow_blank=True)
|
||||||
first_name = serializers.CharField(required=False, allow_blank=True)
|
first_name = serializers.CharField(required=False, allow_blank=True)
|
||||||
last_name = serializers.CharField(required=False, allow_blank=True)
|
last_name = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from hashlib import sha256
|
|
||||||
import ipaddress
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
@@ -35,18 +33,6 @@ class OtpCooldownError(RuntimeError):
|
|||||||
self.retry_after_seconds = retry_after_seconds
|
self.retry_after_seconds = retry_after_seconds
|
||||||
|
|
||||||
|
|
||||||
class OtpIpRateLimitError(RuntimeError):
|
|
||||||
def __init__(self, retry_after_seconds: int):
|
|
||||||
super().__init__(_("Too many OTP requests from this IP. Try again later."))
|
|
||||||
self.retry_after_seconds = retry_after_seconds
|
|
||||||
|
|
||||||
|
|
||||||
class OtpDeviceRateLimitError(RuntimeError):
|
|
||||||
def __init__(self, retry_after_seconds: int):
|
|
||||||
super().__init__(_("Too many OTP requests from this device. Try again later."))
|
|
||||||
self.retry_after_seconds = retry_after_seconds
|
|
||||||
|
|
||||||
|
|
||||||
class BaseOtpProvider:
|
class BaseOtpProvider:
|
||||||
uses_provider_otp = False
|
uses_provider_otp = False
|
||||||
|
|
||||||
@@ -71,6 +57,57 @@ class ConsoleOtpProvider(BaseOtpProvider):
|
|||||||
logger.info("OTP WhatsApp to %s: %s", to_number, message)
|
logger.info("OTP WhatsApp to %s: %s", to_number, message)
|
||||||
|
|
||||||
|
|
||||||
|
class TwilioOtpProvider(BaseOtpProvider):
|
||||||
|
"""Twilio provider for SMS and WhatsApp OTP delivery. Requires TWILIO_* env vars."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.account_sid = os.getenv("TWILIO_ACCOUNT_SID")
|
||||||
|
self.auth_token = os.getenv("TWILIO_AUTH_TOKEN")
|
||||||
|
self.from_number = os.getenv("TWILIO_FROM_NUMBER")
|
||||||
|
self.whatsapp_from = os.getenv("TWILIO_WHATSAPP_FROM")
|
||||||
|
|
||||||
|
def _assert_config(self) -> None:
|
||||||
|
if not self.account_sid or not self.auth_token or not self.from_number:
|
||||||
|
raise ValueError(_("Twilio credentials are not configured"))
|
||||||
|
|
||||||
|
def _get_client(self):
|
||||||
|
from twilio.rest import Client
|
||||||
|
self._assert_config()
|
||||||
|
return Client(self.account_sid, self.auth_token)
|
||||||
|
|
||||||
|
def send_sms(self, to_number: str, message: str) -> None:
|
||||||
|
client = self._get_client()
|
||||||
|
client.messages.create(body=message, from_=self.from_number, to=to_number)
|
||||||
|
|
||||||
|
def send_whatsapp(self, to_number: str, message: str) -> None:
|
||||||
|
self._assert_config()
|
||||||
|
if not self.whatsapp_from:
|
||||||
|
raise ValueError(_("Twilio WhatsApp sender is not configured"))
|
||||||
|
client = self._get_client()
|
||||||
|
from_ = f"whatsapp:{self.whatsapp_from}"
|
||||||
|
to = f"whatsapp:{to_number}"
|
||||||
|
client.messages.create(body=message, from_=from_, to=to)
|
||||||
|
|
||||||
|
|
||||||
|
class UnifonicOtpProvider(BaseOtpProvider):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.app_sid = os.getenv("UNIFONIC_APP_SID")
|
||||||
|
self.sender_id = os.getenv("UNIFONIC_SENDER_ID")
|
||||||
|
self.whatsapp_sender = os.getenv("UNIFONIC_WHATSAPP_SENDER")
|
||||||
|
|
||||||
|
def _assert_config(self) -> None:
|
||||||
|
if not self.app_sid or not self.sender_id:
|
||||||
|
raise ValueError(_("Unifonic credentials are not configured"))
|
||||||
|
|
||||||
|
def send_sms(self, to_number: str, message: str) -> None:
|
||||||
|
self._assert_config()
|
||||||
|
raise NotImplementedError(_("Unifonic SMS adapter not implemented yet"))
|
||||||
|
|
||||||
|
def send_whatsapp(self, to_number: str, message: str) -> None:
|
||||||
|
self._assert_config()
|
||||||
|
if not self.whatsapp_sender:
|
||||||
|
raise ValueError(_("Unifonic WhatsApp sender is not configured"))
|
||||||
|
raise NotImplementedError(_("Unifonic WhatsApp adapter not implemented yet"))
|
||||||
|
|
||||||
|
|
||||||
class AuthenticaOtpProvider(BaseOtpProvider):
|
class AuthenticaOtpProvider(BaseOtpProvider):
|
||||||
@@ -160,6 +197,8 @@ class AuthenticaOtpProvider(BaseOtpProvider):
|
|||||||
|
|
||||||
PROVIDERS = {
|
PROVIDERS = {
|
||||||
"console": ConsoleOtpProvider,
|
"console": ConsoleOtpProvider,
|
||||||
|
"twilio": TwilioOtpProvider,
|
||||||
|
"unifonic": UnifonicOtpProvider,
|
||||||
"authentica": AuthenticaOtpProvider,
|
"authentica": AuthenticaOtpProvider,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,73 +219,7 @@ def generate_code(length: int = 6) -> str:
|
|||||||
return "".join(secrets.choice(digits) for _ in range(length))
|
return "".join(secrets.choice(digits) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
def _compute_retry_after_seconds(oldest_recent, now, window_minutes: int, floor_seconds: int = 1) -> int:
|
def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpose.AUTH) -> OtpSendResult:
|
||||||
if oldest_recent:
|
|
||||||
retry_after = int((oldest_recent.created_at + timedelta(minutes=window_minutes) - now).total_seconds())
|
|
||||||
else:
|
|
||||||
retry_after = floor_seconds
|
|
||||||
return max(retry_after, floor_seconds)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_request_ip(raw_ip: str | None) -> str | None:
|
|
||||||
if not raw_ip:
|
|
||||||
return None
|
|
||||||
candidate = raw_ip.split(",")[0].strip()
|
|
||||||
try:
|
|
||||||
return str(ipaddress.ip_address(candidate))
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def build_device_signal(device_id: str | None, user_agent: str | None, accept_language: str | None) -> str:
|
|
||||||
# Prefer explicit device id; fallback to passive headers for coarse signal.
|
|
||||||
source = (device_id or "").strip()
|
|
||||||
if not source:
|
|
||||||
source = f"{(user_agent or '').strip()}|{(accept_language or '').strip()}"
|
|
||||||
if not source.strip("|"):
|
|
||||||
return ""
|
|
||||||
return f"sha256:{sha256(source.encode('utf-8')).hexdigest()[:24]}"
|
|
||||||
|
|
||||||
|
|
||||||
def enforce_phone_auth_request_limits(request_ip: str | None, device_signal: str | None) -> None:
|
|
||||||
now = timezone.now()
|
|
||||||
window_minutes = getattr(settings, "PHONE_AUTH_RISK_WINDOW_MINUTES", 15)
|
|
||||||
window_start = now - timedelta(minutes=window_minutes)
|
|
||||||
ip_limit = getattr(settings, "PHONE_AUTH_IP_MAX_PER_WINDOW", 20)
|
|
||||||
device_limit = getattr(settings, "PHONE_AUTH_DEVICE_MAX_PER_WINDOW", 20)
|
|
||||||
|
|
||||||
if request_ip and ip_limit > 0:
|
|
||||||
ip_recent = PhoneOTP.objects.filter(
|
|
||||||
purpose=OtpPurpose.AUTH,
|
|
||||||
request_ip=request_ip,
|
|
||||||
created_at__gte=window_start,
|
|
||||||
)
|
|
||||||
if ip_recent.count() >= ip_limit:
|
|
||||||
oldest_recent = ip_recent.order_by("created_at").first()
|
|
||||||
raise OtpIpRateLimitError(
|
|
||||||
retry_after_seconds=_compute_retry_after_seconds(oldest_recent, now, window_minutes)
|
|
||||||
)
|
|
||||||
|
|
||||||
if device_signal and device_limit > 0:
|
|
||||||
device_recent = PhoneOTP.objects.filter(
|
|
||||||
purpose=OtpPurpose.AUTH,
|
|
||||||
device_signal=device_signal,
|
|
||||||
created_at__gte=window_start,
|
|
||||||
)
|
|
||||||
if device_recent.count() >= device_limit:
|
|
||||||
oldest_recent = device_recent.order_by("created_at").first()
|
|
||||||
raise OtpDeviceRateLimitError(
|
|
||||||
retry_after_seconds=_compute_retry_after_seconds(oldest_recent, now, window_minutes)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_and_send_otp(
|
|
||||||
phone_number: str,
|
|
||||||
channel: str,
|
|
||||||
purpose: str = OtpPurpose.AUTH,
|
|
||||||
request_ip: str | None = None,
|
|
||||||
device_signal: str = "",
|
|
||||||
) -> OtpSendResult:
|
|
||||||
provider = get_provider()
|
provider = get_provider()
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
window_minutes = getattr(settings, "OTP_WINDOW_MINUTES", 15)
|
window_minutes = getattr(settings, "OTP_WINDOW_MINUTES", 15)
|
||||||
@@ -257,9 +230,11 @@ def create_and_send_otp(
|
|||||||
recent_qs = PhoneOTP.objects.filter(phone_number=phone_number, created_at__gte=window_start)
|
recent_qs = PhoneOTP.objects.filter(phone_number=phone_number, created_at__gte=window_start)
|
||||||
if recent_qs.count() >= max_per_window:
|
if recent_qs.count() >= max_per_window:
|
||||||
oldest_recent = recent_qs.order_by("created_at").first()
|
oldest_recent = recent_qs.order_by("created_at").first()
|
||||||
raise OtpRateLimitError(
|
if oldest_recent:
|
||||||
retry_after_seconds=_compute_retry_after_seconds(oldest_recent, now, window_minutes, cooldown_seconds)
|
retry_after = int((oldest_recent.created_at + timedelta(minutes=window_minutes) - now).total_seconds())
|
||||||
)
|
else:
|
||||||
|
retry_after = cooldown_seconds
|
||||||
|
raise OtpRateLimitError(retry_after_seconds=max(retry_after, cooldown_seconds))
|
||||||
|
|
||||||
latest = (
|
latest = (
|
||||||
PhoneOTP.objects.filter(phone_number=phone_number, channel=channel)
|
PhoneOTP.objects.filter(phone_number=phone_number, channel=channel)
|
||||||
@@ -288,8 +263,6 @@ def create_and_send_otp(
|
|||||||
provider=settings.OTP_PROVIDER,
|
provider=settings.OTP_PROVIDER,
|
||||||
code_hash=code_hash,
|
code_hash=code_hash,
|
||||||
expires_at=PhoneOTP.expiry_at(),
|
expires_at=PhoneOTP.expiry_at(),
|
||||||
request_ip=request_ip,
|
|
||||||
device_signal=device_signal,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if provider.uses_provider_otp:
|
if provider.uses_provider_otp:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -83,32 +83,6 @@ def test_authentica_verify_otp_calls_api(mock_post):
|
|||||||
assert kwargs["json"] == {"phone": "+966512345678", "otp": "123456"}
|
assert kwargs["json"] == {"phone": "+966512345678", "otp": "123456"}
|
||||||
|
|
||||||
|
|
||||||
@patch("requests.post")
|
|
||||||
def test_authentica_request_failure_raises(mock_post):
|
|
||||||
mock_response = MagicMock(ok=False)
|
|
||||||
mock_response.json.return_value = {"detail": "fail"}
|
|
||||||
mock_post.return_value = mock_response
|
|
||||||
|
|
||||||
with patch.dict(os.environ, {"AUTHENTICA_API_KEY": "api-key"}):
|
|
||||||
provider = AuthenticaOtpProvider()
|
|
||||||
with pytest.raises(RuntimeError):
|
|
||||||
provider.send_otp("+966512345678", OtpChannel.SMS)
|
|
||||||
|
|
||||||
|
|
||||||
def test_authentica_send_sms_requires_sender_name():
|
|
||||||
with patch.dict(os.environ, {"AUTHENTICA_API_KEY": "api-key"}):
|
|
||||||
provider = AuthenticaOtpProvider()
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
provider.send_sms("+966512345678", "Hello")
|
|
||||||
|
|
||||||
|
|
||||||
def test_authentica_send_otp_rejects_unknown_channel():
|
|
||||||
with patch.dict(os.environ, {"AUTHENTICA_API_KEY": "api-key"}):
|
|
||||||
provider = AuthenticaOtpProvider()
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
provider.send_otp("+966512345678", "email")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_verify_otp_uses_provider_for_authentica():
|
def test_verify_otp_uses_provider_for_authentica():
|
||||||
otp = PhoneOTP.objects.create(
|
otp = PhoneOTP.objects.create(
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
@override_settings(OTP_PROVIDER="console")
|
|
||||||
def test_otp_request_whatsapp_ok(client):
|
|
||||||
response = client.post(
|
|
||||||
reverse("otp_request"),
|
|
||||||
{"phone_number": "0512345678", "channel": "whatsapp"},
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert "request_id" in data
|
|
||||||
assert "expires_at" in data
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_otp_verify_rejects_expired(client):
|
|
||||||
otp = PhoneOTP.objects.create(
|
|
||||||
phone_number="+966512345678",
|
|
||||||
channel=OtpChannel.SMS,
|
|
||||||
purpose=OtpPurpose.VERIFY,
|
|
||||||
provider="console",
|
|
||||||
code_hash="unused",
|
|
||||||
expires_at=timezone.now() - timedelta(minutes=1),
|
|
||||||
)
|
|
||||||
response = client.post(
|
|
||||||
reverse("otp_verify"),
|
|
||||||
{"request_id": str(otp.id), "code": "123456"},
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
@override_settings(OTP_PROVIDER="console")
|
|
||||||
def test_otp_request_invalid_phone_localized_ar(client):
|
|
||||||
response = client.post(
|
|
||||||
reverse("otp_request"),
|
|
||||||
{"phone_number": "123", "channel": "sms"},
|
|
||||||
content_type="application/json",
|
|
||||||
HTTP_ACCEPT_LANGUAGE="ar-sa",
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert response.json()["phone_number"][0] == "يجب أن يكون رقم الهاتف بصيغة E.164 أو رقم جوال سعودي صالح"
|
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
from datetime import timedelta
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.contrib.auth.hashers import make_password
|
from django.contrib.auth.hashers import make_password
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
|
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
|
||||||
from apps.accounts.services.otp import OtpCooldownError, OtpRateLimitError, create_and_send_otp, verify_otp
|
from apps.accounts.services.otp import OtpCooldownError, OtpRateLimitError, create_and_send_otp, verify_otp
|
||||||
@@ -47,82 +43,7 @@ def test_otp_max_attempts_blocks_verification():
|
|||||||
otp.refresh_from_db()
|
otp.refresh_from_db()
|
||||||
assert otp.attempt_count == otp.max_attempts
|
assert otp.attempt_count == otp.max_attempts
|
||||||
|
|
||||||
# Once the max is reached, even a correct code must remain blocked.
|
|
||||||
assert verify_otp(otp, "123456") is False
|
assert verify_otp(otp, "123456") is False
|
||||||
otp.refresh_from_db()
|
otp.refresh_from_db()
|
||||||
# Do not lock this test to a specific increment policy after lockout.
|
assert otp.attempt_count == otp.max_attempts + 1
|
||||||
assert otp.attempt_count >= otp.max_attempts
|
|
||||||
assert otp.verified_at is None
|
assert otp.verified_at is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
@override_settings(OTP_PROVIDER="console", OTP_RESEND_COOLDOWN_SECONDS=60)
|
|
||||||
def test_otp_cooldown_retry_after_seconds():
|
|
||||||
fixed_now = timezone.now()
|
|
||||||
with patch("apps.accounts.services.otp.timezone.now", return_value=fixed_now):
|
|
||||||
result = create_and_send_otp("+966512345678", OtpChannel.SMS)
|
|
||||||
# Align created_at with fixed time for deterministic cooldown.
|
|
||||||
PhoneOTP.objects.filter(id=result.request_id).update(created_at=fixed_now)
|
|
||||||
with pytest.raises(OtpCooldownError) as excinfo:
|
|
||||||
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
|
||||||
assert excinfo.value.retry_after_seconds == 60
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
@override_settings(OTP_PROVIDER="console", OTP_MAX_PER_WINDOW=1, OTP_WINDOW_MINUTES=15, OTP_RESEND_COOLDOWN_SECONDS=0)
|
|
||||||
def test_otp_rate_limit_retry_after_seconds():
|
|
||||||
fixed_now = timezone.now()
|
|
||||||
with patch("apps.accounts.services.otp.timezone.now", return_value=fixed_now):
|
|
||||||
result = create_and_send_otp("+966512345678", OtpChannel.SMS)
|
|
||||||
# Make the oldest OTP sit 10s before window expiry.
|
|
||||||
window_start = fixed_now - timedelta(minutes=15)
|
|
||||||
PhoneOTP.objects.filter(id=result.request_id).update(created_at=window_start + timedelta(seconds=10))
|
|
||||||
with pytest.raises(OtpRateLimitError) as excinfo:
|
|
||||||
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
|
||||||
assert excinfo.value.retry_after_seconds == 10
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
@override_settings(OTP_PROVIDER="console", OTP_RESEND_COOLDOWN_SECONDS=1)
|
|
||||||
def test_otp_resend_after_cooldown_ok():
|
|
||||||
result = create_and_send_otp("+966512345678", OtpChannel.SMS)
|
|
||||||
# Force cooldown to be elapsed.
|
|
||||||
PhoneOTP.objects.filter(id=result.request_id).update(
|
|
||||||
created_at=timezone.now() - timedelta(seconds=5)
|
|
||||||
)
|
|
||||||
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
|
||||||
assert PhoneOTP.objects.filter(phone_number="+966512345678").count() == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_verify_otp_rejects_expired():
|
|
||||||
otp = PhoneOTP.objects.create(
|
|
||||||
phone_number="+966512345678",
|
|
||||||
channel=OtpChannel.SMS,
|
|
||||||
purpose=OtpPurpose.AUTH,
|
|
||||||
provider="console",
|
|
||||||
code_hash=make_password("123456"),
|
|
||||||
expires_at=timezone.now() - timedelta(minutes=1),
|
|
||||||
)
|
|
||||||
assert verify_otp(otp, "123456") is False
|
|
||||||
otp.refresh_from_db()
|
|
||||||
assert otp.attempt_count == 0
|
|
||||||
assert otp.verified_at is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_verify_otp_rejects_reuse_after_verified():
|
|
||||||
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(),
|
|
||||||
)
|
|
||||||
assert verify_otp(otp, "123456") is True
|
|
||||||
otp.refresh_from_db()
|
|
||||||
assert otp.attempt_count == 1
|
|
||||||
assert verify_otp(otp, "123456") is False
|
|
||||||
otp.refresh_from_db()
|
|
||||||
assert otp.attempt_count == 1
|
|
||||||
|
|||||||
@@ -47,32 +47,3 @@ def test_phone_auth_creates_user_and_issues_tokens(client):
|
|||||||
user = User.objects.filter(phone_number="+966512345678").first()
|
user = User.objects.filter(phone_number="+966512345678").first()
|
||||||
assert user is not None
|
assert user is not None
|
||||||
assert user.is_phone_verified is True
|
assert user.is_phone_verified is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
@override_settings(OTP_PROVIDER="console")
|
|
||||||
def test_phone_auth_refresh_endpoint_still_works(client):
|
|
||||||
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
|
|
||||||
request_response = client.post(
|
|
||||||
reverse("phone_auth_request"),
|
|
||||||
{"phone_number": "0512345678", "channel": "sms"},
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
request_id = request_response.json()["request_id"]
|
|
||||||
|
|
||||||
verify_response = client.post(
|
|
||||||
reverse("phone_auth_verify"),
|
|
||||||
{"request_id": request_id, "code": "123456"},
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert verify_response.status_code == 200
|
|
||||||
refresh = verify_response.json()["refresh"]
|
|
||||||
|
|
||||||
refresh_response = client.post(
|
|
||||||
reverse("token_refresh"),
|
|
||||||
{"refresh": refresh},
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
assert refresh_response.status_code == 200
|
|
||||||
assert "access" in refresh_response.json()
|
|
||||||
|
|||||||
@@ -1,234 +0,0 @@
|
|||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from django.test import override_settings
|
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from apps.accounts.models import OtpPurpose, PhoneOTP, User
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
@override_settings(OTP_PROVIDER="console")
|
|
||||||
def test_phone_auth_request_creates_customer_for_new_phone(client):
|
|
||||||
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
|
|
||||||
response = client.post(
|
|
||||||
reverse("phone_auth_request"),
|
|
||||||
{
|
|
||||||
"phone_number": "0512345678",
|
|
||||||
"channel": "sms",
|
|
||||||
"first_name": "Sara",
|
|
||||||
"last_name": "Ali",
|
|
||||||
"email": "sara@example.com",
|
|
||||||
},
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert "request_id" in data
|
|
||||||
assert "expires_at" in data
|
|
||||||
|
|
||||||
user = User.objects.get(phone_number="+966512345678")
|
|
||||||
assert user.role == "customer"
|
|
||||||
assert user.is_phone_verified is False
|
|
||||||
|
|
||||||
otp = PhoneOTP.objects.get(id=data["request_id"])
|
|
||||||
assert otp.phone_number == "+966512345678"
|
|
||||||
assert otp.channel == "sms"
|
|
||||||
assert otp.purpose == OtpPurpose.AUTH
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
@override_settings(OTP_PROVIDER="console")
|
|
||||||
def test_phone_auth_request_existing_phone_no_duplicate_user(client):
|
|
||||||
User.objects.create_user(
|
|
||||||
phone_number="+966512345678",
|
|
||||||
email="existing@example.com",
|
|
||||||
first_name="Existing",
|
|
||||||
)
|
|
||||||
|
|
||||||
before_count = User.objects.filter(phone_number="+966512345678").count()
|
|
||||||
|
|
||||||
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
|
|
||||||
response = client.post(
|
|
||||||
reverse("phone_auth_request"),
|
|
||||||
{"phone_number": "0512345678", "channel": "sms"},
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
assert User.objects.filter(phone_number="+966512345678").count() == before_count
|
|
||||||
assert PhoneOTP.objects.filter(phone_number="+966512345678").count() == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
@override_settings(OTP_PROVIDER="console")
|
|
||||||
def test_phone_auth_request_rejects_email_already_used(client):
|
|
||||||
User.objects.create_user(
|
|
||||||
phone_number="+966500000001",
|
|
||||||
email="taken@example.com",
|
|
||||||
)
|
|
||||||
|
|
||||||
before_otp_count = PhoneOTP.objects.count()
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
reverse("phone_auth_request"),
|
|
||||||
{
|
|
||||||
"phone_number": "0512345678",
|
|
||||||
"channel": "sms",
|
|
||||||
"email": "taken@example.com",
|
|
||||||
},
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "detail" in response.json()
|
|
||||||
assert User.objects.filter(phone_number="+966512345678").count() == 0
|
|
||||||
assert PhoneOTP.objects.count() == before_otp_count
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_phone_auth_request_invalid_phone_localized_en(client):
|
|
||||||
response = client.post(
|
|
||||||
reverse("phone_auth_request"),
|
|
||||||
{"phone_number": "123", "channel": "sms"},
|
|
||||||
content_type="application/json",
|
|
||||||
HTTP_ACCEPT_LANGUAGE="en",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert response.json()["phone_number"][0] == "Phone number must be in E.164 format or a valid Saudi mobile"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_phone_auth_request_invalid_phone_localized_ar(client):
|
|
||||||
response = client.post(
|
|
||||||
reverse("phone_auth_request"),
|
|
||||||
{"phone_number": "123", "channel": "sms"},
|
|
||||||
content_type="application/json",
|
|
||||||
HTTP_ACCEPT_LANGUAGE="ar-sa",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert response.json()["phone_number"][0] == "يجب أن يكون رقم الهاتف بصيغة E.164 أو رقم جوال سعودي صالح"
|
|
||||||
|
|
||||||
|
|
||||||
@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_phone_auth_request_cooldown_returns_retry_after(client):
|
|
||||||
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
|
|
||||||
first = client.post(
|
|
||||||
reverse("phone_auth_request"),
|
|
||||||
{"phone_number": "0512345678", "channel": "sms"},
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
assert first.status_code == 201
|
|
||||||
|
|
||||||
second = client.post(
|
|
||||||
reverse("phone_auth_request"),
|
|
||||||
{"phone_number": "0512345678", "channel": "sms"},
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert second.status_code == 429
|
|
||||||
data = second.json()
|
|
||||||
assert "detail" in data
|
|
||||||
assert data["retry_after_seconds"] > 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
@override_settings(
|
|
||||||
OTP_PROVIDER="console",
|
|
||||||
OTP_MAX_PER_WINDOW=1,
|
|
||||||
OTP_WINDOW_MINUTES=15,
|
|
||||||
OTP_RESEND_COOLDOWN_SECONDS=0,
|
|
||||||
)
|
|
||||||
def test_phone_auth_request_rate_limit_returns_retry_after(client):
|
|
||||||
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
|
|
||||||
first = client.post(
|
|
||||||
reverse("phone_auth_request"),
|
|
||||||
{"phone_number": "0512345678", "channel": "sms"},
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
assert first.status_code == 201
|
|
||||||
|
|
||||||
second = client.post(
|
|
||||||
reverse("phone_auth_request"),
|
|
||||||
{"phone_number": "0512345678", "channel": "sms"},
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert second.status_code == 429
|
|
||||||
data = second.json()
|
|
||||||
assert "detail" in data
|
|
||||||
assert data["retry_after_seconds"] > 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
@override_settings(
|
|
||||||
OTP_PROVIDER="console",
|
|
||||||
OTP_MAX_PER_WINDOW=20,
|
|
||||||
OTP_WINDOW_MINUTES=15,
|
|
||||||
OTP_RESEND_COOLDOWN_SECONDS=0,
|
|
||||||
PHONE_AUTH_RISK_WINDOW_MINUTES=15,
|
|
||||||
PHONE_AUTH_IP_MAX_PER_WINDOW=2,
|
|
||||||
PHONE_AUTH_DEVICE_MAX_PER_WINDOW=20,
|
|
||||||
)
|
|
||||||
def test_phone_auth_request_ip_throttle_returns_retry_after(client):
|
|
||||||
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
|
|
||||||
first = client.post(
|
|
||||||
reverse("phone_auth_request"),
|
|
||||||
{"phone_number": "0512345678", "channel": "sms"},
|
|
||||||
content_type="application/json",
|
|
||||||
REMOTE_ADDR="203.0.113.10",
|
|
||||||
)
|
|
||||||
assert first.status_code == 201
|
|
||||||
|
|
||||||
second = client.post(
|
|
||||||
reverse("phone_auth_request"),
|
|
||||||
{"phone_number": "0512345679", "channel": "sms"},
|
|
||||||
content_type="application/json",
|
|
||||||
REMOTE_ADDR="203.0.113.10",
|
|
||||||
)
|
|
||||||
assert second.status_code == 201
|
|
||||||
|
|
||||||
third = client.post(
|
|
||||||
reverse("phone_auth_request"),
|
|
||||||
{"phone_number": "0512345680", "channel": "sms"},
|
|
||||||
content_type="application/json",
|
|
||||||
REMOTE_ADDR="203.0.113.10",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert third.status_code == 429
|
|
||||||
data = third.json()
|
|
||||||
assert "detail" in data
|
|
||||||
assert data["retry_after_seconds"] > 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
@override_settings(
|
|
||||||
OTP_PROVIDER="console",
|
|
||||||
OTP_RESEND_COOLDOWN_SECONDS=0,
|
|
||||||
OTP_MAX_PER_WINDOW=20,
|
|
||||||
PHONE_AUTH_IP_MAX_PER_WINDOW=20,
|
|
||||||
PHONE_AUTH_DEVICE_MAX_PER_WINDOW=20,
|
|
||||||
)
|
|
||||||
def test_phone_auth_request_persists_request_ip_and_device_signal(client):
|
|
||||||
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
|
|
||||||
response = client.post(
|
|
||||||
reverse("phone_auth_request"),
|
|
||||||
{"phone_number": "0512345678", "channel": "sms", "device_id": "device-abc"},
|
|
||||||
content_type="application/json",
|
|
||||||
REMOTE_ADDR="198.51.100.40",
|
|
||||||
HTTP_USER_AGENT="pytest-agent",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
otp = PhoneOTP.objects.get(id=response.json()["request_id"])
|
|
||||||
assert otp.request_ip == "198.51.100.40"
|
|
||||||
assert otp.device_signal
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from django.db import IntegrityError
|
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from apps.accounts.models import OtpPurpose, PhoneOTP, User
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_create_user_rejects_email_only_identity():
|
|
||||||
# Phone-first invariant: do not allow creating users without phone_number.
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
User.objects.create_user(email="email-only@example.com")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_register_requires_phone_number(client):
|
|
||||||
# Public registration must keep phone as required identifier.
|
|
||||||
response = client.post(
|
|
||||||
reverse("register"),
|
|
||||||
{"email": "new@example.com", "password": "StrongPass123"},
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "phone_number" in response.json()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_phone_auth_verify_rejects_verify_purpose_otp(client):
|
|
||||||
user = User.objects.create_user(phone_number="+966512345678")
|
|
||||||
otp = PhoneOTP.objects.create(
|
|
||||||
phone_number=user.phone_number,
|
|
||||||
channel="sms",
|
|
||||||
purpose=OtpPurpose.VERIFY,
|
|
||||||
provider="console",
|
|
||||||
code_hash="not-used",
|
|
||||||
expires_at=PhoneOTP.expiry_at(),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Purpose boundary: /phone/verify must only accept auth OTP requests.
|
|
||||||
with patch("apps.accounts.views.verify_otp", return_value=True):
|
|
||||||
response = client.post(
|
|
||||||
reverse("phone_auth_verify"),
|
|
||||||
{"request_id": str(otp.id), "code": "123456"},
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_otp_verify_rejects_auth_purpose_otp(client):
|
|
||||||
otp = PhoneOTP.objects.create(
|
|
||||||
phone_number="+966512345678",
|
|
||||||
channel="sms",
|
|
||||||
purpose=OtpPurpose.AUTH,
|
|
||||||
provider="console",
|
|
||||||
code_hash="not-used",
|
|
||||||
expires_at=PhoneOTP.expiry_at(),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Purpose boundary: /otp/verify must only accept verify OTP requests.
|
|
||||||
with patch("apps.accounts.views.verify_otp", return_value=True):
|
|
||||||
response = client.post(
|
|
||||||
reverse("otp_verify"),
|
|
||||||
{"request_id": str(otp.id), "code": "123456"},
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_db_rejects_null_phone_number():
|
|
||||||
# DB invariant: phone_number is mandatory.
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
User.objects.create(email="null-phone@example.com")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_db_rejects_non_e164_phone_number():
|
|
||||||
# DB invariant: store normalized E.164 only.
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
User.objects.create_user(phone_number="0512345678")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_db_rejects_duplicate_phone_number():
|
|
||||||
User.objects.create_user(phone_number="+966512345678")
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
User.objects.create_user(phone_number="+966512345678")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_password_token_endpoint_is_disabled(client):
|
|
||||||
User.objects.create_user(phone_number="+966512345678", password="StrongPass123")
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
reverse("token_obtain_pair"),
|
|
||||||
{"phone_number": "+966512345678", "password": "StrongPass123"},
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 410
|
|
||||||
assert "detail" in response.json()
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Tests for Twilio OTP provider implementation."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@patch("apps.accounts.services.otp.TwilioOtpProvider._get_client")
|
||||||
|
def test_twilio_send_sms_calls_client(mock_get_client):
|
||||||
|
from apps.accounts.services.otp import TwilioOtpProvider
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_get_client.return_value = mock_client
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {
|
||||||
|
"TWILIO_ACCOUNT_SID": "AC123",
|
||||||
|
"TWILIO_AUTH_TOKEN": "token",
|
||||||
|
"TWILIO_FROM_NUMBER": "+966500000000",
|
||||||
|
}):
|
||||||
|
provider = TwilioOtpProvider()
|
||||||
|
provider.send_sms("+966512345678", "Your code is 123456")
|
||||||
|
|
||||||
|
mock_client.messages.create.assert_called_once_with(
|
||||||
|
body="Your code is 123456",
|
||||||
|
from_="+966500000000",
|
||||||
|
to="+966512345678",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@patch("apps.accounts.services.otp.TwilioOtpProvider._get_client")
|
||||||
|
def test_twilio_send_whatsapp_calls_client(mock_get_client):
|
||||||
|
from apps.accounts.services.otp import TwilioOtpProvider
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_get_client.return_value = mock_client
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {
|
||||||
|
"TWILIO_ACCOUNT_SID": "AC123",
|
||||||
|
"TWILIO_AUTH_TOKEN": "token",
|
||||||
|
"TWILIO_FROM_NUMBER": "+966500000000",
|
||||||
|
"TWILIO_WHATSAPP_FROM": "14155238886",
|
||||||
|
}):
|
||||||
|
provider = TwilioOtpProvider()
|
||||||
|
provider.send_whatsapp("+966512345678", "Your code is 123456")
|
||||||
|
|
||||||
|
mock_client.messages.create.assert_called_once_with(
|
||||||
|
body="Your code is 123456",
|
||||||
|
from_="whatsapp:14155238886",
|
||||||
|
to="whatsapp:+966512345678",
|
||||||
|
)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from rest_framework_simplejwt.views import TokenRefreshView
|
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||||
|
|
||||||
from apps.accounts.views import (
|
from apps.accounts.views import (
|
||||||
MeView,
|
MeView,
|
||||||
@@ -7,7 +7,6 @@ from apps.accounts.views import (
|
|||||||
OTPVerifyView,
|
OTPVerifyView,
|
||||||
PhoneAuthRequestView,
|
PhoneAuthRequestView,
|
||||||
PhoneAuthVerifyView,
|
PhoneAuthVerifyView,
|
||||||
PasswordTokenObtainDeprecatedView,
|
|
||||||
RegisterView,
|
RegisterView,
|
||||||
SocialLoginPlaceholderView,
|
SocialLoginPlaceholderView,
|
||||||
)
|
)
|
||||||
@@ -15,7 +14,7 @@ from apps.accounts.views import (
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("register/", RegisterView.as_view(), name="register"),
|
path("register/", RegisterView.as_view(), name="register"),
|
||||||
path("me/", MeView.as_view(), name="me"),
|
path("me/", MeView.as_view(), name="me"),
|
||||||
path("token/", PasswordTokenObtainDeprecatedView.as_view(), name="token_obtain_pair"),
|
path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
|
||||||
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
|
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
|
||||||
path("otp/request/", OTPRequestView.as_view(), name="otp_request"),
|
path("otp/request/", OTPRequestView.as_view(), name="otp_request"),
|
||||||
path("otp/verify/", OTPVerifyView.as_view(), name="otp_verify"),
|
path("otp/verify/", OTPVerifyView.as_view(), name="otp_verify"),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
from rest_framework import generics, permissions, status
|
from rest_framework import generics, permissions, status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
@@ -16,13 +17,8 @@ from apps.accounts.serializers import (
|
|||||||
)
|
)
|
||||||
from apps.accounts.services.otp import (
|
from apps.accounts.services.otp import (
|
||||||
OtpCooldownError,
|
OtpCooldownError,
|
||||||
OtpDeviceRateLimitError,
|
|
||||||
OtpIpRateLimitError,
|
|
||||||
OtpRateLimitError,
|
OtpRateLimitError,
|
||||||
build_device_signal,
|
|
||||||
create_and_send_otp,
|
create_and_send_otp,
|
||||||
enforce_phone_auth_request_limits,
|
|
||||||
normalize_request_ip,
|
|
||||||
verify_otp,
|
verify_otp,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,10 +70,7 @@ class OTPVerifyView(APIView):
|
|||||||
serializer = OTPVerifySerializer(data=request.data)
|
serializer = OTPVerifySerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
data = serializer.validated_data
|
data = serializer.validated_data
|
||||||
# Purpose isolation: verification endpoint accepts only verify-purpose OTPs.
|
otp = get_object_or_404(PhoneOTP, id=data["request_id"])
|
||||||
otp = PhoneOTP.objects.filter(id=data["request_id"], purpose=OtpPurpose.VERIFY).first()
|
|
||||||
if not otp:
|
|
||||||
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
if not verify_otp(otp, data["code"]):
|
if not verify_otp(otp, data["code"]):
|
||||||
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
@@ -98,25 +91,6 @@ class PhoneAuthRequestView(APIView):
|
|||||||
data = serializer.validated_data
|
data = serializer.validated_data
|
||||||
phone_number = data["phone_number"]
|
phone_number = data["phone_number"]
|
||||||
email = data.get("email") or None
|
email = data.get("email") or None
|
||||||
request_ip = normalize_request_ip(request.META.get("HTTP_X_FORWARDED_FOR") or request.META.get("REMOTE_ADDR"))
|
|
||||||
device_signal = build_device_signal(
|
|
||||||
data.get("device_id"),
|
|
||||||
request.META.get("HTTP_USER_AGENT"),
|
|
||||||
request.META.get("HTTP_ACCEPT_LANGUAGE"),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
enforce_phone_auth_request_limits(request_ip=request_ip, device_signal=device_signal)
|
|
||||||
except OtpIpRateLimitError as exc:
|
|
||||||
return Response(
|
|
||||||
{"detail": str(exc), "retry_after_seconds": exc.retry_after_seconds},
|
|
||||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
||||||
)
|
|
||||||
except OtpDeviceRateLimitError as exc:
|
|
||||||
return Response(
|
|
||||||
{"detail": str(exc), "retry_after_seconds": exc.retry_after_seconds},
|
|
||||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
||||||
)
|
|
||||||
|
|
||||||
user = User.objects.filter(phone_number=phone_number).first()
|
user = User.objects.filter(phone_number=phone_number).first()
|
||||||
if not user:
|
if not user:
|
||||||
@@ -134,13 +108,7 @@ class PhoneAuthRequestView(APIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = create_and_send_otp(
|
result = create_and_send_otp(phone_number, data["channel"], purpose=OtpPurpose.AUTH)
|
||||||
phone_number,
|
|
||||||
data["channel"],
|
|
||||||
purpose=OtpPurpose.AUTH,
|
|
||||||
request_ip=request_ip,
|
|
||||||
device_signal=device_signal,
|
|
||||||
)
|
|
||||||
except OtpCooldownError as exc:
|
except OtpCooldownError as exc:
|
||||||
return Response(
|
return Response(
|
||||||
{"detail": str(exc), "retry_after_seconds": exc.retry_after_seconds},
|
{"detail": str(exc), "retry_after_seconds": exc.retry_after_seconds},
|
||||||
@@ -165,10 +133,7 @@ class PhoneAuthVerifyView(APIView):
|
|||||||
serializer = PhoneAuthVerifySerializer(data=request.data)
|
serializer = PhoneAuthVerifySerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
data = serializer.validated_data
|
data = serializer.validated_data
|
||||||
# Purpose isolation: login endpoint accepts only auth-purpose OTPs.
|
otp = get_object_or_404(PhoneOTP, id=data["request_id"])
|
||||||
otp = PhoneOTP.objects.filter(id=data["request_id"], purpose=OtpPurpose.AUTH).first()
|
|
||||||
if not otp:
|
|
||||||
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
if not verify_otp(otp, data["code"]):
|
if not verify_otp(otp, data["code"]):
|
||||||
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
@@ -199,17 +164,3 @@ class SocialLoginPlaceholderView(APIView):
|
|||||||
{"detail": _("Social login not configured yet. Add OAuth provider config.")},
|
{"detail": _("Social login not configured yet. Add OAuth provider config.")},
|
||||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PasswordTokenObtainDeprecatedView(APIView):
|
|
||||||
permission_classes = [permissions.AllowAny]
|
|
||||||
|
|
||||||
def post(self, request):
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
"detail": _(
|
|
||||||
"Password login is deprecated. Use /api/auth/phone/request/ then /api/auth/phone/verify/."
|
|
||||||
)
|
|
||||||
},
|
|
||||||
status=status.HTTP_410_GONE,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -12,23 +12,9 @@ from apps.salons.models import Salon, Service, StaffAvailability, StaffProfile
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def base_entities():
|
def base_entities():
|
||||||
owner = User.objects.create_user(
|
owner = User.objects.create_user(email="owner@example.com", password="pass", role=UserRole.MANAGER)
|
||||||
email="owner@example.com",
|
customer = User.objects.create_user(email="customer@example.com", password="pass")
|
||||||
password="pass",
|
staff_user = User.objects.create_user(email="staff@example.com", password="pass", role=UserRole.STAFF)
|
||||||
role=UserRole.MANAGER,
|
|
||||||
phone_number="+966500000001",
|
|
||||||
)
|
|
||||||
customer = User.objects.create_user(
|
|
||||||
email="customer@example.com",
|
|
||||||
password="pass",
|
|
||||||
phone_number="+966500000002",
|
|
||||||
)
|
|
||||||
staff_user = User.objects.create_user(
|
|
||||||
email="staff@example.com",
|
|
||||||
password="pass",
|
|
||||||
role=UserRole.STAFF,
|
|
||||||
phone_number="+966500000003",
|
|
||||||
)
|
|
||||||
|
|
||||||
salon = Salon.objects.create(
|
salon = Salon.objects.create(
|
||||||
owner=owner,
|
owner=owner,
|
||||||
|
|||||||
@@ -17,18 +17,18 @@ def booking_payload():
|
|||||||
email="owner@example.com",
|
email="owner@example.com",
|
||||||
password="pass",
|
password="pass",
|
||||||
role=UserRole.MANAGER,
|
role=UserRole.MANAGER,
|
||||||
phone_number="+966500000001",
|
phone_number="0500000001",
|
||||||
)
|
)
|
||||||
customer = User.objects.create_user(
|
customer = User.objects.create_user(
|
||||||
email="customer@example.com",
|
email="customer@example.com",
|
||||||
password="pass",
|
password="pass",
|
||||||
phone_number="+966500000002",
|
phone_number="0500000002",
|
||||||
)
|
)
|
||||||
staff_user = User.objects.create_user(
|
staff_user = User.objects.create_user(
|
||||||
email="staff@example.com",
|
email="staff@example.com",
|
||||||
password="pass",
|
password="pass",
|
||||||
role=UserRole.STAFF,
|
role=UserRole.STAFF,
|
||||||
phone_number="+966500000003",
|
phone_number="0500000003",
|
||||||
)
|
)
|
||||||
|
|
||||||
salon = Salon.objects.create(
|
salon = Salon.objects.create(
|
||||||
|
|||||||
@@ -15,23 +15,9 @@ from apps.salons.models import Salon, Service, StaffProfile
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def booking_entities():
|
def booking_entities():
|
||||||
owner = User.objects.create_user(
|
owner = User.objects.create_user(email="owner@example.com", password="pass", role=UserRole.MANAGER)
|
||||||
email="owner@example.com",
|
customer = User.objects.create_user(email="customer@example.com", password="pass")
|
||||||
password="pass",
|
staff_user = User.objects.create_user(email="staff@example.com", password="pass", role=UserRole.STAFF)
|
||||||
role=UserRole.MANAGER,
|
|
||||||
phone_number="+966500000011",
|
|
||||||
)
|
|
||||||
customer = User.objects.create_user(
|
|
||||||
email="customer@example.com",
|
|
||||||
password="pass",
|
|
||||||
phone_number="+966500000012",
|
|
||||||
)
|
|
||||||
staff_user = User.objects.create_user(
|
|
||||||
email="staff@example.com",
|
|
||||||
password="pass",
|
|
||||||
role=UserRole.STAFF,
|
|
||||||
phone_number="+966500000013",
|
|
||||||
)
|
|
||||||
|
|
||||||
salon = Salon.objects.create(
|
salon = Salon.objects.create(
|
||||||
owner=owner,
|
owner=owner,
|
||||||
|
|||||||
@@ -146,9 +146,6 @@ OTP_EXPIRY_MINUTES = int(os.getenv("OTP_EXPIRY_MINUTES", "5"))
|
|||||||
OTP_MAX_PER_WINDOW = int(os.getenv("OTP_MAX_PER_WINDOW", "5"))
|
OTP_MAX_PER_WINDOW = int(os.getenv("OTP_MAX_PER_WINDOW", "5"))
|
||||||
OTP_WINDOW_MINUTES = int(os.getenv("OTP_WINDOW_MINUTES", "15"))
|
OTP_WINDOW_MINUTES = int(os.getenv("OTP_WINDOW_MINUTES", "15"))
|
||||||
OTP_RESEND_COOLDOWN_SECONDS = int(os.getenv("OTP_RESEND_COOLDOWN_SECONDS", "60"))
|
OTP_RESEND_COOLDOWN_SECONDS = int(os.getenv("OTP_RESEND_COOLDOWN_SECONDS", "60"))
|
||||||
PHONE_AUTH_RISK_WINDOW_MINUTES = int(os.getenv("PHONE_AUTH_RISK_WINDOW_MINUTES", "15"))
|
|
||||||
PHONE_AUTH_IP_MAX_PER_WINDOW = int(os.getenv("PHONE_AUTH_IP_MAX_PER_WINDOW", "20"))
|
|
||||||
PHONE_AUTH_DEVICE_MAX_PER_WINDOW = int(os.getenv("PHONE_AUTH_DEVICE_MAX_PER_WINDOW", "20"))
|
|
||||||
DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "SAR")
|
DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "SAR")
|
||||||
NOTIFICATION_PROVIDER = os.getenv("NOTIFICATION_PROVIDER", OTP_PROVIDER)
|
NOTIFICATION_PROVIDER = os.getenv("NOTIFICATION_PROVIDER", OTP_PROVIDER)
|
||||||
NOTIFICATION_DEFAULT_CHANNEL = os.getenv("NOTIFICATION_DEFAULT_CHANNEL", "sms")
|
NOTIFICATION_DEFAULT_CHANNEL = os.getenv("NOTIFICATION_DEFAULT_CHANNEL", "sms")
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ Accepted
|
|||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
The platform requires phone-first authentication with OTP delivery for KSA. The codebase includes provider adapters (`console`, `authentica`) and Authentica is implemented for provider-managed OTP delivery (send/verify) and direct SMS messaging. A console provider exists for local development.
|
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
|
## 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.
|
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
|
## Consequences
|
||||||
|
|
||||||
@@ -20,6 +20,8 @@ Use Authentica as the primary OTP provider for the MVP, with `OTP_PROVIDER=authe
|
|||||||
|
|
||||||
## Alternatives Considered
|
## 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
|
## Related
|
||||||
|
|
||||||
|
|||||||
+45
-207
@@ -1,222 +1,60 @@
|
|||||||
# Salon MVP Roadmap And Architecture Review
|
# Architecture
|
||||||
|
|
||||||
## Purpose / Big Picture
|
## Overview
|
||||||
|
|
||||||
This plan reviews the current Salon codebase (Django backend, React/Vite frontend), highlights architectural and design risks, and lays out a pragmatic roadmap to reach an MVP that aligns with **Phase 1: Core MVP Reliability** in `AGENTS.md`: phone-first auth with OTP, robust booking integrity, Moyasar payments, booking lifecycle notifications, localization foundations, and tests for critical flows.
|
The Salon platform is a Django REST API backend with a React/Vite frontend, optimized for KSA (phone auth, Riyadh timezone, Arabic locale).
|
||||||
The roadmap assumes a KSA-focused first launch (phone auth and Riyadh timezone defaults) with a clean path to expand later.
|
|
||||||
|
|
||||||
## Current State Summary
|
## Backend Apps and Responsibilities
|
||||||
|
|
||||||
### Backend (Django, DRF)
|
| App | Responsibility |
|
||||||
|
|-----|----------------|
|
||||||
|
| **accounts** | User model, phone/OTP auth, JWT tokens, locale preferences. OTP providers (console, Twilio, Unifonic) send SMS/WhatsApp. |
|
||||||
|
| **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 provider classes as an MVP shortcut; sends on booking created/confirmed/cancelled. See note below. |
|
||||||
|
|
||||||
- **Project**: `salon_api` in `[backend/salon_api](backend/salon_api)`
|
## Data Model Overview
|
||||||
- `settings.py` configures `AUTH_USER_MODEL = "accounts.User"`, DRF + SimpleJWT, KSA locale defaults, and custom settings for OTP, notifications, and payments.
|
|
||||||
- URLs route to app-level APIs at `/api/auth/`, `/api/salons/`, `/api/bookings/`, `/api/payments/`.
|
|
||||||
- **Auth & Accounts** (`[backend/apps/accounts](backend/apps/accounts)`)
|
|
||||||
- Custom `User` model with phone-first capabilities (`phone_number`, `is_phone_verified`, `preferred_language`, `role`).
|
|
||||||
- Phone normalization services tuned for KSA numbers (`[backend/apps/accounts/services/phone.py](backend/apps/accounts/services/phone.py)`).
|
|
||||||
- OTP domain + service layer with rate limits, cooldowns, expiry, and hashed codes (`[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)`).
|
|
||||||
- Phone-first auth endpoints that issue JWT tokens on successful OTP verification, plus basic email/password registration.
|
|
||||||
- Social login endpoint is a placeholder that always returns 501.
|
|
||||||
- **Salons & Catalog** (`[backend/apps/salons](backend/apps/salons)`)
|
|
||||||
- Models for `Salon`, `Service`, `StaffProfile`, `StaffAvailability`, `Review`, `SalonPhoto`.
|
|
||||||
- Read-only APIs for salon search, services, staff, and reviews.
|
|
||||||
- **Bookings** (`[backend/apps/bookings](backend/apps/bookings)`)
|
|
||||||
- `Booking` model ties together salon, service, staff, customer, time window, price, and status.
|
|
||||||
- `validate_booking_request` in `[backend/apps/bookings/services.py](backend/apps/bookings/services.py)` enforces staff membership, duration matching, availability windows, and overlap prevention for pending/confirmed bookings.
|
|
||||||
- `BookingViewSet` in `[backend/apps/bookings/views.py](backend/apps/bookings/views.py)` applies role-based access and hooks into notifications on create and relevant status changes.
|
|
||||||
- **Payments (Moyasar)** (`[backend/apps/payments](backend/apps/payments)`)
|
|
||||||
- `Payment` model tracks provider, status (fine-grained transitions), amount, idempotency key, external ID, payload, and timestamps.
|
|
||||||
- `MoyasarGateway` in `[backend/apps/payments/services/gateway.py](backend/apps/payments/services/gateway.py)` can create payments via HTTP but has `capture_payment`/`refund_payment` as TODOs.
|
|
||||||
- `create_payment_for_booking` in `[backend/apps/payments/services/payments.py](backend/apps/payments/services/payments.py)` enforces provider choice (Moyasar only), idempotency, and maps webhook events into internal statuses.
|
|
||||||
- Webhook view in `[backend/apps/payments/views.py](backend/apps/payments/views.py)` authenticates via shared secret and applies provider events idempotently.
|
|
||||||
- **Notifications** (`[backend/apps/notifications](backend/apps/notifications)`)
|
|
||||||
- `Notification` model records booking-related notifications and enforces uniqueness per booking/recipient/event/channel.
|
|
||||||
- Services in `[backend/apps/notifications/services.py](backend/apps/notifications/services.py)` reuse OTP providers to send SMS/WhatsApp messages for booking created/confirmed/cancelled events, with localization via `preferred_language`.
|
|
||||||
- Booking views call `notify_booking_lifecycle` / `notify_on_status_change` to trigger notifications for customers and staff.
|
|
||||||
- **Testing**
|
|
||||||
- Solid tests around:
|
|
||||||
- Phone normalization, OTP rate limiting, and phone auth flows (`[backend/apps/accounts/tests](backend/apps/accounts/tests)`).
|
|
||||||
- Booking integrity and overlap rules (`[backend/apps/bookings/tests](backend/apps/bookings/tests)`).
|
|
||||||
- Payment idempotency and Moyasar webhook handling (`[backend/apps/payments/tests](backend/apps/payments/tests)`).
|
|
||||||
- Booking notifications on create/status change (`[backend/apps/notifications/tests](backend/apps/notifications/tests)`).
|
|
||||||
- `docs/risks.md` explicitly tracks several known gaps around auth, booking, payments, data/UX, and ops.
|
|
||||||
|
|
||||||
### Frontend (React, Vite)
|
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.
|
||||||
|
|
||||||
- **Structure**
|
- `accounts.User` owns phone, locale, and auth preferences.
|
||||||
- Vite React app at `[frontend](frontend)` with entry in `[frontend/src/main.jsx](frontend/src/main.jsx)` and single top-level component in `[frontend/src/App.jsx](frontend/src/App.jsx)`.
|
- `salons.Salon`, `salons.Service`, and `salons.Staff` define the catalog and scheduling surface.
|
||||||
- No `react-router` or multi-page routing; the entire experience is one composed screen.
|
- `bookings.Booking` links customer, staff, service, and scheduled time, with status transitions.
|
||||||
- **Current Features**
|
- `payments.Payment` tracks gateway state and idempotency per booking.
|
||||||
- **Salon search**
|
- `notifications.Notification` records each SMS/WhatsApp send attempt tied to a booking event.
|
||||||
- Text search field calling `/salons/?q=<query>` via a small API client in `[frontend/src/api/client.js](frontend/src/api/client.js)`.
|
|
||||||
- Renders responsive list of salons with rating, city, and phone.
|
|
||||||
- **Localization/i18n**
|
|
||||||
- `react-i18next` setup in `[frontend/src/i18n/index.js](frontend/src/i18n/index.js)` with `en` and `ar-sa` translations.
|
|
||||||
- Locale preference stored in `localStorage`; applies `lang` and `dir` on the document.
|
|
||||||
- **Payments beta**
|
|
||||||
- A form in `App.jsx` that sends payment creation requests to `/api/payments/` using the Moyasar-style payload, with configurable `booking_id`, source type, token, and callback URL.
|
|
||||||
- Optionally includes a Bearer token from a manually-entered access token field.
|
|
||||||
- On success, can redirect to `redirect_url` and shows the raw JSON response.
|
|
||||||
- **State & Tests**
|
|
||||||
- All state is local to `App.jsx` via `useState`/`useEffect`; there is no centralized state management or domain hooks yet.
|
|
||||||
- A single test file `[frontend/src/App.test.jsx](frontend/src/App.test.jsx)` covers hero copy and locale/RTL behavior, but not search or payments.
|
|
||||||
|
|
||||||
## Glaring Design And Architectural Issues
|
## Data Flow
|
||||||
|
|
||||||
### Backend Risks
|
```
|
||||||
|
User → React Frontend → Django API
|
||||||
- **Incomplete provider implementations for production-critical flows**
|
↓
|
||||||
- Authentica is the only OTP/notification provider; ensure it is fully configured and exercised in production-like environments.
|
accounts (auth) ──→ OTP providers (Twilio/Unifonic/console)
|
||||||
- `MoyasarGateway` lacks `capture_payment` and `refund_payment` implementations, limiting payment lifecycle coverage.
|
salons (catalog)
|
||||||
- **Risk**: Code reads “production ready” at the API level, but the underlying integrations are not, which could cause outages if deployed naively.
|
bookings ──→ notifications ──→ OTP providers
|
||||||
- **Tight coupling between OTP and notifications**
|
payments ──→ Moyasar gateway
|
||||||
- Notification services import the OTP provider mapping and default `NOTIFICATION_PROVIDER` to `OTP_PROVIDER`, binding booking notifications to auth configuration.
|
|
||||||
- **Risk**: Changing OTP providers or adding a second channel for marketing/ops notifications will be harder and could have unintended side effects.
|
|
||||||
- **Synchronous IO-heavy work in request/response path**
|
|
||||||
- OTP sends, booking notifications, and payment gateway calls all occur synchronously inside view methods (`perform_create`, `create`, etc.).
|
|
||||||
- **Risk**: Slow or flaky providers will degrade API latency and user experience; retries and backoff are hard to implement without background jobs.
|
|
||||||
- **Cross-app domain coupling without a clear orchestration layer**
|
|
||||||
- `apps.bookings` depends on salons and notifications; notifications depend on accounts (OTP providers) and bookings; payments depend on bookings.
|
|
||||||
- **Risk**: As you add more lifecycle rules (e.g., auto-confirm booking on payment, send reminders, handle refunds), the spaghetti of cross-imports will grow unless you introduce clearer service boundaries.
|
|
||||||
- **Auth model vs login patterns**
|
|
||||||
- `User.USERNAME_FIELD` is email, while phone-based JWT issuance happens via custom endpoints.
|
|
||||||
- **Risk**: This split can confuse clients and admin tooling and may complicate future flows like social login or SSO unless you standardize on an identifier strategy.
|
|
||||||
- **Docs drift around ExecPlans**
|
|
||||||
- `AGENTS.md` references `docs/execplans/payments-moyasar.md` as the active plan, while `PLANS.md` names `docs/execplans/booking-notifications.md`.
|
|
||||||
- **Risk**: Contributors may follow different “active” plans, causing architectural inconsistency.
|
|
||||||
|
|
||||||
### Frontend Risks
|
|
||||||
|
|
||||||
- **Monolithic `App` component with no routing**
|
|
||||||
- `App.jsx` mixes hero/search, salon listing, payments, and locale controls.
|
|
||||||
- There is no `react-router` or notion of separate flows (auth, booking, profile, payments).
|
|
||||||
- **Risk**: Extending to full MVP flows (auth, booking, history, management) will quickly become unmanageable without a routing/page system and domain separation.
|
|
||||||
- **Domain logic embedded in UI components**
|
|
||||||
- API payload construction, validation rules (e.g. for source types), and error handling are implemented directly in `App.jsx` rather than reusable hooks or service modules.
|
|
||||||
- **Risk**: Code reuse, testing, and evolution (e.g., adding booking pages or admin consoles) will be painful.
|
|
||||||
- **Minimal test coverage for critical flows**
|
|
||||||
- Only i18n and hero copy are tested; search behavior, API integration, and payments are untested.
|
|
||||||
- **Risk**: Regressions in search, booking, and payments UX will slip through as MVP grows.
|
|
||||||
- **Styling & layout fragility**
|
|
||||||
- `frontend/src/styles.css` uses `::root` instead of `:root`, which likely breaks intended global CSS variables or base styles.
|
|
||||||
- Global CSS is tightly bound to the monolithic `App` layout.
|
|
||||||
- **Risk**: Visual regressions and layout churn when introducing additional pages or components.
|
|
||||||
- **Ad hoc auth token handling**
|
|
||||||
- The “access token” is a free-form text field that gets persisted as `auth_token` in `localStorage` and injected into payment requests.
|
|
||||||
- **Risk**: This is a placeholder pattern that does not scale to full auth (refresh tokens, logout, token rotation) and will need to be replaced.
|
|
||||||
|
|
||||||
### Cross-Cutting Risks
|
|
||||||
|
|
||||||
- **Lack of async/background processing**
|
|
||||||
- No Celery/RQ or similar job queue; all side effects are synchronous.
|
|
||||||
- **Risk**: Scaling SMS/WhatsApp notifications, email, and payment webhook fan-out will be difficult.
|
|
||||||
- **Observability and admin tooling gaps**
|
|
||||||
- Errors for payments and notifications are recorded in model metadata but not clearly surfaced in logs, dashboards, or admin views.
|
|
||||||
- **Risk**: Operational debugging during MVP rollout will be slower and more error-prone.
|
|
||||||
- **Internationalization strategy vs future markets**
|
|
||||||
- Phone normalization and defaults are tailored to KSA, which is correct for MVP, but `docs/risks.md` already notes the need to broaden later.
|
|
||||||
- **Risk**: Without clear boundaries between KSA-specific logic and generic logic, future expansion may require invasive changes.
|
|
||||||
|
|
||||||
## MVP Roadmap (Aligned To Phase 1)
|
|
||||||
|
|
||||||
This roadmap assumes “MVP” is equivalent to **Phase 1: Core MVP Reliability** in `AGENTS.md`, with a thin but robust frontend on top.
|
|
||||||
|
|
||||||
### Phase 0 – Architecture & Production Readiness Hardening
|
|
||||||
|
|
||||||
- **Finalize critical provider implementations**
|
|
||||||
- Ensure Authentica OTP/SMS is fully configured and validated end-to-end behind the provider abstraction in `[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)`, and wired into `[backend/apps/notifications/services.py](backend/apps/notifications/services.py)`.
|
|
||||||
- Implement or deliberately fence off `capture_payment` and `refund_payment` in `[backend/apps/payments/services/gateway.py](backend/apps/payments/services/gateway.py)` so that the MVP either fully supports or explicitly does not support partial captures/refunds.
|
|
||||||
- **Clarify and document boundaries**
|
|
||||||
- Add a short architecture section in `README`/docs describing how `accounts`, `salons`, `bookings`, `payments`, and `notifications` interact, and what each service is responsible for.
|
|
||||||
- Resolve the ExecPlan drift by making `AGENTS.md` and `PLANS.md` agree on the current active plan.
|
|
||||||
- **Introduce minimal async infrastructure (optional but recommended)**
|
|
||||||
- Decide whether MVP will ship with a task queue (e.g., Celery with Redis) or keep everything synchronous for the initial launch.
|
|
||||||
- If yes, introduce a thin task layer for OTP sends and booking notifications while preserving current APIs; if not, at least add clear timeouts/logging to external calls.
|
|
||||||
- **Frontend scaffolding for growth**
|
|
||||||
- Introduce `react-router` and refactor `App.jsx` into route-based pages (e.g., `HomePage`, `BookPage`, `PaymentPage`, `ProfilePage`), with shared layout and navigation.
|
|
||||||
- Extract salon search, payment form, and locale controls into dedicated components and hooks.
|
|
||||||
|
|
||||||
### Phase 1 – Core MVP Features (Backend + Frontend)
|
|
||||||
|
|
||||||
- **Phone-first auth UX**
|
|
||||||
- Backend: reuse existing phone auth endpoints; ensure error messages and rate-limit responses are predictable and localized.
|
|
||||||
- Frontend:
|
|
||||||
- Build OTP-based login/registration screens that drive `/api/auth/phone/request/` and `/api/auth/phone/verify/`.
|
|
||||||
- Introduce an auth context (or similar) to store access/refresh tokens, current user profile, and handle logout.
|
|
||||||
- Defer social login beyond MVP, but keep API surface ready for it.
|
|
||||||
- **Booking search and creation**
|
|
||||||
- Backend is largely ready (booking validation and role-based access); review booking serializers in `[backend/apps/bookings/serializers.py](backend/apps/bookings/serializers.py)` to ensure they expose all fields needed for frontend booking forms.
|
|
||||||
- Frontend:
|
|
||||||
- Build a **booking flow**: pick a salon → choose service → select staff (optional) → select date/time slot (based on availability endpoints) → confirm booking.
|
|
||||||
- Add a “My bookings” page showing upcoming and past bookings, tied into the existing `/api/bookings/` endpoints.
|
|
||||||
- **Payments via Moyasar**
|
|
||||||
- Backend: confirm `create_payment_for_booking` contracts (inputs/outputs) are stable and documented.
|
|
||||||
- Frontend:
|
|
||||||
- Evolve the payments beta UI into a **post-booking payment step** that starts from a selected booking and guides the user into Moyasar’s hosted flow, then shows a status page.
|
|
||||||
- Handle callback/return from Moyasar (even if via manual redirect URL in MVP) and surface payment success/failure to the user.
|
|
||||||
- **Booking lifecycle notifications**
|
|
||||||
- Backend already sends notifications on booking create and status changes; align messaging templates with product UX and ensure localization strings exist.
|
|
||||||
- Frontend: surface notification results implicitly via booking status changes and explicit messages on the booking details page.
|
|
||||||
- **Localization foundations**
|
|
||||||
- Backend: ensure `UserLocaleMiddleware` and translation strings cover all user-visible errors in auth, bookings, payments, and notifications.
|
|
||||||
- Frontend: expand `en/ar-sa` translations to cover auth, booking, and payment flows; verify RTL layouts on the new screens.
|
|
||||||
- **Tests for critical flows**
|
|
||||||
- Backend: extend tests where needed to cover new booking/payment edge cases (e.g., tying booking status to payment status if/when introduced).
|
|
||||||
- Frontend: add Vitest tests for:
|
|
||||||
- Phone auth screen flows (request/verify success + errors).
|
|
||||||
- Booking flow (form validation, happy path, displaying server-side errors).
|
|
||||||
- Payment initiation from an existing booking.
|
|
||||||
|
|
||||||
### Phase 2 – Manager Ops Lite (Post-MVP, partially covered now)
|
|
||||||
|
|
||||||
- **Salon and staff management UI**
|
|
||||||
- Use existing salon and staff models to build basic management pages for salon owners/managers (create/update services, staff, availability).
|
|
||||||
- **Calendar views and rescheduling**
|
|
||||||
- Provide calendar views for staff/managers to view daily/weekly bookings and reschedule or cancel within defined rules.
|
|
||||||
- **Reviews and ratings**
|
|
||||||
- Implement review submission and rating recalculation on the backend, with corresponding frontend components.
|
|
||||||
- **Reporting basics**
|
|
||||||
- Lightweight reports for managers (upcoming bookings, simple revenue summaries based on payment status) using existing payments data.
|
|
||||||
|
|
||||||
### Phase 3 – Scale & Compliance (Later)
|
|
||||||
|
|
||||||
- **Audit logging** for admin actions and booking/payment state changes.
|
|
||||||
- **PDPL/GDPR retention policies** and data export tooling.
|
|
||||||
- **Observability**: structured logging, metrics, and basic dashboards for auth failures, OTP send failures, payment errors, and notification outcomes.
|
|
||||||
|
|
||||||
## Architecture Overview Diagram
|
|
||||||
|
|
||||||
A simplified view of the target MVP data flow:
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
user["User (web/mobile)"] --> frontend["ReactFrontend"]
|
|
||||||
frontend --> api["DjangoAPI"]
|
|
||||||
|
|
||||||
api --> accounts["AccountsApp"]
|
|
||||||
api --> salons["SalonsApp"]
|
|
||||||
api --> bookings["BookingsApp"]
|
|
||||||
api --> payments["PaymentsApp"]
|
|
||||||
api --> notifications["NotificationsApp"]
|
|
||||||
|
|
||||||
accounts --> otpProviders["OtpProviders"]
|
|
||||||
notifications --> otpProviders
|
|
||||||
payments --> moyasar["MoyasarGateway"]
|
|
||||||
|
|
||||||
bookings --> notifications
|
|
||||||
bookings --> payments
|
|
||||||
salons --> bookings
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
This diagram clarifies current coupling and highlights where future refactors (e.g., a dedicated messaging service or payment orchestrator) could sit.
|
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.
|
||||||
|
|
||||||
## Validation And Acceptance For This Plan
|
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.
|
||||||
|
|
||||||
- The roadmap is accepted when:
|
## Async and Observability (MVP Decision)
|
||||||
- It clearly maps current backend and frontend capabilities to the Phase 1 MVP goals in `AGENTS.md`.
|
|
||||||
- It identifies the highest-risk design/architecture issues that could impede MVP reliability or future evolution.
|
**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.
|
||||||
- It provides a phased, concrete sequence of work packages that can be implemented via ExecPlans (e.g., booking notifications, Moyasar payments, phone auth UX).
|
This is captured in ADR 0001 (`docs/adr/0001-synchronous-external-calls-mvp.md`).
|
||||||
- Each major feature area (auth, bookings, payments, notifications, localization, tests) should have or adopt an ExecPlan under `docs/execplans/` in line with `PLANS.md` before implementation begins.
|
|
||||||
|
**Rationale:**
|
||||||
|
- Reduces deployment complexity (no Redis, no worker processes).
|
||||||
|
- MVP traffic is expected to be low; synchronous latency is acceptable.
|
||||||
|
- External calls already use timeouts (e.g. Moyasar: 10s, Twilio: SDK default).
|
||||||
|
|
||||||
|
**Future:** When scaling, introduce a task queue (e.g. Celery + Redis) for OTP and notification sends. Payment creation and webhooks should remain synchronous for immediate feedback and idempotency.
|
||||||
|
|
||||||
|
**Observability:** Errors are logged via Python `logging` and stored in model metadata (e.g. `Payment.metadata["gateway_error"]`, `Notification.error_message`). Structured logging and metrics are Phase 3 work.
|
||||||
|
|||||||
+3
-13
@@ -4,21 +4,10 @@ This file tracks known gaps and risks to address in future iterations.
|
|||||||
|
|
||||||
## Security And Auth
|
## Security And Auth
|
||||||
- Phone normalization is KSA-focused and minimal; broaden for multi-country use.
|
- Phone normalization is KSA-focused and minimal; broaden for multi-country use.
|
||||||
- OTP protections now include per-phone limits plus `/phone/request` IP/device window controls; thresholds still need production tuning.
|
- OTP protections are basic; add device fingerprinting and IP throttling if needed.
|
||||||
- Authentica OTP provider is implemented (SMS + WhatsApp via Authentica OTP).
|
- Authentica OTP provider is implemented (SMS + WhatsApp via Authentica OTP); Unifonic remains a scaffold.
|
||||||
- Social login is a placeholder.
|
- Social login is a placeholder.
|
||||||
- `USERNAME_FIELD` is now `"phone_number"`; `REQUIRED_FIELDS = []`; `create_superuser` accepts `phone_number`. Admin and `createsuperuser` work correctly for phone-only users.
|
- `USERNAME_FIELD` is now `"phone_number"`; `REQUIRED_FIELDS = []`; `create_superuser` accepts `phone_number`. Admin and `createsuperuser` work correctly for phone-only users.
|
||||||
- Password token obtain endpoint (`/api/auth/token/`) is deprecated (`410 Gone`); phone OTP flow is the login source of truth.
|
|
||||||
- OTP purpose isolation is enforced at verification endpoint boundaries (`/otp/verify` accepts only `verify`, `/phone/verify` accepts only `auth`).
|
|
||||||
- Django admin user configuration remains email-centric (ordering/add form defaults), increasing operational friction for phone-only accounts.
|
|
||||||
- Multiple serializers/model `__str__` paths in non-auth apps still fallback to `user.email`; phone-only users may get poor display/audit clarity.
|
|
||||||
|
|
||||||
## Next Auth Review Points
|
|
||||||
- DB-level guardrails for `accounts_user.phone_number` are now enforced (`NOT NULL`, `UNIQUE`, E.164 check constraint).
|
|
||||||
- Decide user lifecycle for phone auth (create user before OTP verify vs provisional/pre-user state).
|
|
||||||
- Abuse-control implementation for `/api/auth/phone/request/` is in place (IP throttling + persisted device signal); next step is monitor false positives and tune limits.
|
|
||||||
- Define OAuth account-linking policy (phone/email conflicts, merge rules, trust source).
|
|
||||||
- Add explicit tests for remaining phone-first invariants (verified-phone guards and any legacy-path regressions).
|
|
||||||
|
|
||||||
## Booking Integrity
|
## Booking Integrity
|
||||||
- Availability checks and overlap prevention are now enforced for staff bookings.
|
- Availability checks and overlap prevention are now enforced for staff bookings.
|
||||||
@@ -40,3 +29,4 @@ This file tracks known gaps and risks to address in future iterations.
|
|||||||
- No audit logs for admin actions.
|
- No audit logs for admin actions.
|
||||||
- No multi-tenant isolation or data export tooling.
|
- No multi-tenant isolation or data export tooling.
|
||||||
- No GDPR/PDPL data retention policies defined.
|
- No GDPR/PDPL data retention policies defined.
|
||||||
|
- CI baseline exists, but needs Gitea runner registration and required-check enforcement.
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ Guide for diagnosing and mitigating OTP send or verify failures in phone-first a
|
|||||||
- If provider credentials are missing or invalid, fix the environment variables and restart the API process.
|
- 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 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 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.
|
||||||
- For phone-login abuse spikes, also validate `PHONE_AUTH_IP_MAX_PER_WINDOW`, `PHONE_AUTH_DEVICE_MAX_PER_WINDOW`, and `PHONE_AUTH_RISK_WINDOW_MINUTES`.
|
|
||||||
- If verification is failing, confirm server time is correct and `OTP_EXPIRY_MINUTES` is appropriate.
|
- If verification is failing, confirm server time is correct and `OTP_EXPIRY_MINUTES` is appropriate.
|
||||||
|
|
||||||
## Rollback / Escalation
|
## Rollback / Escalation
|
||||||
|
|||||||
@@ -58,18 +58,18 @@ Expected: server starts at `http://127.0.0.1:8000/`.
|
|||||||
|
|
||||||
### 4) Obtain a JWT access token
|
### 4) Obtain a JWT access token
|
||||||
|
|
||||||
Password token login at `/api/auth/token/` is deprecated for phone-first auth. For this runbook, mint a local JWT in Django shell.
|
|
||||||
|
|
||||||
The demo customer is:
|
The demo customer is:
|
||||||
|
|
||||||
- `customer@example.com`
|
- `customer@example.com`
|
||||||
- `Customer123!`
|
- `Customer123!`
|
||||||
|
|
||||||
Generate an access token:
|
Fetch the access token:
|
||||||
|
|
||||||
python3 manage.py shell -c "from django.contrib.auth import get_user_model; from rest_framework_simplejwt.tokens import RefreshToken; u=get_user_model().objects.get(email='customer@example.com'); print(str(RefreshToken.for_user(u).access_token))"
|
curl -s -X POST http://127.0.0.1:8000/api/auth/token/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"customer@example.com","password":"Customer123!"}'
|
||||||
|
|
||||||
Expected: a JWT string printed to stdout. Use it as `<ACCESS>`.
|
Expected: JSON containing `access` and `refresh` tokens.
|
||||||
|
|
||||||
### 5) Create a payment
|
### 5) Create a payment
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
---
|
||||||
|
name: salon-mvp-roadmap
|
||||||
|
overview: High-level roadmap to bring the existing Salon Django/React codebase to a reliable MVP aligned with Phase 1 goals in AGENTS.md, plus a review of current architecture and major risks.
|
||||||
|
todos:
|
||||||
|
- id: backend-providers-readiness
|
||||||
|
content: "Harden backend providers: implement at least one real SMS/WhatsApp provider and clarify Moyasar capture/refund behavior for MVP."
|
||||||
|
status: pending
|
||||||
|
- id: async-and-observability
|
||||||
|
content: Decide on async task infrastructure and observability basics for OTP, notifications, and payments, and document the choice.
|
||||||
|
status: pending
|
||||||
|
- id: frontend-structure-and-routing
|
||||||
|
content: Refactor frontend into routed pages with separated components/hooks for search, auth, booking, and payments.
|
||||||
|
status: pending
|
||||||
|
- id: auth-and-booking-flows
|
||||||
|
content: Implement phone-first auth and end-to-end booking flows on the frontend using existing backend APIs.
|
||||||
|
status: pending
|
||||||
|
- id: payments-and-notifications-ux
|
||||||
|
content: Integrate payment initiation and booking lifecycle notifications into user-facing flows, including success/error handling.
|
||||||
|
status: pending
|
||||||
|
- id: tests-for-critical-flows
|
||||||
|
content: Expand backend and frontend tests to cover auth, booking, payment, and notification critical paths for MVP reliability.
|
||||||
|
status: pending
|
||||||
|
isProject: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Salon MVP Roadmap And Architecture Review
|
||||||
|
|
||||||
|
## Purpose / Big Picture
|
||||||
|
|
||||||
|
This plan reviews the current Salon codebase (Django backend, React/Vite frontend), highlights architectural and design risks, and lays out a pragmatic roadmap to reach an MVP that aligns with **Phase 1: Core MVP Reliability** in `AGENTS.md`: phone-first auth with OTP, robust booking integrity, Moyasar payments, booking lifecycle notifications, localization foundations, and tests for critical flows.
|
||||||
|
The roadmap assumes a KSA-focused first launch (phone auth and Riyadh timezone defaults) with a clean path to expand later.
|
||||||
|
|
||||||
|
## Current State Summary
|
||||||
|
|
||||||
|
### Backend (Django, DRF)
|
||||||
|
|
||||||
|
- **Project**: `salon_api` in `[backend/salon_api](backend/salon_api)`
|
||||||
|
- `settings.py` configures `AUTH_USER_MODEL = "accounts.User"`, DRF + SimpleJWT, KSA locale defaults, and custom settings for OTP, notifications, and payments.
|
||||||
|
- URLs route to app-level APIs at `/api/auth/`, `/api/salons/`, `/api/bookings/`, `/api/payments/`.
|
||||||
|
- **Auth & Accounts** (`[backend/apps/accounts](backend/apps/accounts)`)
|
||||||
|
- Custom `User` model with phone-first capabilities (`phone_number`, `is_phone_verified`, `preferred_language`, `role`).
|
||||||
|
- Phone normalization services tuned for KSA numbers (`[backend/apps/accounts/services/phone.py](backend/apps/accounts/services/phone.py)`).
|
||||||
|
- OTP domain + service layer with rate limits, cooldowns, expiry, and hashed codes (`[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)`).
|
||||||
|
- Phone-first auth endpoints that issue JWT tokens on successful OTP verification, plus basic email/password registration.
|
||||||
|
- Social login endpoint is a placeholder that always returns 501.
|
||||||
|
- **Salons & Catalog** (`[backend/apps/salons](backend/apps/salons)`)
|
||||||
|
- Models for `Salon`, `Service`, `StaffProfile`, `StaffAvailability`, `Review`, `SalonPhoto`.
|
||||||
|
- Read-only APIs for salon search, services, staff, and reviews.
|
||||||
|
- **Bookings** (`[backend/apps/bookings](backend/apps/bookings)`)
|
||||||
|
- `Booking` model ties together salon, service, staff, customer, time window, price, and status.
|
||||||
|
- `validate_booking_request` in `[backend/apps/bookings/services.py](backend/apps/bookings/services.py)` enforces staff membership, duration matching, availability windows, and overlap prevention for pending/confirmed bookings.
|
||||||
|
- `BookingViewSet` in `[backend/apps/bookings/views.py](backend/apps/bookings/views.py)` applies role-based access and hooks into notifications on create and relevant status changes.
|
||||||
|
- **Payments (Moyasar)** (`[backend/apps/payments](backend/apps/payments)`)
|
||||||
|
- `Payment` model tracks provider, status (fine-grained transitions), amount, idempotency key, external ID, payload, and timestamps.
|
||||||
|
- `MoyasarGateway` in `[backend/apps/payments/services/gateway.py](backend/apps/payments/services/gateway.py)` can create payments via HTTP but has `capture_payment`/`refund_payment` as TODOs.
|
||||||
|
- `create_payment_for_booking` in `[backend/apps/payments/services/payments.py](backend/apps/payments/services/payments.py)` enforces provider choice (Moyasar only), idempotency, and maps webhook events into internal statuses.
|
||||||
|
- Webhook view in `[backend/apps/payments/views.py](backend/apps/payments/views.py)` authenticates via shared secret and applies provider events idempotently.
|
||||||
|
- **Notifications** (`[backend/apps/notifications](backend/apps/notifications)`)
|
||||||
|
- `Notification` model records booking-related notifications and enforces uniqueness per booking/recipient/event/channel.
|
||||||
|
- Services in `[backend/apps/notifications/services.py](backend/apps/notifications/services.py)` reuse OTP providers to send SMS/WhatsApp messages for booking created/confirmed/cancelled events, with localization via `preferred_language`.
|
||||||
|
- Booking views call `notify_booking_lifecycle` / `notify_on_status_change` to trigger notifications for customers and staff.
|
||||||
|
- **Testing**
|
||||||
|
- Solid tests around:
|
||||||
|
- Phone normalization, OTP rate limiting, and phone auth flows (`[backend/apps/accounts/tests](backend/apps/accounts/tests)`).
|
||||||
|
- Booking integrity and overlap rules (`[backend/apps/bookings/tests](backend/apps/bookings/tests)`).
|
||||||
|
- Payment idempotency and Moyasar webhook handling (`[backend/apps/payments/tests](backend/apps/payments/tests)`).
|
||||||
|
- Booking notifications on create/status change (`[backend/apps/notifications/tests](backend/apps/notifications/tests)`).
|
||||||
|
- `docs/risks.md` explicitly tracks several known gaps around auth, booking, payments, data/UX, and ops.
|
||||||
|
|
||||||
|
### Frontend (React, Vite)
|
||||||
|
|
||||||
|
- **Structure**
|
||||||
|
- Vite React app at `[frontend](frontend)` with entry in `[frontend/src/main.jsx](frontend/src/main.jsx)` and single top-level component in `[frontend/src/App.jsx](frontend/src/App.jsx)`.
|
||||||
|
- No `react-router` or multi-page routing; the entire experience is one composed screen.
|
||||||
|
- **Current Features**
|
||||||
|
- **Salon search**
|
||||||
|
- Text search field calling `/salons/?q=<query>` via a small API client in `[frontend/src/api/client.js](frontend/src/api/client.js)`.
|
||||||
|
- Renders responsive list of salons with rating, city, and phone.
|
||||||
|
- **Localization/i18n**
|
||||||
|
- `react-i18next` setup in `[frontend/src/i18n/index.js](frontend/src/i18n/index.js)` with `en` and `ar-sa` translations.
|
||||||
|
- Locale preference stored in `localStorage`; applies `lang` and `dir` on the document.
|
||||||
|
- **Payments beta**
|
||||||
|
- A form in `App.jsx` that sends payment creation requests to `/api/payments/` using the Moyasar-style payload, with configurable `booking_id`, source type, token, and callback URL.
|
||||||
|
- Optionally includes a Bearer token from a manually-entered access token field.
|
||||||
|
- On success, can redirect to `redirect_url` and shows the raw JSON response.
|
||||||
|
- **State & Tests**
|
||||||
|
- All state is local to `App.jsx` via `useState`/`useEffect`; there is no centralized state management or domain hooks yet.
|
||||||
|
- A single test file `[frontend/src/App.test.jsx](frontend/src/App.test.jsx)` covers hero copy and locale/RTL behavior, but not search or payments.
|
||||||
|
|
||||||
|
## Glaring Design And Architectural Issues
|
||||||
|
|
||||||
|
### Backend Risks
|
||||||
|
|
||||||
|
- **Incomplete provider implementations for production-critical flows**
|
||||||
|
- Twilio/Unifonic providers in `[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)` are stubs with `NotImplementedError` for send methods, yet they are the backbone for both OTP and booking notifications.
|
||||||
|
- `MoyasarGateway` lacks `capture_payment` and `refund_payment` implementations, limiting payment lifecycle coverage.
|
||||||
|
- **Risk**: Code reads “production ready” at the API level, but the underlying integrations are not, which could cause outages if deployed naively.
|
||||||
|
- **Tight coupling between OTP and notifications**
|
||||||
|
- Notification services import the OTP provider mapping and default `NOTIFICATION_PROVIDER` to `OTP_PROVIDER`, binding booking notifications to auth configuration.
|
||||||
|
- **Risk**: Changing OTP providers or adding a second channel for marketing/ops notifications will be harder and could have unintended side effects.
|
||||||
|
- **Synchronous IO-heavy work in request/response path**
|
||||||
|
- OTP sends, booking notifications, and payment gateway calls all occur synchronously inside view methods (`perform_create`, `create`, etc.).
|
||||||
|
- **Risk**: Slow or flaky providers will degrade API latency and user experience; retries and backoff are hard to implement without background jobs.
|
||||||
|
- **Cross-app domain coupling without a clear orchestration layer**
|
||||||
|
- `apps.bookings` depends on salons and notifications; notifications depend on accounts (OTP providers) and bookings; payments depend on bookings.
|
||||||
|
- **Risk**: As you add more lifecycle rules (e.g., auto-confirm booking on payment, send reminders, handle refunds), the spaghetti of cross-imports will grow unless you introduce clearer service boundaries.
|
||||||
|
- **Auth model vs login patterns**
|
||||||
|
- `User.USERNAME_FIELD` is email, while phone-based JWT issuance happens via custom endpoints.
|
||||||
|
- **Risk**: This split can confuse clients and admin tooling and may complicate future flows like social login or SSO unless you standardize on an identifier strategy.
|
||||||
|
- **Docs drift around ExecPlans**
|
||||||
|
- `AGENTS.md` references `docs/execplans/payments-moyasar.md` as the active plan, while `PLANS.md` names `docs/execplans/booking-notifications.md`.
|
||||||
|
- **Risk**: Contributors may follow different “active” plans, causing architectural inconsistency.
|
||||||
|
|
||||||
|
### Frontend Risks
|
||||||
|
|
||||||
|
- **Monolithic `App` component with no routing**
|
||||||
|
- `App.jsx` mixes hero/search, salon listing, payments, and locale controls.
|
||||||
|
- There is no `react-router` or notion of separate flows (auth, booking, profile, payments).
|
||||||
|
- **Risk**: Extending to full MVP flows (auth, booking, history, management) will quickly become unmanageable without a routing/page system and domain separation.
|
||||||
|
- **Domain logic embedded in UI components**
|
||||||
|
- API payload construction, validation rules (e.g. for source types), and error handling are implemented directly in `App.jsx` rather than reusable hooks or service modules.
|
||||||
|
- **Risk**: Code reuse, testing, and evolution (e.g., adding booking pages or admin consoles) will be painful.
|
||||||
|
- **Minimal test coverage for critical flows**
|
||||||
|
- Only i18n and hero copy are tested; search behavior, API integration, and payments are untested.
|
||||||
|
- **Risk**: Regressions in search, booking, and payments UX will slip through as MVP grows.
|
||||||
|
- **Styling & layout fragility**
|
||||||
|
- `frontend/src/styles.css` uses `::root` instead of `:root`, which likely breaks intended global CSS variables or base styles.
|
||||||
|
- Global CSS is tightly bound to the monolithic `App` layout.
|
||||||
|
- **Risk**: Visual regressions and layout churn when introducing additional pages or components.
|
||||||
|
- **Ad hoc auth token handling**
|
||||||
|
- The “access token” is a free-form text field that gets persisted as `auth_token` in `localStorage` and injected into payment requests.
|
||||||
|
- **Risk**: This is a placeholder pattern that does not scale to full auth (refresh tokens, logout, token rotation) and will need to be replaced.
|
||||||
|
|
||||||
|
### Cross-Cutting Risks
|
||||||
|
|
||||||
|
- **Lack of async/background processing**
|
||||||
|
- No Celery/RQ or similar job queue; all side effects are synchronous.
|
||||||
|
- **Risk**: Scaling SMS/WhatsApp notifications, email, and payment webhook fan-out will be difficult.
|
||||||
|
- **Observability and admin tooling gaps**
|
||||||
|
- Errors for payments and notifications are recorded in model metadata but not clearly surfaced in logs, dashboards, or admin views.
|
||||||
|
- **Risk**: Operational debugging during MVP rollout will be slower and more error-prone.
|
||||||
|
- **Internationalization strategy vs future markets**
|
||||||
|
- Phone normalization and defaults are tailored to KSA, which is correct for MVP, but `docs/risks.md` already notes the need to broaden later.
|
||||||
|
- **Risk**: Without clear boundaries between KSA-specific logic and generic logic, future expansion may require invasive changes.
|
||||||
|
|
||||||
|
## MVP Roadmap (Aligned To Phase 1)
|
||||||
|
|
||||||
|
This roadmap assumes “MVP” is equivalent to **Phase 1: Core MVP Reliability** in `AGENTS.md`, with a thin but robust frontend on top.
|
||||||
|
|
||||||
|
### Phase 0 – Architecture & Production Readiness Hardening
|
||||||
|
|
||||||
|
- **Finalize critical provider implementations**
|
||||||
|
- Implement at least one real SMS/WhatsApp provider (Twilio or Unifonic) end-to-end, behind the existing provider abstraction in `[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)`, and wire it into `[backend/apps/notifications/services.py](backend/apps/notifications/services.py)`.
|
||||||
|
- Implement or deliberately fence off `capture_payment` and `refund_payment` in `[backend/apps/payments/services/gateway.py](backend/apps/payments/services/gateway.py)` so that the MVP either fully supports or explicitly does not support partial captures/refunds.
|
||||||
|
- **Clarify and document boundaries**
|
||||||
|
- Add a short architecture section in `README`/docs describing how `accounts`, `salons`, `bookings`, `payments`, and `notifications` interact, and what each service is responsible for.
|
||||||
|
- Resolve the ExecPlan drift by making `AGENTS.md` and `PLANS.md` agree on the current active plan.
|
||||||
|
- **Introduce minimal async infrastructure (optional but recommended)**
|
||||||
|
- Decide whether MVP will ship with a task queue (e.g., Celery with Redis) or keep everything synchronous for the initial launch.
|
||||||
|
- If yes, introduce a thin task layer for OTP sends and booking notifications while preserving current APIs; if not, at least add clear timeouts/logging to external calls.
|
||||||
|
- **Frontend scaffolding for growth**
|
||||||
|
- Introduce `react-router` and refactor `App.jsx` into route-based pages (e.g., `HomePage`, `BookPage`, `PaymentPage`, `ProfilePage`), with shared layout and navigation.
|
||||||
|
- Extract salon search, payment form, and locale controls into dedicated components and hooks.
|
||||||
|
|
||||||
|
### Phase 1 – Core MVP Features (Backend + Frontend)
|
||||||
|
|
||||||
|
- **Phone-first auth UX**
|
||||||
|
- Backend: reuse existing phone auth endpoints; ensure error messages and rate-limit responses are predictable and localized.
|
||||||
|
- Frontend:
|
||||||
|
- Build OTP-based login/registration screens that drive `/api/auth/phone/request/` and `/api/auth/phone/verify/`.
|
||||||
|
- Introduce an auth context (or similar) to store access/refresh tokens, current user profile, and handle logout.
|
||||||
|
- Defer social login beyond MVP, but keep API surface ready for it.
|
||||||
|
- **Booking search and creation**
|
||||||
|
- Backend is largely ready (booking validation and role-based access); review booking serializers in `[backend/apps/bookings/serializers.py](backend/apps/bookings/serializers.py)` to ensure they expose all fields needed for frontend booking forms.
|
||||||
|
- Frontend:
|
||||||
|
- Build a **booking flow**: pick a salon → choose service → select staff (optional) → select date/time slot (based on availability endpoints) → confirm booking.
|
||||||
|
- Add a “My bookings” page showing upcoming and past bookings, tied into the existing `/api/bookings/` endpoints.
|
||||||
|
- **Payments via Moyasar**
|
||||||
|
- Backend: confirm `create_payment_for_booking` contracts (inputs/outputs) are stable and documented.
|
||||||
|
- Frontend:
|
||||||
|
- Evolve the payments beta UI into a **post-booking payment step** that starts from a selected booking and guides the user into Moyasar’s hosted flow, then shows a status page.
|
||||||
|
- Handle callback/return from Moyasar (even if via manual redirect URL in MVP) and surface payment success/failure to the user.
|
||||||
|
- **Booking lifecycle notifications**
|
||||||
|
- Backend already sends notifications on booking create and status changes; align messaging templates with product UX and ensure localization strings exist.
|
||||||
|
- Frontend: surface notification results implicitly via booking status changes and explicit messages on the booking details page.
|
||||||
|
- **Localization foundations**
|
||||||
|
- Backend: ensure `UserLocaleMiddleware` and translation strings cover all user-visible errors in auth, bookings, payments, and notifications.
|
||||||
|
- Frontend: expand `en/ar-sa` translations to cover auth, booking, and payment flows; verify RTL layouts on the new screens.
|
||||||
|
- **Tests for critical flows**
|
||||||
|
- Backend: extend tests where needed to cover new booking/payment edge cases (e.g., tying booking status to payment status if/when introduced).
|
||||||
|
- Frontend: add Vitest tests for:
|
||||||
|
- Phone auth screen flows (request/verify success + errors).
|
||||||
|
- Booking flow (form validation, happy path, displaying server-side errors).
|
||||||
|
- Payment initiation from an existing booking.
|
||||||
|
|
||||||
|
### Phase 2 – Manager Ops Lite (Post-MVP, partially covered now)
|
||||||
|
|
||||||
|
- **Salon and staff management UI**
|
||||||
|
- Use existing salon and staff models to build basic management pages for salon owners/managers (create/update services, staff, availability).
|
||||||
|
- **Calendar views and rescheduling**
|
||||||
|
- Provide calendar views for staff/managers to view daily/weekly bookings and reschedule or cancel within defined rules.
|
||||||
|
- **Reviews and ratings**
|
||||||
|
- Implement review submission and rating recalculation on the backend, with corresponding frontend components.
|
||||||
|
- **Reporting basics**
|
||||||
|
- Lightweight reports for managers (upcoming bookings, simple revenue summaries based on payment status) using existing payments data.
|
||||||
|
|
||||||
|
### Phase 3 – Scale & Compliance (Later)
|
||||||
|
|
||||||
|
- **Audit logging** for admin actions and booking/payment state changes.
|
||||||
|
- **PDPL/GDPR retention policies** and data export tooling.
|
||||||
|
- **Observability**: structured logging, metrics, and basic dashboards for auth failures, OTP send failures, payment errors, and notification outcomes.
|
||||||
|
|
||||||
|
## Architecture Overview Diagram
|
||||||
|
|
||||||
|
A simplified view of the target MVP data flow:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
user["User (web/mobile)"] --> frontend["ReactFrontend"]
|
||||||
|
frontend --> api["DjangoAPI"]
|
||||||
|
|
||||||
|
api --> accounts["AccountsApp"]
|
||||||
|
api --> salons["SalonsApp"]
|
||||||
|
api --> bookings["BookingsApp"]
|
||||||
|
api --> payments["PaymentsApp"]
|
||||||
|
api --> notifications["NotificationsApp"]
|
||||||
|
|
||||||
|
accounts --> otpProviders["OtpProviders"]
|
||||||
|
notifications --> otpProviders
|
||||||
|
payments --> moyasar["MoyasarGateway"]
|
||||||
|
|
||||||
|
bookings --> notifications
|
||||||
|
bookings --> payments
|
||||||
|
salons --> bookings
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This diagram clarifies current coupling and highlights where future refactors (e.g., a dedicated messaging service or payment orchestrator) could sit.
|
||||||
|
|
||||||
|
## Validation And Acceptance For This Plan
|
||||||
|
|
||||||
|
- The roadmap is accepted when:
|
||||||
|
- It clearly maps current backend and frontend capabilities to the Phase 1 MVP goals in `AGENTS.md`.
|
||||||
|
- It identifies the highest-risk design/architecture issues that could impede MVP reliability or future evolution.
|
||||||
|
- It provides a phased, concrete sequence of work packages that can be implemented via ExecPlans (e.g., booking notifications, Moyasar payments, phone auth UX).
|
||||||
|
- Each major feature area (auth, bookings, payments, notifications, localization, tests) should have or adopt an ExecPlan under `docs/execplans/` in line with `PLANS.md` before implementation begins.
|
||||||
|
|
||||||
Reference in New Issue
Block a user