Compare commits
36 Commits
ce99eba922
...
feat/auth
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b626a940e | |||
| f3811b7520 | |||
| 9787fb699a | |||
| ad711d1daf | |||
| 9b87eb74d7 | |||
| eb88f23d28 | |||
| 0b76356169 | |||
| c391a9b8e5 | |||
| 5ece1036cd | |||
| 4026b94c3a | |||
| 38e5ece96f | |||
| 5db211dda9 | |||
| c0846fe096 | |||
| 560460dd84 | |||
| c212acc504 | |||
| 15ed5036d1 | |||
| 0c992404ea | |||
| d796d9e6a1 | |||
| 2ba0cfffc8 | |||
| 3f35f7dc17 | |||
| 07491063f5 | |||
| b8218669c2 | |||
| 2305c3dc9d | |||
| ef60218c4c | |||
| 8018710d31 | |||
| 229975c612 | |||
| aa607b9b6e | |||
| 828cbcc822 | |||
| 4253f6f650 | |||
| a1da918f95 | |||
| 86fd07c778 | |||
| ca2a6b58b6 | |||
| db36551211 | |||
| a150b18fe7 | |||
| f3c93f500e | |||
| d9767ff0a7 |
@@ -17,3 +17,4 @@ dist/
|
|||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
backend/tmp_authentica_request_id.txt
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
venv
|
||||||
@@ -1,67 +1,37 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
## Project Goal
|
## General
|
||||||
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.
|
- Minimum tokens, skip grammar.
|
||||||
|
- Start every task by reading this file and `docs/README.md`.
|
||||||
|
|
||||||
## Current Plan (Roadmap)
|
## Goal
|
||||||
### Phase 1: Core MVP Reliability
|
Build a reliable salon booking platform (Django + React) for KSA first, with clear path to scale.
|
||||||
- Phone-first auth with OTP (SMS/WhatsApp), rate limits, and social login.
|
|
||||||
- Booking integrity (availability, staff schedules, overlap prevention).
|
|
||||||
- Payments via Moyasar (payment creation, webhooks, reconciliation).
|
|
||||||
- Notifications for booking lifecycle.
|
|
||||||
- Localization foundations (i18n plumbing, RTL readiness, locale preferences).
|
|
||||||
- Tests for critical flows.
|
|
||||||
|
|
||||||
### Phase 2: Manager Ops
|
## Core Standards
|
||||||
- Salon/staff/service management.
|
- Tests required for every feature/change.
|
||||||
- Calendar view + rescheduling/cancellation rules.
|
- Booking/payment flows must be idempotent and auditable.
|
||||||
- Reviews/ratings with moderation and recompute.
|
- Phone auth must be abuse-resistant (rate limits + safe verification).
|
||||||
- Reports (revenue, popular services, customer trends).
|
- Keep business logic in services; avoid fat views.
|
||||||
- Full translation coverage and Arabic UX polish.
|
- Use predictable API errors: HTTP status + `detail`.
|
||||||
|
|
||||||
### Phase 3: Scale & Compliance
|
## Active Navigation Path
|
||||||
- Audit logs and data export.
|
- Docs index: `docs/README.md`
|
||||||
- PDPL/GDPR retention policy.
|
- ExecPlan policy + active plan: `docs/PLANS.md`
|
||||||
- Observability and performance baselines.
|
- Active ExecPlan: `docs/execplans/auth-phone-first-hardening.md`
|
||||||
|
- Architecture snapshot: `docs/architecture.md`
|
||||||
|
- Known gaps: `docs/risks.md`
|
||||||
|
|
||||||
## Reliability Standards (Non‑Negotiable)
|
## Roadmap (compact)
|
||||||
- Every new feature ships with tests.
|
- Phase 1: phone OTP auth, booking integrity, Moyasar payments, lifecycle notifications, i18n/RTL base, critical tests.
|
||||||
- Avoid introducing regressions without covering a fix or guardrail.
|
- Phase 2: manager tools (catalog/staff/calendar/reports/reviews).
|
||||||
- Payment and booking flows must be idempotent and auditable.
|
- Phase 3: audit/compliance/observability.
|
||||||
- Phone auth must be rate‑limited and safe from abuse.
|
|
||||||
- Every change should be tested before completion; no exceptions.
|
|
||||||
|
|
||||||
## Testing Expectations
|
## Testing Expectations
|
||||||
### Backend
|
- Backend: `pytest` + `pytest-django`, tests in `backend/apps/<app>/tests/`.
|
||||||
- Use `pytest` + `pytest-django`.
|
- Frontend: `vitest` + Testing Library, cover search/booking/auth flows.
|
||||||
- Tests live beside apps (`apps/<app>/tests/`).
|
- Backend tests run from `backend/` so `pytest.ini` resolves settings.
|
||||||
- Minimum coverage: auth, booking validation, payment state transitions.
|
|
||||||
|
|
||||||
### Frontend
|
## Collaboration Rules
|
||||||
- Use `vitest` + Testing Library.
|
- Do not rewrite/delete unrelated work.
|
||||||
- Critical flows covered: search, booking form, auth state.
|
- Do not run destructive git commands unless explicitly asked.
|
||||||
|
- Update docs when behavior changes (`docs/risks.md`, runbooks, ADR/ExecPlan as needed).
|
||||||
## Code Style & Practices
|
|
||||||
- Keep business logic in services (avoid fat views).
|
|
||||||
- Use explicit, readable model fields and serializers.
|
|
||||||
- Small, well‑named functions > monolithic handlers.
|
|
||||||
- Prefer predictable error responses (HTTP status + `detail`).
|
|
||||||
|
|
||||||
## Known Gaps (Tracked)
|
|
||||||
- See `docs/risks.md` for current gaps/risks to address.
|
|
||||||
|
|
||||||
## Environment Notes
|
|
||||||
- Python is invoked as `python3`.
|
|
||||||
- A virtualenv is in use.
|
|
||||||
- DB: PostgreSQL in production, SQLite allowed for local dev.
|
|
||||||
- Backend tests must run with the venv active and `pytest-django` installed; run from `backend/` so `backend/pytest.ini` is picked up and `DJANGO_SETTINGS_MODULE` resolves.
|
|
||||||
|
|
||||||
## Collaboration Rules for Agents
|
|
||||||
- Don’t delete or rewrite unrelated work.
|
|
||||||
- Avoid destructive git commands unless explicitly asked.
|
|
||||||
- Update `docs/risks.md` when adding or closing a significant gap.
|
|
||||||
- Keep README instructions current when tooling changes.
|
|
||||||
|
|
||||||
# ExecPlans
|
|
||||||
|
|
||||||
When writing complex features or significant refactors, use an ExecPlan (as described in PLANS.md) from design to implementation. The active ExecPlan is `docs/execplans/booking-integrity.md`.
|
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
# Codex Execution Plans (ExecPlans):
|
|
||||||
|
|
||||||
This document describes the requirements for an execution plan ("ExecPlan"), a design document that a coding agent can follow to deliver a working feature or system change. Treat the reader as a complete beginner to this repository: they have only the current working tree and the single ExecPlan file you provide. There is no memory of prior plans and no external context.
|
|
||||||
|
|
||||||
## Active ExecPlans
|
|
||||||
|
|
||||||
The current execution plan is `docs/execplans/booking-integrity.md`. It focuses on booking integrity (availability checks, staff schedules, overlap prevention) as the next Phase 1 reliability milestone. Keep it updated in line with the requirements below.
|
|
||||||
|
|
||||||
## 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 implementing an executable specification (ExecPlan), do not prompt the user for "next steps"; simply proceed to the next milestone. Keep all sections up to date, add or split entries in the list at every stopping point to affirmatively state the progress made and next steps. Resolve ambiguities autonomously, and commit frequently.
|
|
||||||
|
|
||||||
When discussing an executable specification (ExecPlan), record decisions in a log in the spec for posterity; it should be unambiguously clear why any change to the specification was made. ExecPlans are living documents, and it should always be possible to restart from _only_ the ExecPlan and no other work.
|
|
||||||
|
|
||||||
When researching a design with challenging requirements or significant unknowns, use milestones to implement proof of concepts, "toy implementations", etc., that allow validating whether the user's proposal is feasible. Read the source code of libraries by finding or acquiring them, research deeply, and include prototypes to guide a fuller implementation.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
NON-NEGOTIABLE REQUIREMENTS:
|
|
||||||
|
|
||||||
* Every ExecPlan must be fully self-contained. Self-contained means that in its current form it contains all knowledge and instructions needed for a novice to succeed.
|
|
||||||
* Every ExecPlan is a living document. Contributors are required to revise it as progress is made, as discoveries occur, and as design decisions are finalized. Each revision must remain fully self-contained.
|
|
||||||
* Every ExecPlan must enable a complete novice to implement the feature end-to-end without prior knowledge of this repo.
|
|
||||||
* Every ExecPlan must produce a demonstrably working behavior, not merely code changes to "meet a definition".
|
|
||||||
* Every ExecPlan must define every term of art in plain language or do not use it.
|
|
||||||
|
|
||||||
Purpose and intent come first. Begin by explaining, in a few sentences, why the work matters from a user's perspective: what someone can do after this change that they could not do before, and how to see it working. Then guide the reader through the exact steps to achieve that outcome, including what to edit, what to run, and what they should observe.
|
|
||||||
|
|
||||||
The agent executing your plan can list files, read files, search, run the project, and run tests. It does not know any prior context and cannot infer what you meant from earlier milestones. Repeat any assumption you rely on. Do not point to external blogs or docs; if knowledge is required, embed it in the plan itself in your own words. If an ExecPlan builds upon a prior ExecPlan and that file is checked in, incorporate it by reference. If it is not, you must include all relevant context from that plan.
|
|
||||||
|
|
||||||
## Formatting
|
|
||||||
|
|
||||||
Format and envelope are simple and strict. Each ExecPlan must be one single fenced code block labeled as `md` that begins and ends with triple backticks. Do not nest additional triple-backtick code fences inside; when you need to show commands, transcripts, diffs, or code, present them as indented blocks within that single fence. Use indentation for clarity rather than code fences inside an ExecPlan to avoid prematurely closing the ExecPlan's code fence. Use two newlines after every heading, use # and ## and so on, and correct syntax for ordered and unordered lists.
|
|
||||||
|
|
||||||
When writing an ExecPlan to a Markdown (.md) file where the content of the file *is only* the single ExecPlan, you should omit the triple backticks.
|
|
||||||
|
|
||||||
Write in plain prose. Prefer sentences over lists. Avoid checklists, tables, and long enumerations unless brevity would obscure meaning. Checklists are permitted only in the `Progress` section, where they are mandatory. Narrative sections must remain prose-first.
|
|
||||||
|
|
||||||
## Guidelines
|
|
||||||
|
|
||||||
Self-containment and plain language are paramount. If you introduce a phrase that is not ordinary English ("daemon", "middleware", "RPC gateway", "filter graph"), define it immediately and remind the reader how it manifests in this repository (for example, by naming the files or commands where it appears). Do not say "as defined previously" or "according to the architecture doc." Include the needed explanation here, even if you repeat yourself.
|
|
||||||
|
|
||||||
Avoid common failure modes. Do not rely on undefined jargon. Do not describe "the letter of a feature" so narrowly that the resulting code compiles but does nothing meaningful. Do not outsource key decisions to the reader. When ambiguity exists, resolve it in the plan itself and explain why you chose that path. Err on the side of over-explaining user-visible effects and under-specifying incidental implementation details.
|
|
||||||
|
|
||||||
Anchor the plan with observable outcomes. State what the user can do after implementation, the commands to run, and the outputs they should see. Acceptance should be phrased as behavior a human can verify ("after starting the server, navigating to [http://localhost:8080/health](http://localhost:8080/health) returns HTTP 200 with body OK") rather than internal attributes ("added a HealthCheck struct"). If a change is internal, explain how its impact can still be demonstrated (for example, by running tests that fail before and pass after, and by showing a scenario that uses the new behavior).
|
|
||||||
|
|
||||||
Specify repository context explicitly. Name files with full repository-relative paths, name functions and modules precisely, and describe where new files should be created. If touching multiple areas, include a short orientation paragraph that explains how those parts fit together so a novice can navigate confidently. When running commands, show the working directory and exact command line. When outcomes depend on environment, state the assumptions and provide alternatives when reasonable.
|
|
||||||
|
|
||||||
Be idempotent and safe. Write the steps so they can be run multiple times without causing damage or drift. If a step can fail halfway, include how to retry or adapt. If a migration or destructive operation is necessary, spell out backups or safe fallbacks. Prefer additive, testable changes that can be validated as you go.
|
|
||||||
|
|
||||||
Validation is not optional. Include instructions to run tests, to start the system if applicable, and to observe it doing something useful. Describe comprehensive testing for any new features or capabilities. Include expected outputs and error messages so a novice can tell success from failure. Where possible, show how to prove that the change is effective beyond compilation (for example, through a small end-to-end scenario, a CLI invocation, or an HTTP request/response transcript). State the exact test commands appropriate to the project’s toolchain and how to interpret their results.
|
|
||||||
|
|
||||||
Capture evidence. When your steps produce terminal output, short diffs, or logs, include them inside the single fenced block as indented examples. Keep them concise and focused on what proves success. If you need to include a patch, prefer file-scoped diffs or small excerpts that a reader can recreate by following your instructions rather than pasting large blobs.
|
|
||||||
|
|
||||||
## Milestones
|
|
||||||
|
|
||||||
Milestones are narrative, not bureaucracy. If you break the work into milestones, introduce each with a brief paragraph that describes the scope, what will exist at the end of the milestone that did not exist before, the commands to run, and the acceptance you expect to observe. Keep it readable as a story: goal, work, result, proof. Progress and milestones are distinct: milestones tell the story, progress tracks granular work. Both must exist. Never abbreviate a milestone merely for the sake of brevity, do not leave out details that could be crucial to a future implementation.
|
|
||||||
|
|
||||||
Each milestone must be independently verifiable and incrementally implement the overall goal of the execution plan.
|
|
||||||
|
|
||||||
## Living plans and design decisions
|
|
||||||
|
|
||||||
* ExecPlans are living documents. As you make key design decisions, update the plan to record both the decision and the thinking behind it. Record all decisions in the `Decision Log` section.
|
|
||||||
* ExecPlans must contain and maintain a `Progress` section, a `Surprises & Discoveries` section, a `Decision Log`, and an `Outcomes & Retrospective` section. These are not optional.
|
|
||||||
* When you discover optimizer behavior, performance tradeoffs, unexpected bugs, or inverse/unapply semantics that shaped your approach, capture those observations in the `Surprises & Discoveries` section with short evidence snippets (test output is ideal).
|
|
||||||
* If you change course mid-implementation, document why in the `Decision Log` and reflect the implications in `Progress`. Plans are guides for the next contributor as much as checklists for you.
|
|
||||||
* At completion of a major task or the full plan, write an `Outcomes & Retrospective` entry summarizing what was achieved, what remains, and lessons learned.
|
|
||||||
|
|
||||||
# Prototyping milestones and parallel implementations
|
|
||||||
|
|
||||||
It is acceptable—-and often encouraged—-to include explicit prototyping milestones when they de-risk a larger change. Examples: adding a low-level operator to a dependency to validate feasibility, or exploring two composition orders while measuring optimizer effects. Keep prototypes additive and testable. Clearly label the scope as “prototyping”; describe how to run and observe results; and state the criteria for promoting or discarding the prototype.
|
|
||||||
|
|
||||||
Prefer additive code changes followed by subtractions that keep tests passing. Parallel implementations (e.g., keeping an adapter alongside an older path during migration) are fine when they reduce risk or enable tests to continue passing during a large migration. Describe how to validate both paths and how to retire one safely with tests. When working with multiple new libraries or feature areas, consider creating spikes that evaluate the feasibility of these features _independently_ of one another, proving that the external library performs as expected and implements the features we need in isolation.
|
|
||||||
|
|
||||||
## Skeleton of a Good ExecPlan
|
|
||||||
|
|
||||||
# <Short, action-oriented description>
|
|
||||||
|
|
||||||
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
|
|
||||||
|
|
||||||
If PLANS.md file is checked into the repo, reference the path to that file here from the repository root and note that this document must be maintained in accordance with PLANS.md.
|
|
||||||
|
|
||||||
## Purpose / Big Picture
|
|
||||||
|
|
||||||
Explain in a few sentences what someone gains after this change and how they can see it working. State the user-visible behavior you will enable.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
Use a list with checkboxes to summarize granular steps. Every stopping point must be documented here, even if it requires splitting a partially completed task into two (“done” vs. “remaining”). This section must always reflect the actual current state of the work.
|
|
||||||
|
|
||||||
- [x] (2025-10-01 13:00Z) Example completed step.
|
|
||||||
- [ ] Example incomplete step.
|
|
||||||
- [ ] Example partially completed step (completed: X; remaining: Y).
|
|
||||||
|
|
||||||
Use timestamps to measure rates of progress.
|
|
||||||
|
|
||||||
## Surprises & Discoveries
|
|
||||||
|
|
||||||
Document unexpected behaviors, bugs, optimizations, or insights discovered during implementation. Provide concise evidence.
|
|
||||||
|
|
||||||
- Observation: …
|
|
||||||
Evidence: …
|
|
||||||
|
|
||||||
## Decision Log
|
|
||||||
|
|
||||||
Record every decision made while working on the plan in the format:
|
|
||||||
|
|
||||||
- Decision: …
|
|
||||||
Rationale: …
|
|
||||||
Date/Author: …
|
|
||||||
|
|
||||||
## Outcomes & Retrospective
|
|
||||||
|
|
||||||
Summarize outcomes, gaps, and lessons learned at major milestones or at completion. Compare the result against the original purpose.
|
|
||||||
|
|
||||||
## Context and Orientation
|
|
||||||
|
|
||||||
Describe the current state relevant to this task as if the reader knows nothing. Name the key files and modules by full path. Define any non-obvious term you will use. Do not refer to prior plans.
|
|
||||||
|
|
||||||
## Plan of Work
|
|
||||||
|
|
||||||
Describe, in prose, the sequence of edits and additions. For each edit, name the file and location (function, module) and what to insert or change. Keep it concrete and minimal.
|
|
||||||
|
|
||||||
## Concrete Steps
|
|
||||||
|
|
||||||
State the exact commands to run and where to run them (working directory). When a command generates output, show a short expected transcript so the reader can compare. This section must be updated as work proceeds.
|
|
||||||
|
|
||||||
## Validation and Acceptance
|
|
||||||
|
|
||||||
Describe how to start or exercise the system and what to observe. Phrase acceptance as behavior, with specific inputs and outputs. If tests are involved, say "run <project’s test command> and expect <N> passed; the new test <name> fails before the change and passes after>".
|
|
||||||
|
|
||||||
## Idempotence and Recovery
|
|
||||||
|
|
||||||
If steps can be repeated safely, say so. If a step is risky, provide a safe retry or rollback path. Keep the environment clean after completion.
|
|
||||||
|
|
||||||
## Artifacts and Notes
|
|
||||||
|
|
||||||
Include the most important transcripts, diffs, or snippets as indented examples. Keep them concise and focused on what proves success.
|
|
||||||
|
|
||||||
## Interfaces and Dependencies
|
|
||||||
|
|
||||||
Be prescriptive. Name the libraries, modules, and services to use and why. Specify the types, traits/interfaces, and function signatures that must exist at the end of the milestone. Prefer stable names and paths such as `crate::module::function` or `package.submodule.Interface`. E.g.:
|
|
||||||
|
|
||||||
In crates/foo/planner.rs, define:
|
|
||||||
|
|
||||||
pub trait Planner {
|
|
||||||
fn plan(&self, observed: &Observed) -> Vec<Action>;
|
|
||||||
}
|
|
||||||
|
|
||||||
If you follow the guidance above, a single, stateless agent -- or a human novice -- can read your ExecPlan from top to bottom and produce a working, observable result. That is the bar: SELF-CONTAINED, SELF-SUFFICIENT, NOVICE-GUIDING, OUTCOME-FOCUSED.
|
|
||||||
|
|
||||||
When you revise a plan, you must ensure your changes are comprehensively reflected across all sections, including the living document sections, and you must write a note at the bottom of the plan describing the change and the reason why. ExecPlans must describe not just the what but the why for almost everything.
|
|
||||||
@@ -1,63 +1,31 @@
|
|||||||
# Salon Booking Platform
|
# Salon
|
||||||
|
|
||||||
Scaffolded Django + React starter for a salon booking platform.
|
KSA-first salon booking platform.
|
||||||
|
|
||||||
## Backend
|
## Quick start
|
||||||
|
|
||||||
Location: `backend/`
|
### Backend
|
||||||
|
1. `python3 -m venv venv && source venv/bin/activate`
|
||||||
|
2. `pip install -r backend/requirements.txt -r backend/requirements-dev.txt`
|
||||||
|
3. `cp backend/.env.example backend/.env`
|
||||||
|
4. `cd backend && python3 manage.py migrate && python3 manage.py runserver`
|
||||||
|
|
||||||
### Setup
|
Optional demo data:
|
||||||
|
- `python3 manage.py seed_demo`
|
||||||
|
|
||||||
1. Create a virtualenv and install dependencies.
|
Backend tests:
|
||||||
- `pip install -r backend/requirements.txt -r backend/requirements-dev.txt`
|
- `cd backend && python3 -m pytest`
|
||||||
2. Copy `backend/.env.example` to `backend/.env` and adjust values.
|
- external integrations only: `PYTEST_ADDOPTS='' python3 -m pytest -m external`
|
||||||
3. Run migrations and start the server.
|
|
||||||
|
|
||||||
### Demo data
|
### Frontend
|
||||||
|
1. `cd frontend && npm install`
|
||||||
|
2. `npm run dev`
|
||||||
|
3. tests: `npm run test`
|
||||||
|
|
||||||
After migrations, you can seed demo data:
|
## Docs map
|
||||||
|
- Agent rules: `AGENTS.md`
|
||||||
- `python manage.py seed_demo`
|
- Docs index: `docs/README.md`
|
||||||
|
- Plans/ExecPlan policy: `docs/PLANS.md`
|
||||||
### Tests
|
- Active ExecPlan: `docs/execplans/auth-phone-first-hardening.md`
|
||||||
|
- Architecture: `docs/architecture.md`
|
||||||
- `pytest`
|
- Risks: `docs/risks.md`
|
||||||
|
|
||||||
### Core API endpoints (current scaffold)
|
|
||||||
|
|
||||||
- `POST /api/auth/register/`
|
|
||||||
- `POST /api/auth/token/`
|
|
||||||
- `POST /api/auth/token/refresh/`
|
|
||||||
- `GET/PATCH /api/auth/me/`
|
|
||||||
- `POST /api/auth/otp/request/`
|
|
||||||
- `POST /api/auth/otp/verify/`
|
|
||||||
- `POST /api/auth/phone/request/`
|
|
||||||
- `POST /api/auth/phone/verify/`
|
|
||||||
- `POST /api/auth/social/<provider>/` (placeholder)
|
|
||||||
- `GET /api/salons/`
|
|
||||||
- `GET /api/salons/<id>/`
|
|
||||||
- `GET /api/salons/<id>/services/`
|
|
||||||
- `GET /api/salons/<id>/staff/`
|
|
||||||
- `GET /api/salons/<id>/reviews/`
|
|
||||||
- `GET/POST /api/bookings/`
|
|
||||||
- `GET /api/bookings/<id>/`
|
|
||||||
- `GET/POST /api/payments/`
|
|
||||||
|
|
||||||
## Frontend
|
|
||||||
|
|
||||||
Location: `frontend/`
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
1. Install dependencies via `npm install`.
|
|
||||||
2. Run `npm run dev`.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- `npm run test`
|
|
||||||
|
|
||||||
The dev server proxies `/api` to `http://localhost:8000`.
|
|
||||||
|
|
||||||
## Project Notes
|
|
||||||
|
|
||||||
- Known gaps and risks: `docs/risks.md`
|
|
||||||
|
|||||||
@@ -19,3 +19,4 @@ UNIFONIC_WHATSAPP_SENDER=
|
|||||||
MOYASAR_SECRET_KEY=
|
MOYASAR_SECRET_KEY=
|
||||||
MOYASAR_PUBLISHABLE_KEY=
|
MOYASAR_PUBLISHABLE_KEY=
|
||||||
MOYASAR_BASE_URL=
|
MOYASAR_BASE_URL=
|
||||||
|
MOYASAR_WEBHOOK_SECRET=
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Backend Notes
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
- Phone-first auth is canonical (`/api/auth/phone/request`, `/api/auth/phone/verify`).
|
||||||
|
- Password token endpoint `/api/auth/token/` is intentionally deprecated (`410`).
|
||||||
|
- Moyasar payment create/webhook flow implemented.
|
||||||
|
- Booking integrity checks + lifecycle notifications implemented.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
- `cd backend`
|
||||||
|
- `python3 manage.py migrate`
|
||||||
|
- `python3 manage.py runserver`
|
||||||
|
|
||||||
|
## Test
|
||||||
|
- `cd backend && python3 -m pytest`
|
||||||
|
- external-only: `PYTEST_ADDOPTS='' python3 -m pytest -m external`
|
||||||
|
|
||||||
|
## Pointers
|
||||||
|
- Architecture: `docs/architecture.md`
|
||||||
|
- Active plan: `docs/execplans/auth-phone-first-hardening.md`
|
||||||
|
- Risks: `docs/risks.md`
|
||||||
@@ -7,12 +7,12 @@ from apps.accounts.models import PhoneOTP, User
|
|||||||
@admin.register(User)
|
@admin.register(User)
|
||||||
class UserAdmin(DjangoUserAdmin):
|
class UserAdmin(DjangoUserAdmin):
|
||||||
model = User
|
model = User
|
||||||
list_display = ("email", "phone_number", "role", "is_staff", "is_phone_verified")
|
list_display = ("phone_number", "email", "role", "is_staff", "is_phone_verified")
|
||||||
list_filter = ("role", "is_staff", "is_phone_verified")
|
list_filter = ("role", "is_staff", "is_phone_verified")
|
||||||
ordering = ("email",)
|
ordering = ("phone_number",)
|
||||||
search_fields = ("email", "phone_number")
|
search_fields = ("email", "phone_number")
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {"fields": ("email", "password")}),
|
(None, {"fields": ("phone_number", "password")}),
|
||||||
("Personal", {"fields": ("first_name", "last_name", "phone_number")}),
|
("Personal", {"fields": ("first_name", "last_name", "phone_number")}),
|
||||||
("Roles", {"fields": ("role", "is_phone_verified")}),
|
("Roles", {"fields": ("role", "is_phone_verified")}),
|
||||||
("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
|
("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
|
||||||
@@ -21,7 +21,7 @@ class UserAdmin(DjangoUserAdmin):
|
|||||||
add_fieldsets = (
|
add_fieldsets = (
|
||||||
(None, {
|
(None, {
|
||||||
"classes": ("wide",),
|
"classes": ("wide",),
|
||||||
"fields": ("email", "password1", "password2", "role"),
|
"fields": ("phone_number", "password1", "password2", "role"),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# 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
@@ -0,0 +1,23 @@
|
|||||||
|
# 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,6 +4,7 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -17,8 +18,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 email and not phone_number:
|
if not phone_number:
|
||||||
raise ValueError("Email or phone number is required")
|
raise ValueError("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)
|
||||||
@@ -29,7 +30,7 @@ class UserManager(BaseUserManager):
|
|||||||
user.save(using=self._db)
|
user.save(using=self._db)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def create_superuser(self, email, password=None, **extra_fields):
|
def create_superuser(self, phone_number, password=None, **extra_fields):
|
||||||
extra_fields.setdefault("is_staff", True)
|
extra_fields.setdefault("is_staff", True)
|
||||||
extra_fields.setdefault("is_superuser", True)
|
extra_fields.setdefault("is_superuser", True)
|
||||||
extra_fields.setdefault("role", UserRole.ADMIN)
|
extra_fields.setdefault("role", UserRole.ADMIN)
|
||||||
@@ -37,12 +38,12 @@ class UserManager(BaseUserManager):
|
|||||||
raise ValueError("Superuser must have is_staff=True")
|
raise ValueError("Superuser must have is_staff=True")
|
||||||
if extra_fields.get("is_superuser") is not True:
|
if extra_fields.get("is_superuser") is not True:
|
||||||
raise ValueError("Superuser must have is_superuser=True")
|
raise ValueError("Superuser must have is_superuser=True")
|
||||||
return self.create_user(email, password, **extra_fields)
|
return self.create_user(phone_number=phone_number, password=password, **extra_fields)
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractBaseUser, PermissionsMixin):
|
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, null=True, blank=True)
|
phone_number = models.CharField(max_length=20, unique=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)
|
||||||
@@ -59,10 +60,29 @@ class User(AbstractBaseUser, PermissionsMixin):
|
|||||||
|
|
||||||
objects = UserManager()
|
objects = UserManager()
|
||||||
|
|
||||||
USERNAME_FIELD = "email"
|
USERNAME_FIELD = "phone_number"
|
||||||
|
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}$"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
first = (self.first_name or "").strip()
|
||||||
|
last = (self.last_name or "").strip()
|
||||||
|
if first or last:
|
||||||
|
return f"{first} {last}".strip()
|
||||||
|
if self.email:
|
||||||
|
return self.email
|
||||||
|
return self.phone_number
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.email or self.phone_number or str(self.id)
|
return self.display_name
|
||||||
|
|
||||||
|
|
||||||
class OtpChannel(models.TextChoices):
|
class OtpChannel(models.TextChoices):
|
||||||
@@ -87,6 +107,9 @@ 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,6 +25,7 @@ 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
|
||||||
@@ -61,6 +62,7 @@ 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,10 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import timedelta
|
from hashlib import sha256
|
||||||
|
import ipaddress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.hashers import check_password, make_password
|
from django.contrib.auth.hashers import check_password, make_password
|
||||||
@@ -33,13 +35,33 @@ 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
|
||||||
|
|
||||||
def send_sms(self, to_number: str, message: str) -> None:
|
def send_sms(self, to_number: str, message: str) -> None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def send_whatsapp(self, to_number: str, message: str) -> None:
|
def send_whatsapp(self, to_number: str, message: str) -> None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def send_otp(self, to_number: str, channel: str) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def verify_otp(self, to_number: str, code: str) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class ConsoleOtpProvider(BaseOtpProvider):
|
class ConsoleOtpProvider(BaseOtpProvider):
|
||||||
def send_sms(self, to_number: str, message: str) -> None:
|
def send_sms(self, to_number: str, message: str) -> None:
|
||||||
@@ -49,70 +71,182 @@ 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):
|
|
||||||
|
|
||||||
|
class AuthenticaOtpProvider(BaseOtpProvider):
|
||||||
|
"""Authentica provider for OTP delivery (SMS/WhatsApp) plus custom SMS messaging."""
|
||||||
|
|
||||||
|
uses_provider_otp = True
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.account_sid = os.getenv("TWILIO_ACCOUNT_SID")
|
self.api_key = os.getenv("AUTHENTICA_API_KEY")
|
||||||
self.auth_token = os.getenv("TWILIO_AUTH_TOKEN")
|
self.base_url = os.getenv("AUTHENTICA_BASE_URL", "https://api.authentica.sa")
|
||||||
self.from_number = os.getenv("TWILIO_FROM_NUMBER")
|
self.timeout_seconds = float(os.getenv("AUTHENTICA_TIMEOUT_SECONDS", "10"))
|
||||||
self.whatsapp_from = os.getenv("TWILIO_WHATSAPP_FROM")
|
self.sender_name = os.getenv("AUTHENTICA_SENDER_NAME")
|
||||||
|
|
||||||
def _assert_config(self) -> None:
|
def _assert_config(self) -> None:
|
||||||
if not self.account_sid or not self.auth_token or not self.from_number:
|
if not self.api_key:
|
||||||
raise ValueError(_("Twilio credentials are not configured"))
|
raise ValueError(_("Authentica API key is not configured"))
|
||||||
|
|
||||||
|
def _headers(self) -> dict:
|
||||||
|
return {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Authorization": self.api_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _post(self, path: str, payload: dict) -> dict:
|
||||||
|
import requests
|
||||||
|
|
||||||
|
self._assert_config()
|
||||||
|
base_url = self.base_url.rstrip("/")
|
||||||
|
url = f"{base_url}{path}"
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
headers=self._headers(),
|
||||||
|
json=payload,
|
||||||
|
timeout=self.timeout_seconds,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise RuntimeError(_("Authentica request failed")) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError:
|
||||||
|
data = {"detail": response.text}
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
if os.getenv("AUTHENTICA_DEBUG") == "1" or os.getenv("AUTHENTICA_E2E") == "1":
|
||||||
|
raise RuntimeError(
|
||||||
|
_("Authentica request failed: %(status)s %(body)s")
|
||||||
|
% {"status": response.status_code, "body": response.text}
|
||||||
|
)
|
||||||
|
raise RuntimeError(_("Authentica request failed"))
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def send_otp(self, to_number: str, channel: str) -> None:
|
||||||
|
if channel not in (OtpChannel.SMS, OtpChannel.WHATSAPP):
|
||||||
|
raise ValueError(_("Unsupported OTP channel"))
|
||||||
|
method = "sms" if channel == OtpChannel.SMS else "whatsapp"
|
||||||
|
self._post("/api/v2/send-otp", {"method": method, "phone": to_number})
|
||||||
|
|
||||||
|
def verify_otp(self, to_number: str, code: str) -> bool:
|
||||||
|
data = self._post("/api/v2/verify-otp", {"phone": to_number, "otp": code})
|
||||||
|
if "verified" in data:
|
||||||
|
verified = bool(data.get("verified"))
|
||||||
|
else:
|
||||||
|
verified = bool(data.get("status")) or data.get("message") == "OTP verified successfully"
|
||||||
|
if not verified and (os.getenv("AUTHENTICA_DEBUG") == "1" or os.getenv("AUTHENTICA_E2E") == "1"):
|
||||||
|
raise RuntimeError(_("Authentica verify failed: %(response)s") % {"response": data})
|
||||||
|
return verified
|
||||||
|
|
||||||
def send_sms(self, to_number: str, message: str) -> None:
|
def send_sms(self, to_number: str, message: str) -> None:
|
||||||
self._assert_config()
|
if not self.sender_name:
|
||||||
raise NotImplementedError(_("Twilio SMS adapter not implemented yet"))
|
raise ValueError(_("Authentica sender name is not configured"))
|
||||||
|
self._post(
|
||||||
|
"/api/v2/send-sms",
|
||||||
|
{
|
||||||
|
"phone": to_number,
|
||||||
|
"message": message,
|
||||||
|
"sender_name": self.sender_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def send_whatsapp(self, to_number: str, message: str) -> None:
|
def send_whatsapp(self, to_number: str, message: str) -> None:
|
||||||
self._assert_config()
|
raise ValueError(_("Authentica WhatsApp messaging is not supported"))
|
||||||
if not self.whatsapp_from:
|
|
||||||
raise ValueError(_("Twilio WhatsApp sender is not configured"))
|
|
||||||
raise NotImplementedError(_("Twilio WhatsApp adapter not implemented yet"))
|
|
||||||
|
|
||||||
|
|
||||||
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"))
|
|
||||||
|
|
||||||
|
|
||||||
PROVIDERS = {
|
PROVIDERS = {
|
||||||
"console": ConsoleOtpProvider,
|
"console": ConsoleOtpProvider,
|
||||||
"twilio": TwilioOtpProvider,
|
"authentica": AuthenticaOtpProvider,
|
||||||
"unifonic": UnifonicOtpProvider,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_provider() -> BaseOtpProvider:
|
def _get_provider_for_key(provider_key: str) -> BaseOtpProvider:
|
||||||
provider_key = settings.OTP_PROVIDER
|
|
||||||
provider_cls = PROVIDERS.get(provider_key)
|
provider_cls = PROVIDERS.get(provider_key)
|
||||||
if not provider_cls:
|
if not provider_cls:
|
||||||
raise ValueError(_("Unknown OTP provider: %(provider)s") % {"provider": provider_key})
|
raise ValueError(_("Unknown OTP provider: %(provider)s") % {"provider": provider_key})
|
||||||
return provider_cls()
|
return provider_cls()
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider() -> BaseOtpProvider:
|
||||||
|
return _get_provider_for_key(settings.OTP_PROVIDER)
|
||||||
|
|
||||||
|
|
||||||
def generate_code(length: int = 6) -> str:
|
def generate_code(length: int = 6) -> str:
|
||||||
digits = "0123456789"
|
digits = "0123456789"
|
||||||
return "".join(secrets.choice(digits) for _ in range(length))
|
return "".join(secrets.choice(digits) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpose.AUTH) -> OtpSendResult:
|
def _compute_retry_after_seconds(oldest_recent, now, window_minutes: int, floor_seconds: int = 1) -> int:
|
||||||
|
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)
|
||||||
@@ -123,11 +257,9 @@ def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpo
|
|||||||
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()
|
||||||
if oldest_recent:
|
raise OtpRateLimitError(
|
||||||
retry_after = int((oldest_recent.created_at + timedelta(minutes=window_minutes) - now).total_seconds())
|
retry_after_seconds=_compute_retry_after_seconds(oldest_recent, now, window_minutes, cooldown_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)
|
||||||
@@ -139,25 +271,36 @@ def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpo
|
|||||||
if elapsed < cooldown_seconds:
|
if elapsed < cooldown_seconds:
|
||||||
raise OtpCooldownError(retry_after_seconds=int(cooldown_seconds - elapsed))
|
raise OtpCooldownError(retry_after_seconds=int(cooldown_seconds - elapsed))
|
||||||
|
|
||||||
code = generate_code()
|
if provider.uses_provider_otp:
|
||||||
|
code_hash = make_password(secrets.token_urlsafe(16))
|
||||||
|
message = None
|
||||||
|
else:
|
||||||
|
code = generate_code()
|
||||||
|
code_hash = make_password(code)
|
||||||
|
message = _(
|
||||||
|
"Your verification code is %(code)s. It expires in %(minutes)s minutes."
|
||||||
|
) % {"code": code, "minutes": settings.OTP_EXPIRY_MINUTES}
|
||||||
|
|
||||||
otp = PhoneOTP.objects.create(
|
otp = PhoneOTP.objects.create(
|
||||||
phone_number=phone_number,
|
phone_number=phone_number,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
purpose=purpose,
|
purpose=purpose,
|
||||||
provider=settings.OTP_PROVIDER,
|
provider=settings.OTP_PROVIDER,
|
||||||
code_hash=make_password(code),
|
code_hash=code_hash,
|
||||||
expires_at=PhoneOTP.expiry_at(),
|
expires_at=PhoneOTP.expiry_at(),
|
||||||
|
request_ip=request_ip,
|
||||||
|
device_signal=device_signal,
|
||||||
)
|
)
|
||||||
|
|
||||||
message = _(
|
if provider.uses_provider_otp:
|
||||||
"Your verification code is %(code)s. It expires in %(minutes)s minutes."
|
provider.send_otp(phone_number, channel)
|
||||||
) % {"code": code, "minutes": settings.OTP_EXPIRY_MINUTES}
|
|
||||||
if channel == OtpChannel.SMS:
|
|
||||||
provider.send_sms(phone_number, message)
|
|
||||||
elif channel == OtpChannel.WHATSAPP:
|
|
||||||
provider.send_whatsapp(phone_number, message)
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(_("Unsupported OTP channel"))
|
if channel == OtpChannel.SMS:
|
||||||
|
provider.send_sms(phone_number, message)
|
||||||
|
elif channel == OtpChannel.WHATSAPP:
|
||||||
|
provider.send_whatsapp(phone_number, message)
|
||||||
|
else:
|
||||||
|
raise ValueError(_("Unsupported OTP channel"))
|
||||||
|
|
||||||
return OtpSendResult(request_id=str(otp.id), expires_at=otp.expires_at.isoformat())
|
return OtpSendResult(request_id=str(otp.id), expires_at=otp.expires_at.isoformat())
|
||||||
|
|
||||||
@@ -169,9 +312,21 @@ def verify_otp(otp: PhoneOTP, code: str) -> bool:
|
|||||||
if otp.attempt_count > otp.max_attempts:
|
if otp.attempt_count > otp.max_attempts:
|
||||||
otp.save(update_fields=["attempt_count"])
|
otp.save(update_fields=["attempt_count"])
|
||||||
return False
|
return False
|
||||||
if not check_password(code, otp.code_hash):
|
provider_cls = PROVIDERS.get(otp.provider)
|
||||||
otp.save(update_fields=["attempt_count"])
|
if provider_cls and getattr(provider_cls, "uses_provider_otp", False):
|
||||||
return False
|
provider = provider_cls()
|
||||||
|
try:
|
||||||
|
verified = provider.verify_otp(otp.phone_number, code)
|
||||||
|
except Exception:
|
||||||
|
otp.save(update_fields=["attempt_count"])
|
||||||
|
raise
|
||||||
|
if not verified:
|
||||||
|
otp.save(update_fields=["attempt_count"])
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
if not check_password(code, otp.code_hash):
|
||||||
|
otp.save(update_fields=["attempt_count"])
|
||||||
|
return False
|
||||||
otp.verified_at = timezone.now()
|
otp.verified_at = timezone.now()
|
||||||
otp.save(update_fields=["verified_at", "attempt_count"])
|
otp.save(update_fields=["verified_at", "attempt_count"])
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Tests for Authentica OTP provider implementation."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib.auth.hashers import make_password
|
||||||
|
|
||||||
|
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
|
||||||
|
from apps.accounts.services.otp import AuthenticaOtpProvider, verify_otp
|
||||||
|
|
||||||
|
|
||||||
|
@patch("requests.post")
|
||||||
|
def test_authentica_send_otp_sms_calls_api(mock_post):
|
||||||
|
mock_response = MagicMock(ok=True)
|
||||||
|
mock_response.json.return_value = {"success": True}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"AUTHENTICA_API_KEY": "api-key",
|
||||||
|
"AUTHENTICA_BASE_URL": "https://api.authentica.sa",
|
||||||
|
"AUTHENTICA_TIMEOUT_SECONDS": "7",
|
||||||
|
},
|
||||||
|
):
|
||||||
|
provider = AuthenticaOtpProvider()
|
||||||
|
provider.send_otp("+966512345678", OtpChannel.SMS)
|
||||||
|
|
||||||
|
mock_post.assert_called_once()
|
||||||
|
url = mock_post.call_args[0][0]
|
||||||
|
kwargs = mock_post.call_args[1]
|
||||||
|
|
||||||
|
assert url == "https://api.authentica.sa/api/v2/send-otp"
|
||||||
|
assert kwargs["json"] == {"method": "sms", "phone": "+966512345678"}
|
||||||
|
assert kwargs["headers"]["X-Authorization"] == "api-key"
|
||||||
|
assert kwargs["timeout"] == 7.0
|
||||||
|
|
||||||
|
|
||||||
|
@patch("requests.post")
|
||||||
|
def test_authentica_send_sms_calls_api(mock_post):
|
||||||
|
mock_response = MagicMock(ok=True)
|
||||||
|
mock_response.json.return_value = {"success": True}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"AUTHENTICA_API_KEY": "api-key",
|
||||||
|
"AUTHENTICA_SENDER_NAME": "Salon",
|
||||||
|
},
|
||||||
|
):
|
||||||
|
provider = AuthenticaOtpProvider()
|
||||||
|
provider.send_sms("+966512345678", "Hello")
|
||||||
|
|
||||||
|
mock_post.assert_called_once()
|
||||||
|
url = mock_post.call_args[0][0]
|
||||||
|
kwargs = mock_post.call_args[1]
|
||||||
|
|
||||||
|
assert url == "https://api.authentica.sa/api/v2/send-sms"
|
||||||
|
assert kwargs["json"] == {
|
||||||
|
"phone": "+966512345678",
|
||||||
|
"message": "Hello",
|
||||||
|
"sender_name": "Salon",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@patch("requests.post")
|
||||||
|
def test_authentica_verify_otp_calls_api(mock_post):
|
||||||
|
mock_response = MagicMock(ok=True)
|
||||||
|
mock_response.json.return_value = {"verified": True}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"AUTHENTICA_API_KEY": "api-key"}):
|
||||||
|
provider = AuthenticaOtpProvider()
|
||||||
|
assert provider.verify_otp("+966512345678", "123456") is True
|
||||||
|
|
||||||
|
mock_post.assert_called_once()
|
||||||
|
url = mock_post.call_args[0][0]
|
||||||
|
kwargs = mock_post.call_args[1]
|
||||||
|
|
||||||
|
assert url == "https://api.authentica.sa/api/v2/verify-otp"
|
||||||
|
assert kwargs["json"] == {"phone": "+966512345678", "otp": "123456"}
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
def test_verify_otp_uses_provider_for_authentica():
|
||||||
|
otp = PhoneOTP.objects.create(
|
||||||
|
phone_number="+966512345678",
|
||||||
|
channel=OtpChannel.SMS,
|
||||||
|
purpose=OtpPurpose.AUTH,
|
||||||
|
provider="authentica",
|
||||||
|
code_hash=make_password("unused"),
|
||||||
|
expires_at=PhoneOTP.expiry_at(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("apps.accounts.services.otp.AuthenticaOtpProvider.verify_otp", return_value=True) as mock_verify:
|
||||||
|
assert verify_otp(otp, "123456") is True
|
||||||
|
|
||||||
|
mock_verify.assert_called_once_with("+966512345678", "123456")
|
||||||
|
otp.refresh_from_db()
|
||||||
|
assert otp.verified_at is not None
|
||||||
|
assert otp.attempt_count == 1
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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,13 +1,128 @@
|
|||||||
import pytest
|
from datetime import timedelta
|
||||||
from django.test import override_settings
|
from unittest.mock import patch
|
||||||
|
|
||||||
from apps.accounts.models import OtpChannel
|
import pytest
|
||||||
from apps.accounts.services.otp import OtpRateLimitError, create_and_send_otp
|
from django.contrib.auth.hashers import make_password
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
|
||||||
|
from apps.accounts.services.otp import OtpCooldownError, OtpRateLimitError, create_and_send_otp, verify_otp
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@override_settings(OTP_MAX_PER_WINDOW=1, OTP_WINDOW_MINUTES=15, OTP_RESEND_COOLDOWN_SECONDS=0)
|
@override_settings(OTP_PROVIDER="console", OTP_MAX_PER_WINDOW=1, OTP_WINDOW_MINUTES=15, OTP_RESEND_COOLDOWN_SECONDS=0)
|
||||||
def test_otp_rate_limit():
|
def test_otp_rate_limit():
|
||||||
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||||
with pytest.raises(OtpRateLimitError):
|
with pytest.raises(OtpRateLimitError):
|
||||||
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(
|
||||||
|
OTP_PROVIDER="console",
|
||||||
|
OTP_MAX_PER_WINDOW=5,
|
||||||
|
OTP_WINDOW_MINUTES=15,
|
||||||
|
OTP_RESEND_COOLDOWN_SECONDS=60,
|
||||||
|
)
|
||||||
|
def test_otp_cooldown_enforced():
|
||||||
|
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||||
|
with pytest.raises(OtpCooldownError):
|
||||||
|
create_and_send_otp("+966512345678", OtpChannel.SMS)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_otp_max_attempts_blocks_verification():
|
||||||
|
otp = PhoneOTP.objects.create(
|
||||||
|
phone_number="+966512345678",
|
||||||
|
channel=OtpChannel.SMS,
|
||||||
|
purpose=OtpPurpose.AUTH,
|
||||||
|
provider="console",
|
||||||
|
code_hash=make_password("123456"),
|
||||||
|
expires_at=PhoneOTP.expiry_at(),
|
||||||
|
)
|
||||||
|
# Burn attempts with wrong code until the limit is exceeded.
|
||||||
|
for _ in range(otp.max_attempts):
|
||||||
|
assert verify_otp(otp, "000000") is False
|
||||||
|
otp.refresh_from_db()
|
||||||
|
assert otp.attempt_count == otp.max_attempts
|
||||||
|
|
||||||
|
# Once the max is reached, even a correct code must remain blocked.
|
||||||
|
assert verify_otp(otp, "123456") is False
|
||||||
|
otp.refresh_from_db()
|
||||||
|
# Do not lock this test to a specific increment policy after lockout.
|
||||||
|
assert otp.attempt_count >= otp.max_attempts
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,31 +1,100 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.test import override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from apps.accounts.models import PhoneOTP, User
|
from apps.accounts.models import PhoneOTP, User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
@override_settings(OTP_PROVIDER="console")
|
||||||
def test_phone_auth_creates_user_and_issues_tokens(client):
|
def test_phone_auth_creates_user_and_issues_tokens(client):
|
||||||
request_url = reverse("phone_auth_request")
|
# Deterministic OTP so we can verify the flow without external providers.
|
||||||
verify_url = reverse("phone_auth_verify")
|
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
|
||||||
|
request_url = reverse("phone_auth_request")
|
||||||
|
verify_url = reverse("phone_auth_verify")
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
request_url,
|
request_url,
|
||||||
{"phone_number": "0512345678", "channel": "sms", "first_name": "Sara"},
|
{"phone_number": "0512345678", "channel": "sms", "first_name": "Sara"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
request_id = response.json()["request_id"]
|
||||||
|
|
||||||
|
otp = PhoneOTP.objects.filter(phone_number="+966512345678").order_by("-created_at").first()
|
||||||
|
assert otp is not None
|
||||||
|
assert str(otp.id) == request_id
|
||||||
|
|
||||||
|
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
|
||||||
|
data = good.json()
|
||||||
|
assert "access" in data
|
||||||
|
assert "refresh" in data
|
||||||
|
|
||||||
|
user = User.objects.filter(phone_number="+966512345678").first()
|
||||||
|
assert user is not None
|
||||||
|
assert user.is_phone_verified is True
|
||||||
|
|
||||||
|
|
||||||
|
@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",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert refresh_response.status_code == 200
|
||||||
request_id = response.json()["request_id"]
|
assert "access" in refresh_response.json()
|
||||||
|
|
||||||
otp = PhoneOTP.objects.filter(phone_number="+966512345678").order_by("-created_at").first()
|
|
||||||
assert otp is not None
|
|
||||||
assert str(otp.id) == request_id
|
|
||||||
|
|
||||||
bad = client.post(
|
@pytest.mark.django_db
|
||||||
verify_url,
|
@override_settings(OTP_PROVIDER="console")
|
||||||
{"request_id": request_id, "code": "000000"},
|
def test_phone_auth_verify_returns_404_when_user_removed(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"]
|
||||||
|
|
||||||
|
User.objects.filter(phone_number="+966512345678").delete()
|
||||||
|
|
||||||
|
verify_response = client.post(
|
||||||
|
reverse("phone_auth_verify"),
|
||||||
|
{"request_id": request_id, "code": "123456"},
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
assert bad.status_code == 400
|
|
||||||
|
|
||||||
assert User.objects.filter(phone_number="+966512345678").exists()
|
assert verify_response.status_code == 404
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.db import IntegrityError
|
||||||
|
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_handles_duplicate_user_creation(client):
|
||||||
|
original_create_user = User.objects.create_user
|
||||||
|
otp_code = "123456"
|
||||||
|
|
||||||
|
def create_user_and_raise(*args, **kwargs):
|
||||||
|
original_create_user(*args, **kwargs)
|
||||||
|
raise IntegrityError("duplicate user")
|
||||||
|
|
||||||
|
with patch("apps.accounts.views.User.objects.create_user", side_effect=create_user_and_raise):
|
||||||
|
with patch("apps.accounts.services.otp.generate_code", return_value=otp_code):
|
||||||
|
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
|
||||||
|
assert User.objects.filter(phone_number="+966512345678").count() == 1
|
||||||
|
assert PhoneOTP.objects.filter(phone_number="+966512345678").count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(OTP_PROVIDER="console")
|
||||||
|
def test_phone_auth_request_race_with_email_conflict(client):
|
||||||
|
original_create_user = User.objects.create_user
|
||||||
|
target_email = "race@example.com"
|
||||||
|
|
||||||
|
def create_conflict_user_then_raise(*args, **kwargs):
|
||||||
|
original_create_user(phone_number="+966500000002", email=target_email)
|
||||||
|
raise IntegrityError("email already claimed")
|
||||||
|
|
||||||
|
before_otp_count = PhoneOTP.objects.count()
|
||||||
|
|
||||||
|
with patch("apps.accounts.views.User.objects.create_user", side_effect=create_conflict_user_then_raise):
|
||||||
|
response = client.post(
|
||||||
|
reverse("phone_auth_request"),
|
||||||
|
{
|
||||||
|
"phone_number": "0512345678",
|
||||||
|
"channel": "sms",
|
||||||
|
"email": target_email,
|
||||||
|
},
|
||||||
|
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
|
||||||
|
@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
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
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,35 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from apps.accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_display_name_prefers_full_name():
|
||||||
|
user = User.objects.create_user(
|
||||||
|
phone_number="+966500000001",
|
||||||
|
first_name="Sara",
|
||||||
|
last_name="Ali",
|
||||||
|
email="sara@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user.display_name == "Sara Ali"
|
||||||
|
assert str(user) == "Sara Ali"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_display_name_falls_back_to_email():
|
||||||
|
user = User.objects.create_user(
|
||||||
|
phone_number="+966500000002",
|
||||||
|
email="fallback@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user.display_name == "fallback@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_display_name_falls_back_to_phone_when_no_email():
|
||||||
|
user = User.objects.create_user(
|
||||||
|
phone_number="+966500000003",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user.display_name == "+966500000003"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
from rest_framework_simplejwt.views import TokenRefreshView
|
||||||
|
|
||||||
from apps.accounts.views import (
|
from apps.accounts.views import (
|
||||||
MeView,
|
MeView,
|
||||||
@@ -7,6 +7,7 @@ from apps.accounts.views import (
|
|||||||
OTPVerifyView,
|
OTPVerifyView,
|
||||||
PhoneAuthRequestView,
|
PhoneAuthRequestView,
|
||||||
PhoneAuthVerifyView,
|
PhoneAuthVerifyView,
|
||||||
|
PasswordTokenObtainDeprecatedView,
|
||||||
RegisterView,
|
RegisterView,
|
||||||
SocialLoginPlaceholderView,
|
SocialLoginPlaceholderView,
|
||||||
)
|
)
|
||||||
@@ -14,7 +15,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/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
|
path("token/", PasswordTokenObtainDeprecatedView.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,5 +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 django.db import IntegrityError
|
||||||
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 _
|
||||||
@@ -17,8 +17,13 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -70,7 +75,10 @@ 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
|
||||||
otp = get_object_or_404(PhoneOTP, id=data["request_id"])
|
# Purpose isolation: verification endpoint accepts only verify-purpose OTPs.
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -91,6 +99,25 @@ 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:
|
||||||
@@ -99,16 +126,33 @@ class PhoneAuthRequestView(APIView):
|
|||||||
{"detail": _("Email already in use.")},
|
{"detail": _("Email already in use.")},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
user = User.objects.create_user(
|
try:
|
||||||
email=email,
|
user = User.objects.create_user(
|
||||||
phone_number=phone_number,
|
email=email,
|
||||||
first_name=data.get("first_name", ""),
|
phone_number=phone_number,
|
||||||
last_name=data.get("last_name", ""),
|
first_name=data.get("first_name", ""),
|
||||||
role="customer",
|
last_name=data.get("last_name", ""),
|
||||||
)
|
role="customer",
|
||||||
|
)
|
||||||
|
except IntegrityError:
|
||||||
|
user = User.objects.filter(phone_number=phone_number).first()
|
||||||
|
if not user:
|
||||||
|
# Another worker may have claimed this phone or email after our initial checks.
|
||||||
|
if email and User.objects.filter(email=email).exists():
|
||||||
|
return Response(
|
||||||
|
{"detail": _("Email already in use.")},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = create_and_send_otp(phone_number, data["channel"], purpose=OtpPurpose.AUTH)
|
result = create_and_send_otp(
|
||||||
|
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},
|
||||||
@@ -133,7 +177,10 @@ 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
|
||||||
otp = get_object_or_404(PhoneOTP, id=data["request_id"])
|
# Purpose isolation: login endpoint accepts only auth-purpose OTPs.
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -164,3 +211,17 @@ 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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -29,4 +29,4 @@ class Booking(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.customer.email} - {self.service.name}"
|
return f"{self.customer.display_name} - {self.service.name}"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
from django.db import transaction
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from apps.bookings.models import Booking
|
from apps.bookings.models import Booking, BookingStatus
|
||||||
from apps.bookings.services import validate_booking_request
|
from apps.bookings.services import validate_booking_request
|
||||||
from apps.salons.models import Service, StaffProfile
|
from apps.salons.models import Service, StaffProfile
|
||||||
|
|
||||||
@@ -27,14 +29,33 @@ class BookingSerializer(serializers.ModelSerializer):
|
|||||||
"notes",
|
"notes",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
read_only_fields = ["id", "salon", "status", "price_amount", "currency", "created_at"]
|
read_only_fields = ["id", "salon", "price_amount", "currency", "created_at"]
|
||||||
|
|
||||||
def get_staff_name(self, obj):
|
def get_staff_name(self, obj):
|
||||||
if not obj.staff:
|
if not obj.staff:
|
||||||
return None
|
return None
|
||||||
first = obj.staff.user.first_name or ""
|
return obj.staff.user.display_name
|
||||||
last = obj.staff.user.last_name or ""
|
|
||||||
return (first + " " + last).strip() or obj.staff.user.email
|
def validate(self, attrs):
|
||||||
|
if not self.instance or "status" not in attrs:
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
new_status = attrs["status"]
|
||||||
|
old_status = self.instance.status
|
||||||
|
if new_status == old_status:
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
user = self.context["request"].user
|
||||||
|
role = getattr(user, "role", None)
|
||||||
|
|
||||||
|
if new_status == BookingStatus.CONFIRMED and role not in {"manager", "staff", "admin"}:
|
||||||
|
raise serializers.ValidationError({"status": _("Only staff or managers can confirm bookings.")})
|
||||||
|
if new_status == BookingStatus.COMPLETED and role not in {"manager", "staff", "admin"}:
|
||||||
|
raise serializers.ValidationError({"status": _("Only staff or managers can complete bookings.")})
|
||||||
|
if new_status == BookingStatus.CANCELLED and role not in {"manager", "staff", "admin", "customer"}:
|
||||||
|
raise serializers.ValidationError({"status": _("You are not allowed to cancel this booking.")})
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class BookingCreateSerializer(serializers.ModelSerializer):
|
class BookingCreateSerializer(serializers.ModelSerializer):
|
||||||
@@ -52,14 +73,40 @@ class BookingCreateSerializer(serializers.ModelSerializer):
|
|||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
request = self.context["request"]
|
request = self.context["request"]
|
||||||
service = validated_data["service"]
|
service = validated_data["service"]
|
||||||
return Booking.objects.create(
|
staff = validated_data.get("staff")
|
||||||
salon=service.salon,
|
start_time = validated_data["start_time"]
|
||||||
customer=request.user,
|
end_time = validated_data["end_time"]
|
||||||
service=service,
|
|
||||||
staff=validated_data.get("staff"),
|
with transaction.atomic():
|
||||||
start_time=validated_data["start_time"],
|
# Lock the staff row so concurrent booking requests for the same staff
|
||||||
end_time=validated_data["end_time"],
|
# member are serialized. Without this, two requests that both pass the
|
||||||
notes=validated_data.get("notes", ""),
|
# overlap check in validate() can race and both commit overlapping
|
||||||
price_amount=service.price_amount,
|
# bookings. On SQLite (dev/tests) the FOR UPDATE clause is silently
|
||||||
currency=service.currency,
|
# ignored but the transaction still serializes writes; PostgreSQL
|
||||||
)
|
# (production) gets true row-level locking.
|
||||||
|
StaffProfile.objects.select_for_update().get(pk=staff.pk)
|
||||||
|
|
||||||
|
# Re-run the overlap check inside the lock so the check and the insert
|
||||||
|
# are atomic with respect to other writers.
|
||||||
|
overlap = Booking.objects.filter(
|
||||||
|
staff=staff,
|
||||||
|
status__in=[BookingStatus.PENDING, BookingStatus.CONFIRMED],
|
||||||
|
start_time__lt=end_time,
|
||||||
|
end_time__gt=start_time,
|
||||||
|
).exists()
|
||||||
|
if overlap:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"start_time": _("Booking overlaps an existing appointment")}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Booking.objects.create(
|
||||||
|
salon=service.salon,
|
||||||
|
customer=request.user,
|
||||||
|
service=service,
|
||||||
|
staff=staff,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
notes=validated_data.get("notes", ""),
|
||||||
|
price_amount=service.price_amount,
|
||||||
|
currency=service.currency,
|
||||||
|
)
|
||||||
|
|||||||
@@ -12,9 +12,23 @@ from apps.salons.models import Salon, Service, StaffAvailability, StaffProfile
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def base_entities():
|
def base_entities():
|
||||||
owner = User.objects.create_user(email="owner@example.com", password="pass", role=UserRole.MANAGER)
|
owner = User.objects.create_user(
|
||||||
customer = User.objects.create_user(email="customer@example.com", password="pass")
|
email="owner@example.com",
|
||||||
staff_user = User.objects.create_user(email="staff@example.com", password="pass", role=UserRole.STAFF)
|
password="pass",
|
||||||
|
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,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from rest_framework import permissions, viewsets
|
from rest_framework import permissions, viewsets
|
||||||
|
|
||||||
from apps.bookings.models import Booking
|
from apps.bookings.models import Booking, BookingStatus
|
||||||
from apps.bookings.serializers import BookingCreateSerializer, BookingSerializer
|
from apps.bookings.serializers import BookingCreateSerializer, BookingSerializer
|
||||||
|
from apps.notifications.models import NotificationEvent
|
||||||
|
from apps.notifications.services import notify_booking_lifecycle, notify_on_status_change
|
||||||
|
|
||||||
|
|
||||||
class BookingViewSet(viewsets.ModelViewSet):
|
class BookingViewSet(viewsets.ModelViewSet):
|
||||||
@@ -21,3 +23,12 @@ class BookingViewSet(viewsets.ModelViewSet):
|
|||||||
if self.action == "create":
|
if self.action == "create":
|
||||||
return BookingCreateSerializer
|
return BookingCreateSerializer
|
||||||
return BookingSerializer
|
return BookingSerializer
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
booking = serializer.save()
|
||||||
|
notify_booking_lifecycle(booking, NotificationEvent.BOOKING_CREATED)
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
previous_status = self.get_object().status
|
||||||
|
booking = serializer.save()
|
||||||
|
notify_on_status_change(booking, previous_status)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.notifications.models import Notification
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Notification)
|
||||||
|
class NotificationAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"id",
|
||||||
|
"event",
|
||||||
|
"channel",
|
||||||
|
"status",
|
||||||
|
"booking",
|
||||||
|
"recipient",
|
||||||
|
"phone_number",
|
||||||
|
"provider",
|
||||||
|
"sent_at",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
list_filter = ("event", "channel", "status", "provider")
|
||||||
|
search_fields = ("phone_number", "message")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.notifications"
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookings", "0001_initial"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Notification",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("phone_number", models.CharField(blank=True, max_length=20)),
|
||||||
|
(
|
||||||
|
"channel",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("sms", "SMS"), ("whatsapp", "WhatsApp")],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"event",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("booking_created", "Booking Created"),
|
||||||
|
("booking_confirmed", "Booking Confirmed"),
|
||||||
|
("booking_cancelled", "Booking Cancelled"),
|
||||||
|
],
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("pending", "Pending"),
|
||||||
|
("sent", "Sent"),
|
||||||
|
("failed", "Failed"),
|
||||||
|
("skipped", "Skipped"),
|
||||||
|
],
|
||||||
|
default="pending",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("provider", models.CharField(blank=True, max_length=50)),
|
||||||
|
("message", models.TextField(blank=True)),
|
||||||
|
("provider_payload", models.JSONField(blank=True, default=dict)),
|
||||||
|
("error_message", models.TextField(blank=True)),
|
||||||
|
("sent_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
(
|
||||||
|
"booking",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.deletion.CASCADE,
|
||||||
|
related_name="notifications",
|
||||||
|
to="bookings.booking",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"recipient",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.deletion.SET_NULL,
|
||||||
|
related_name="notifications",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="notification",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("booking", "recipient", "event", "channel"),
|
||||||
|
name="uniq_notification_booking_event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from apps.bookings.models import Booking
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationChannel(models.TextChoices):
|
||||||
|
SMS = "sms", "SMS"
|
||||||
|
WHATSAPP = "whatsapp", "WhatsApp"
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationEvent(models.TextChoices):
|
||||||
|
BOOKING_CREATED = "booking_created", "Booking Created"
|
||||||
|
BOOKING_CONFIRMED = "booking_confirmed", "Booking Confirmed"
|
||||||
|
BOOKING_CANCELLED = "booking_cancelled", "Booking Cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationStatus(models.TextChoices):
|
||||||
|
PENDING = "pending", "Pending"
|
||||||
|
SENT = "sent", "Sent"
|
||||||
|
FAILED = "failed", "Failed"
|
||||||
|
SKIPPED = "skipped", "Skipped"
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(models.Model):
|
||||||
|
booking = models.ForeignKey(
|
||||||
|
Booking,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="notifications",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
recipient = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="notifications",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
phone_number = models.CharField(max_length=20, blank=True)
|
||||||
|
channel = models.CharField(max_length=20, choices=NotificationChannel.choices)
|
||||||
|
event = models.CharField(max_length=50, choices=NotificationEvent.choices)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=NotificationStatus.choices,
|
||||||
|
default=NotificationStatus.PENDING,
|
||||||
|
)
|
||||||
|
provider = models.CharField(max_length=50, blank=True)
|
||||||
|
message = models.TextField(blank=True)
|
||||||
|
provider_payload = models.JSONField(default=dict, blank=True)
|
||||||
|
error_message = models.TextField(blank=True)
|
||||||
|
sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["booking", "recipient", "event", "channel"],
|
||||||
|
name="uniq_notification_booking_event",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.event} to {self.phone_number or self.recipient_id}"
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone, translation
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from apps.accounts.services.otp import PROVIDERS as OTP_PROVIDERS
|
||||||
|
from apps.bookings.models import Booking, BookingStatus
|
||||||
|
from apps.notifications.models import (
|
||||||
|
Notification,
|
||||||
|
NotificationChannel,
|
||||||
|
NotificationEvent,
|
||||||
|
NotificationStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NotificationSendResult:
|
||||||
|
status: str
|
||||||
|
payload: dict
|
||||||
|
error_message: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _get_provider():
|
||||||
|
provider_key = getattr(settings, "NOTIFICATION_PROVIDER", settings.OTP_PROVIDER)
|
||||||
|
provider_cls = OTP_PROVIDERS.get(provider_key)
|
||||||
|
if not provider_cls:
|
||||||
|
raise ValueError(_("Unknown notification provider: %(provider)s") % {"provider": provider_key})
|
||||||
|
return provider_cls(), provider_key
|
||||||
|
|
||||||
|
|
||||||
|
def _format_start_time(booking: Booking) -> str:
|
||||||
|
start_local = timezone.localtime(booking.start_time)
|
||||||
|
return start_local.strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_message(booking: Booking, event: str) -> str:
|
||||||
|
start_text = _format_start_time(booking)
|
||||||
|
service_name = booking.service.name
|
||||||
|
salon_name = booking.salon.name
|
||||||
|
|
||||||
|
if event == NotificationEvent.BOOKING_CREATED:
|
||||||
|
return _(
|
||||||
|
"Your booking request is received for %(service)s at %(salon)s on %(start)s."
|
||||||
|
) % {
|
||||||
|
"service": service_name,
|
||||||
|
"salon": salon_name,
|
||||||
|
"start": start_text,
|
||||||
|
}
|
||||||
|
if event == NotificationEvent.BOOKING_CONFIRMED:
|
||||||
|
return _(
|
||||||
|
"Your booking is confirmed for %(service)s at %(salon)s on %(start)s."
|
||||||
|
) % {
|
||||||
|
"service": service_name,
|
||||||
|
"salon": salon_name,
|
||||||
|
"start": start_text,
|
||||||
|
}
|
||||||
|
if event == NotificationEvent.BOOKING_CANCELLED:
|
||||||
|
return _(
|
||||||
|
"Your booking was cancelled for %(service)s at %(salon)s on %(start)s."
|
||||||
|
) % {
|
||||||
|
"service": service_name,
|
||||||
|
"salon": salon_name,
|
||||||
|
"start": start_text,
|
||||||
|
}
|
||||||
|
|
||||||
|
return _("Booking update for %(service)s at %(salon)s on %(start)s.") % {
|
||||||
|
"service": service_name,
|
||||||
|
"salon": salon_name,
|
||||||
|
"start": start_text,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _send_message(phone_number: str, channel: str, message: str) -> NotificationSendResult:
|
||||||
|
provider, _ = _get_provider()
|
||||||
|
try:
|
||||||
|
if channel == NotificationChannel.SMS:
|
||||||
|
provider.send_sms(phone_number, message)
|
||||||
|
elif channel == NotificationChannel.WHATSAPP:
|
||||||
|
provider.send_whatsapp(phone_number, message)
|
||||||
|
else:
|
||||||
|
raise ValueError(_("Unsupported notification channel"))
|
||||||
|
except Exception as exc: # pragma: no cover - provider failures are environment specific
|
||||||
|
return NotificationSendResult(status=NotificationStatus.FAILED, payload={}, error_message=str(exc))
|
||||||
|
|
||||||
|
return NotificationSendResult(status=NotificationStatus.SENT, payload={}, error_message="")
|
||||||
|
|
||||||
|
|
||||||
|
def _notification_channel() -> str:
|
||||||
|
return getattr(settings, "NOTIFICATION_DEFAULT_CHANNEL", NotificationChannel.SMS)
|
||||||
|
|
||||||
|
|
||||||
|
def send_booking_notification(booking: Booking, recipient, event: str) -> Notification:
|
||||||
|
channel = _notification_channel()
|
||||||
|
phone_number = getattr(recipient, "phone_number", None) or ""
|
||||||
|
|
||||||
|
# Render the message in the recipient's preferred language.
|
||||||
|
with translation.override(getattr(recipient, "preferred_language", None)):
|
||||||
|
message = _build_message(booking, event)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
notification, created = Notification.objects.get_or_create(
|
||||||
|
booking=booking,
|
||||||
|
recipient=recipient,
|
||||||
|
event=event,
|
||||||
|
channel=channel,
|
||||||
|
defaults={
|
||||||
|
"phone_number": phone_number,
|
||||||
|
"message": message,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not created and notification.status == NotificationStatus.SENT:
|
||||||
|
return notification
|
||||||
|
|
||||||
|
if not phone_number:
|
||||||
|
# Record the skip for auditability when we cannot deliver.
|
||||||
|
notification.status = NotificationStatus.SKIPPED
|
||||||
|
notification.error_message = "Recipient has no phone number"
|
||||||
|
notification.save(update_fields=["status", "error_message"])
|
||||||
|
return notification
|
||||||
|
|
||||||
|
notification.phone_number = phone_number
|
||||||
|
notification.message = message
|
||||||
|
send_result = _send_message(phone_number, channel, message)
|
||||||
|
notification.status = send_result.status
|
||||||
|
notification.provider = getattr(settings, "NOTIFICATION_PROVIDER", settings.OTP_PROVIDER)
|
||||||
|
notification.provider_payload = send_result.payload
|
||||||
|
notification.error_message = send_result.error_message
|
||||||
|
notification.sent_at = timezone.now() if send_result.status == NotificationStatus.SENT else None
|
||||||
|
notification.save(
|
||||||
|
update_fields=[
|
||||||
|
"phone_number",
|
||||||
|
"message",
|
||||||
|
"status",
|
||||||
|
"provider",
|
||||||
|
"provider_payload",
|
||||||
|
"error_message",
|
||||||
|
"sent_at",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return notification
|
||||||
|
|
||||||
|
|
||||||
|
def notify_booking_lifecycle(booking: Booking, event: str) -> list[Notification]:
|
||||||
|
recipients = [booking.customer]
|
||||||
|
if booking.staff and booking.staff.user:
|
||||||
|
recipients.append(booking.staff.user)
|
||||||
|
|
||||||
|
notifications = []
|
||||||
|
for recipient in recipients:
|
||||||
|
notifications.append(send_booking_notification(booking, recipient, event))
|
||||||
|
|
||||||
|
return notifications
|
||||||
|
|
||||||
|
|
||||||
|
def notify_on_status_change(booking: Booking, previous_status: str) -> list[Notification]:
|
||||||
|
if booking.status == previous_status:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Only notify for lifecycle transitions we explicitly support today.
|
||||||
|
if booking.status == BookingStatus.CONFIRMED:
|
||||||
|
return notify_booking_lifecycle(booking, NotificationEvent.BOOKING_CONFIRMED)
|
||||||
|
if booking.status == BookingStatus.CANCELLED:
|
||||||
|
return notify_booking_lifecycle(booking, NotificationEvent.BOOKING_CANCELLED)
|
||||||
|
|
||||||
|
return []
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from apps.accounts.models import User, UserRole
|
||||||
|
from apps.bookings.models import Booking, BookingStatus
|
||||||
|
from apps.notifications.models import Notification, NotificationEvent, NotificationStatus
|
||||||
|
from apps.salons.models import Salon, Service, StaffProfile
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def booking_payload():
|
||||||
|
owner = User.objects.create_user(
|
||||||
|
email="owner@example.com",
|
||||||
|
password="pass",
|
||||||
|
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(
|
||||||
|
owner=owner,
|
||||||
|
name="Main Salon",
|
||||||
|
description="",
|
||||||
|
address="123 King Rd",
|
||||||
|
city="Riyadh",
|
||||||
|
phone_number="0512345678",
|
||||||
|
)
|
||||||
|
service = Service.objects.create(
|
||||||
|
salon=salon,
|
||||||
|
name="Haircut",
|
||||||
|
description="",
|
||||||
|
duration_minutes=60,
|
||||||
|
price_amount=120,
|
||||||
|
currency="SAR",
|
||||||
|
)
|
||||||
|
staff = StaffProfile.objects.create(user=staff_user, salon=salon)
|
||||||
|
|
||||||
|
start_time = timezone.now() + timedelta(days=1)
|
||||||
|
end_time = start_time + timedelta(minutes=60)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"customer": customer,
|
||||||
|
"staff_user": staff_user,
|
||||||
|
"service": service,
|
||||||
|
"staff": staff,
|
||||||
|
"payload": {
|
||||||
|
"service": service.id,
|
||||||
|
"staff": staff.id,
|
||||||
|
"start_time": start_time.isoformat(),
|
||||||
|
"end_time": end_time.isoformat(),
|
||||||
|
"notes": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_booking_create_sends_notifications(booking_payload):
|
||||||
|
client = APIClient()
|
||||||
|
client.force_authenticate(user=booking_payload["customer"])
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
reverse("booking-list"),
|
||||||
|
booking_payload["payload"],
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
notifications = Notification.objects.filter(event=NotificationEvent.BOOKING_CREATED)
|
||||||
|
assert notifications.count() == 2
|
||||||
|
assert all(notification.status == NotificationStatus.SENT for notification in notifications)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_booking_status_change_sends_notifications_once(booking_payload):
|
||||||
|
client = APIClient()
|
||||||
|
client.force_authenticate(user=booking_payload["customer"])
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
reverse("booking-list"),
|
||||||
|
booking_payload["payload"],
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
booking_id = Booking.objects.get(customer=booking_payload["customer"]).id
|
||||||
|
update_payload = {"status": BookingStatus.CONFIRMED}
|
||||||
|
|
||||||
|
client.force_authenticate(user=booking_payload["staff_user"])
|
||||||
|
response_update = client.patch(
|
||||||
|
reverse("booking-detail", args=[booking_id]),
|
||||||
|
update_payload,
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response_update.status_code == 200
|
||||||
|
|
||||||
|
notifications = Notification.objects.filter(event=NotificationEvent.BOOKING_CONFIRMED)
|
||||||
|
assert notifications.count() == 2
|
||||||
|
|
||||||
|
response_repeat = client.patch(
|
||||||
|
reverse("booking-detail", args=[booking_id]),
|
||||||
|
update_payload,
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response_repeat.status_code == 200
|
||||||
|
|
||||||
|
notifications_repeat = Notification.objects.filter(event=NotificationEvent.BOOKING_CONFIRMED)
|
||||||
|
assert notifications_repeat.count() == 2
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-02-28 09:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('payments', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='authorized_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='captured_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='failed_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='idempotency_key',
|
||||||
|
field=models.UUIDField(blank=True, null=True, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='paid_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='provider_payload',
|
||||||
|
field=models.JSONField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='refunded_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='status_updated_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='verified_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='voided_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='external_id',
|
||||||
|
field=models.CharField(blank=True, max_length=200, null=True, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('initiated', 'Initiated'), ('created', 'Created'), ('authorized', 'Authorized'), ('captured', 'Captured'), ('paid', 'Paid'), ('failed', 'Failed'), ('refunded', 'Refunded'), ('voided', 'Voided'), ('verified', 'Verified')], default='initiated', max_length=20),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -15,21 +15,35 @@ class PaymentProvider(models.TextChoices):
|
|||||||
|
|
||||||
|
|
||||||
class PaymentStatus(models.TextChoices):
|
class PaymentStatus(models.TextChoices):
|
||||||
|
INITIATED = "initiated", "Initiated"
|
||||||
CREATED = "created", "Created"
|
CREATED = "created", "Created"
|
||||||
AUTHORIZED = "authorized", "Authorized"
|
AUTHORIZED = "authorized", "Authorized"
|
||||||
CAPTURED = "captured", "Captured"
|
CAPTURED = "captured", "Captured"
|
||||||
|
PAID = "paid", "Paid"
|
||||||
FAILED = "failed", "Failed"
|
FAILED = "failed", "Failed"
|
||||||
REFUNDED = "refunded", "Refunded"
|
REFUNDED = "refunded", "Refunded"
|
||||||
|
VOIDED = "voided", "Voided"
|
||||||
|
VERIFIED = "verified", "Verified"
|
||||||
|
|
||||||
|
|
||||||
class Payment(models.Model):
|
class Payment(models.Model):
|
||||||
booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name="payments")
|
booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name="payments")
|
||||||
provider = models.CharField(max_length=50, choices=PaymentProvider.choices)
|
provider = models.CharField(max_length=50, choices=PaymentProvider.choices)
|
||||||
status = models.CharField(max_length=20, choices=PaymentStatus.choices, default=PaymentStatus.CREATED)
|
status = models.CharField(max_length=20, choices=PaymentStatus.choices, default=PaymentStatus.INITIATED)
|
||||||
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
currency = models.CharField(max_length=10, default=getattr(settings, "DEFAULT_CURRENCY", "SAR"))
|
currency = models.CharField(max_length=10, default=getattr(settings, "DEFAULT_CURRENCY", "SAR"))
|
||||||
external_id = models.CharField(max_length=200, blank=True)
|
external_id = models.CharField(max_length=200, null=True, blank=True, unique=True)
|
||||||
|
idempotency_key = models.UUIDField(null=True, blank=True, unique=True)
|
||||||
|
provider_payload = models.JSONField(null=True, blank=True)
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
authorized_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
captured_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
paid_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
failed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
refunded_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
voided_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
verified_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
status_updated_at = models.DateTimeField(null=True, blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from rest_framework import serializers
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.bookings.models import Booking
|
from apps.bookings.models import Booking
|
||||||
from apps.payments.models import Payment, PaymentProvider, PaymentStatus
|
from apps.payments.models import Payment, PaymentProvider
|
||||||
|
|
||||||
|
|
||||||
class PaymentSerializer(serializers.ModelSerializer):
|
class PaymentSerializer(serializers.ModelSerializer):
|
||||||
@@ -18,7 +18,16 @@ class PaymentSerializer(serializers.ModelSerializer):
|
|||||||
"amount",
|
"amount",
|
||||||
"currency",
|
"currency",
|
||||||
"external_id",
|
"external_id",
|
||||||
|
"idempotency_key",
|
||||||
"metadata",
|
"metadata",
|
||||||
|
"authorized_at",
|
||||||
|
"captured_at",
|
||||||
|
"paid_at",
|
||||||
|
"failed_at",
|
||||||
|
"refunded_at",
|
||||||
|
"voided_at",
|
||||||
|
"verified_at",
|
||||||
|
"status_updated_at",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
@@ -27,23 +36,33 @@ class PaymentSerializer(serializers.ModelSerializer):
|
|||||||
class PaymentCreateSerializer(serializers.ModelSerializer):
|
class PaymentCreateSerializer(serializers.ModelSerializer):
|
||||||
booking_id = serializers.IntegerField(write_only=True)
|
booking_id = serializers.IntegerField(write_only=True)
|
||||||
provider = serializers.ChoiceField(choices=PaymentProvider.choices)
|
provider = serializers.ChoiceField(choices=PaymentProvider.choices)
|
||||||
|
idempotency_key = serializers.UUIDField(write_only=True)
|
||||||
|
source = serializers.JSONField(write_only=True, required=False)
|
||||||
|
callback_url = serializers.URLField(write_only=True, required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Payment
|
model = Payment
|
||||||
fields = ["booking_id", "provider"]
|
fields = ["booking_id", "provider", "idempotency_key", "source", "callback_url"]
|
||||||
|
|
||||||
def validate_booking_id(self, value):
|
def validate_booking_id(self, value):
|
||||||
if not Booking.objects.filter(id=value).exists():
|
if not Booking.objects.filter(id=value).exists():
|
||||||
raise serializers.ValidationError(_("Booking not found"))
|
raise serializers.ValidationError(_("Booking not found"))
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def create(self, validated_data):
|
def validate(self, attrs):
|
||||||
booking = Booking.objects.get(id=validated_data["booking_id"])
|
provider = attrs.get("provider")
|
||||||
return Payment.objects.create(
|
source = attrs.get("source")
|
||||||
booking=booking,
|
if provider != PaymentProvider.MOYASAR:
|
||||||
provider=validated_data["provider"],
|
raise serializers.ValidationError({"provider": _("Provider integration not implemented")})
|
||||||
status=PaymentStatus.CREATED,
|
if source is None:
|
||||||
amount=booking.price_amount,
|
raise serializers.ValidationError({"source": _("Payment source is required")})
|
||||||
currency=booking.currency,
|
source_type = source.get("type")
|
||||||
metadata={},
|
if not source_type:
|
||||||
)
|
raise serializers.ValidationError({"source": _("Payment source type is required")})
|
||||||
|
if source_type == "creditcard":
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"source": _("Card data must not be sent to the backend; use frontend tokenization")}
|
||||||
|
)
|
||||||
|
if source_type == "token" and not attrs.get("callback_url"):
|
||||||
|
raise serializers.ValidationError({"callback_url": _("Callback URL is required for token payments")})
|
||||||
|
return attrs
|
||||||
|
|||||||
@@ -2,21 +2,43 @@ import os
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentGatewayError(RuntimeError):
|
||||||
|
def __init__(self, message: str, status_code: Optional[int] = None, payload: Optional[dict] = None) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
self.payload = payload or {}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PaymentInitResult:
|
class PaymentInitResult:
|
||||||
external_id: str
|
external_id: str
|
||||||
|
status: Optional[str]
|
||||||
redirect_url: Optional[str]
|
redirect_url: Optional[str]
|
||||||
|
payload: dict
|
||||||
|
|
||||||
|
|
||||||
class BasePaymentGateway:
|
class BasePaymentGateway:
|
||||||
def create_payment(self, amount: str, currency: str, description: str) -> PaymentInitResult:
|
def create_payment(
|
||||||
|
self,
|
||||||
|
amount: int,
|
||||||
|
currency: str,
|
||||||
|
description: str,
|
||||||
|
source: dict,
|
||||||
|
callback_url: Optional[str],
|
||||||
|
given_id: str,
|
||||||
|
metadata: dict,
|
||||||
|
) -> PaymentInitResult:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def capture_payment(self, external_id: str) -> None:
|
def capture_payment(self, external_id: str, amount: Optional[int] = None) -> None:
|
||||||
|
"""Capture an authorized payment. Amount in minor units; omit for full capture."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def refund_payment(self, external_id: str, amount: Optional[str] = None) -> None:
|
def refund_payment(self, external_id: str, amount: Optional[int] = None) -> None:
|
||||||
|
"""Refund a paid/captured payment. Amount in minor units; omit for full refund."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
@@ -30,14 +52,87 @@ class MoyasarGateway(BasePaymentGateway):
|
|||||||
if not self.secret_key or not self.publishable_key:
|
if not self.secret_key or not self.publishable_key:
|
||||||
raise ValueError("Moyasar credentials are not configured")
|
raise ValueError("Moyasar credentials are not configured")
|
||||||
|
|
||||||
def create_payment(self, amount: str, currency: str, description: str) -> PaymentInitResult:
|
def create_payment(
|
||||||
|
self,
|
||||||
|
amount: int,
|
||||||
|
currency: str,
|
||||||
|
description: str,
|
||||||
|
source: dict,
|
||||||
|
callback_url: Optional[str],
|
||||||
|
given_id: str,
|
||||||
|
metadata: dict,
|
||||||
|
) -> PaymentInitResult:
|
||||||
self._assert_config()
|
self._assert_config()
|
||||||
raise NotImplementedError("Moyasar gateway integration not implemented yet")
|
url = f"{self.base_url}/v1/payments"
|
||||||
|
payload = {
|
||||||
|
"amount": amount,
|
||||||
|
"currency": currency,
|
||||||
|
"description": description,
|
||||||
|
"source": source,
|
||||||
|
"given_id": given_id,
|
||||||
|
"metadata": metadata,
|
||||||
|
}
|
||||||
|
if callback_url:
|
||||||
|
payload["callback_url"] = callback_url
|
||||||
|
|
||||||
def capture_payment(self, external_id: str) -> None:
|
try:
|
||||||
self._assert_config()
|
response = requests.post(url, json=payload, auth=(self.secret_key, ""), timeout=10)
|
||||||
raise NotImplementedError("Moyasar capture not implemented yet")
|
except requests.RequestException as exc:
|
||||||
|
raise PaymentGatewayError("Failed to reach Moyasar") from exc
|
||||||
|
|
||||||
def refund_payment(self, external_id: str, amount: Optional[str] = None) -> None:
|
try:
|
||||||
|
data = response.json() if response.content else {}
|
||||||
|
except ValueError as exc:
|
||||||
|
raise PaymentGatewayError("Invalid response from Moyasar") from exc
|
||||||
|
|
||||||
|
if response.status_code not in (200, 201):
|
||||||
|
raise PaymentGatewayError(
|
||||||
|
"Moyasar returned an error",
|
||||||
|
status_code=response.status_code,
|
||||||
|
payload=data,
|
||||||
|
)
|
||||||
|
redirect_url = None
|
||||||
|
source_payload = data.get("source") or {}
|
||||||
|
if isinstance(source_payload, dict):
|
||||||
|
redirect_url = source_payload.get("transaction_url")
|
||||||
|
|
||||||
|
return PaymentInitResult(
|
||||||
|
external_id=data.get("id"),
|
||||||
|
status=data.get("status"),
|
||||||
|
redirect_url=redirect_url,
|
||||||
|
payload=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
def capture_payment(self, external_id: str, amount: Optional[int] = None) -> None:
|
||||||
|
"""Capture an authorized payment. Amount in minor units; omit for full capture."""
|
||||||
self._assert_config()
|
self._assert_config()
|
||||||
raise NotImplementedError("Moyasar refund not implemented yet")
|
url = f"{self.base_url}/v1/payments/{external_id}/capture"
|
||||||
|
payload = {} if amount is None else {"amount": amount}
|
||||||
|
try:
|
||||||
|
response = requests.post(url, json=payload, auth=(self.secret_key, ""), timeout=10)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise PaymentGatewayError("Failed to reach Moyasar for capture") from exc
|
||||||
|
if response.status_code not in (200, 201):
|
||||||
|
data = response.json() if response.content else {}
|
||||||
|
raise PaymentGatewayError(
|
||||||
|
"Moyasar capture failed",
|
||||||
|
status_code=response.status_code,
|
||||||
|
payload=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
def refund_payment(self, external_id: str, amount: Optional[int] = None) -> None:
|
||||||
|
"""Refund a paid/captured payment. Amount in minor units; omit for full refund."""
|
||||||
|
self._assert_config()
|
||||||
|
url = f"{self.base_url}/v1/payments/{external_id}/refund"
|
||||||
|
payload = {} if amount is None else {"amount": amount}
|
||||||
|
try:
|
||||||
|
response = requests.post(url, json=payload, auth=(self.secret_key, ""), timeout=10)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise PaymentGatewayError("Failed to reach Moyasar for refund") from exc
|
||||||
|
if response.status_code not in (200, 201):
|
||||||
|
data = response.json() if response.content else {}
|
||||||
|
raise PaymentGatewayError(
|
||||||
|
"Moyasar refund failed",
|
||||||
|
status_code=response.status_code,
|
||||||
|
payload=data,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.bookings.models import Booking
|
||||||
|
from apps.payments.models import Payment, PaymentProvider, PaymentStatus
|
||||||
|
from apps.payments.services.gateway import MoyasarGateway, PaymentGatewayError
|
||||||
|
|
||||||
|
CURRENCY_DECIMALS = {
|
||||||
|
"SAR": 2,
|
||||||
|
"USD": 2,
|
||||||
|
"EUR": 2,
|
||||||
|
"GBP": 2,
|
||||||
|
"KWD": 3,
|
||||||
|
"BHD": 3,
|
||||||
|
"JOD": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
MoyasarAllowedSourceTypes = {"token", "stcpay", "applepay", "samsungpay"}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_minor_units(amount: Decimal, currency: str) -> int:
|
||||||
|
decimals = CURRENCY_DECIMALS.get(currency.upper(), 2)
|
||||||
|
factor = Decimal("1") if decimals == 0 else Decimal(10) ** decimals
|
||||||
|
minor = (amount * factor).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
|
||||||
|
return int(minor)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_provider_status(status: Optional[str]) -> Optional[str]:
|
||||||
|
if not status:
|
||||||
|
return None
|
||||||
|
status = status.lower()
|
||||||
|
mapping = {
|
||||||
|
"initiated": PaymentStatus.INITIATED,
|
||||||
|
"authorized": PaymentStatus.AUTHORIZED,
|
||||||
|
"captured": PaymentStatus.CAPTURED,
|
||||||
|
"paid": PaymentStatus.PAID,
|
||||||
|
"failed": PaymentStatus.FAILED,
|
||||||
|
"refunded": PaymentStatus.REFUNDED,
|
||||||
|
"voided": PaymentStatus.VOIDED,
|
||||||
|
"verified": PaymentStatus.VERIFIED,
|
||||||
|
}
|
||||||
|
return mapping.get(status)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_status(payment: Payment, status: str) -> None:
|
||||||
|
now = timezone.now()
|
||||||
|
payment.status = status
|
||||||
|
payment.status_updated_at = now
|
||||||
|
if status == PaymentStatus.AUTHORIZED:
|
||||||
|
payment.authorized_at = now
|
||||||
|
elif status == PaymentStatus.CAPTURED:
|
||||||
|
payment.captured_at = now
|
||||||
|
elif status == PaymentStatus.PAID:
|
||||||
|
payment.paid_at = now
|
||||||
|
elif status == PaymentStatus.FAILED:
|
||||||
|
payment.failed_at = now
|
||||||
|
elif status == PaymentStatus.REFUNDED:
|
||||||
|
payment.refunded_at = now
|
||||||
|
elif status == PaymentStatus.VOIDED:
|
||||||
|
payment.voided_at = now
|
||||||
|
elif status == PaymentStatus.VERIFIED:
|
||||||
|
payment.verified_at = now
|
||||||
|
|
||||||
|
|
||||||
|
def create_payment_for_booking(
|
||||||
|
booking: Booking,
|
||||||
|
provider: str,
|
||||||
|
idempotency_key,
|
||||||
|
source: dict,
|
||||||
|
callback_url: Optional[str] = None,
|
||||||
|
) -> Tuple[Payment, bool, Optional[str]]:
|
||||||
|
if provider != PaymentProvider.MOYASAR:
|
||||||
|
raise serializers.ValidationError({"provider": _("Provider integration not implemented")})
|
||||||
|
|
||||||
|
existing = Payment.objects.filter(idempotency_key=idempotency_key).first()
|
||||||
|
if existing:
|
||||||
|
if existing.booking_id != booking.id or existing.provider != provider:
|
||||||
|
raise serializers.ValidationError({"idempotency_key": _("Idempotency key already used")})
|
||||||
|
return existing, False, existing.metadata.get("redirect_url")
|
||||||
|
|
||||||
|
source_type = (source or {}).get("type")
|
||||||
|
if source_type not in MoyasarAllowedSourceTypes:
|
||||||
|
raise serializers.ValidationError({"source": _("Unsupported payment source type")})
|
||||||
|
|
||||||
|
payment = Payment.objects.create(
|
||||||
|
booking=booking,
|
||||||
|
provider=provider,
|
||||||
|
status=PaymentStatus.INITIATED,
|
||||||
|
amount=booking.price_amount,
|
||||||
|
currency=booking.currency,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
metadata={"booking_id": booking.id},
|
||||||
|
)
|
||||||
|
_apply_status(payment, PaymentStatus.INITIATED)
|
||||||
|
payment.save(update_fields=["status", "status_updated_at"])
|
||||||
|
|
||||||
|
amount_minor = _to_minor_units(booking.price_amount, booking.currency)
|
||||||
|
description = f"Booking {booking.id}"
|
||||||
|
gateway = MoyasarGateway()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = gateway.create_payment(
|
||||||
|
amount=amount_minor,
|
||||||
|
currency=booking.currency,
|
||||||
|
description=description,
|
||||||
|
source=source,
|
||||||
|
callback_url=callback_url,
|
||||||
|
given_id=str(idempotency_key),
|
||||||
|
metadata={"booking_id": booking.id},
|
||||||
|
)
|
||||||
|
except PaymentGatewayError as exc:
|
||||||
|
_apply_status(payment, PaymentStatus.FAILED)
|
||||||
|
payment.metadata["gateway_error"] = {
|
||||||
|
"message": str(exc),
|
||||||
|
"status_code": exc.status_code,
|
||||||
|
"payload": exc.payload,
|
||||||
|
}
|
||||||
|
payment.save(update_fields=[
|
||||||
|
"status",
|
||||||
|
"status_updated_at",
|
||||||
|
"failed_at",
|
||||||
|
"metadata",
|
||||||
|
])
|
||||||
|
raise serializers.ValidationError({"detail": _("Payment provider error")}) from exc
|
||||||
|
|
||||||
|
if not result.external_id:
|
||||||
|
_apply_status(payment, PaymentStatus.FAILED)
|
||||||
|
payment.metadata["gateway_error"] = {"message": "Missing payment reference from provider"}
|
||||||
|
payment.save(update_fields=[
|
||||||
|
"status",
|
||||||
|
"status_updated_at",
|
||||||
|
"failed_at",
|
||||||
|
"metadata",
|
||||||
|
])
|
||||||
|
raise serializers.ValidationError({"detail": _("Payment provider error")})
|
||||||
|
|
||||||
|
payment.external_id = result.external_id
|
||||||
|
payment.provider_payload = result.payload
|
||||||
|
payment.metadata["redirect_url"] = result.redirect_url
|
||||||
|
|
||||||
|
mapped_status = _map_provider_status(result.status)
|
||||||
|
if mapped_status:
|
||||||
|
_apply_status(payment, mapped_status)
|
||||||
|
|
||||||
|
payment.save()
|
||||||
|
return payment, True, result.redirect_url
|
||||||
|
|
||||||
|
|
||||||
|
def apply_webhook_event(payment: Payment, event_type: str, payload: dict) -> bool:
|
||||||
|
mapping = {
|
||||||
|
"payment_authorized": PaymentStatus.AUTHORIZED,
|
||||||
|
"payment_captured": PaymentStatus.CAPTURED,
|
||||||
|
"payment_paid": PaymentStatus.PAID,
|
||||||
|
"payment_failed": PaymentStatus.FAILED,
|
||||||
|
"payment_faild": PaymentStatus.FAILED,
|
||||||
|
"payment_abandoned": PaymentStatus.FAILED,
|
||||||
|
"payment_refunded": PaymentStatus.REFUNDED,
|
||||||
|
"payment_voided": PaymentStatus.VOIDED,
|
||||||
|
"payment_verified": PaymentStatus.VERIFIED,
|
||||||
|
}
|
||||||
|
target_status = mapping.get(event_type)
|
||||||
|
if not target_status:
|
||||||
|
return False
|
||||||
|
if payment.status == target_status:
|
||||||
|
return False
|
||||||
|
_apply_status(payment, target_status)
|
||||||
|
payment.metadata["last_webhook"] = payload
|
||||||
|
payment.save()
|
||||||
|
return True
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Tests for Moyasar capture and refund gateway methods."""
|
||||||
|
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from apps.payments.services.gateway import MoyasarGateway, PaymentGatewayError
|
||||||
|
|
||||||
|
|
||||||
|
@patch("apps.payments.services.gateway.requests.post")
|
||||||
|
def test_moyasar_capture_calls_api(mock_post):
|
||||||
|
mock_post.return_value = Mock(status_code=200, content=b"{}", json=lambda: {"id": "pay_1", "status": "captured"})
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {
|
||||||
|
"MOYASAR_SECRET_KEY": "sk_test",
|
||||||
|
"MOYASAR_PUBLISHABLE_KEY": "pk_test",
|
||||||
|
}):
|
||||||
|
gateway = MoyasarGateway()
|
||||||
|
gateway.capture_payment("pay_1")
|
||||||
|
|
||||||
|
mock_post.assert_called_once()
|
||||||
|
call_args = mock_post.call_args
|
||||||
|
assert "pay_1/capture" in call_args[0][0]
|
||||||
|
assert call_args[1]["auth"] == ("sk_test", "")
|
||||||
|
|
||||||
|
|
||||||
|
@patch("apps.payments.services.gateway.requests.post")
|
||||||
|
def test_moyasar_refund_calls_api(mock_post):
|
||||||
|
mock_post.return_value = Mock(status_code=200, content=b"{}", json=lambda: {"id": "pay_1", "status": "refunded"})
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {
|
||||||
|
"MOYASAR_SECRET_KEY": "sk_test",
|
||||||
|
"MOYASAR_PUBLISHABLE_KEY": "pk_test",
|
||||||
|
}):
|
||||||
|
gateway = MoyasarGateway()
|
||||||
|
gateway.refund_payment("pay_1")
|
||||||
|
|
||||||
|
mock_post.assert_called_once()
|
||||||
|
call_args = mock_post.call_args
|
||||||
|
assert "pay_1/refund" in call_args[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
@patch("apps.payments.services.gateway.requests.post")
|
||||||
|
def test_moyasar_capture_raises_on_error(mock_post):
|
||||||
|
mock_post.return_value = Mock(status_code=400, content=b'{"message":"Invalid"}', json=lambda: {"message": "Invalid"})
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {
|
||||||
|
"MOYASAR_SECRET_KEY": "sk_test",
|
||||||
|
"MOYASAR_PUBLISHABLE_KEY": "pk_test",
|
||||||
|
}):
|
||||||
|
gateway = MoyasarGateway()
|
||||||
|
with pytest.raises(PaymentGatewayError) as exc_info:
|
||||||
|
gateway.capture_payment("pay_1")
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from apps.accounts.models import User, UserRole
|
||||||
|
from apps.bookings.models import Booking, BookingStatus
|
||||||
|
from apps.payments.models import Payment, PaymentProvider, PaymentStatus
|
||||||
|
from apps.salons.models import Salon, Service, StaffProfile
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def booking_entities():
|
||||||
|
owner = User.objects.create_user(
|
||||||
|
email="owner@example.com",
|
||||||
|
password="pass",
|
||||||
|
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(
|
||||||
|
owner=owner,
|
||||||
|
name="Main Salon",
|
||||||
|
description="",
|
||||||
|
address="123 King Rd",
|
||||||
|
city="Riyadh",
|
||||||
|
phone_number="0512345678",
|
||||||
|
)
|
||||||
|
service = Service.objects.create(
|
||||||
|
salon=salon,
|
||||||
|
name="Haircut",
|
||||||
|
description="",
|
||||||
|
duration_minutes=60,
|
||||||
|
price_amount=120,
|
||||||
|
currency="SAR",
|
||||||
|
)
|
||||||
|
staff = StaffProfile.objects.create(user=staff_user, salon=salon)
|
||||||
|
|
||||||
|
start_time = timezone.now() + timedelta(days=1)
|
||||||
|
end_time = start_time + timedelta(minutes=60)
|
||||||
|
booking = Booking.objects.create(
|
||||||
|
salon=salon,
|
||||||
|
customer=customer,
|
||||||
|
service=service,
|
||||||
|
staff=staff,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
status=BookingStatus.PENDING,
|
||||||
|
price_amount=service.price_amount,
|
||||||
|
currency=service.currency,
|
||||||
|
notes="",
|
||||||
|
)
|
||||||
|
return customer, booking
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_gateway_response(payment_id="pay_test", status="initiated"):
|
||||||
|
response = Mock()
|
||||||
|
response.status_code = 201
|
||||||
|
response.json.return_value = {
|
||||||
|
"id": payment_id,
|
||||||
|
"status": status,
|
||||||
|
"source": {"transaction_url": "https://moyasar.example/tx"},
|
||||||
|
}
|
||||||
|
response.content = b"{}"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@patch("apps.payments.services.gateway.requests.post")
|
||||||
|
def test_create_payment_idempotency_returns_existing(mock_post, booking_entities, monkeypatch):
|
||||||
|
customer, booking = booking_entities
|
||||||
|
client = APIClient()
|
||||||
|
client.force_authenticate(user=customer)
|
||||||
|
|
||||||
|
monkeypatch.setenv("MOYASAR_SECRET_KEY", "sk_test")
|
||||||
|
monkeypatch.setenv("MOYASAR_PUBLISHABLE_KEY", "pk_test")
|
||||||
|
|
||||||
|
mock_post.return_value = _mock_gateway_response()
|
||||||
|
request_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"booking_id": booking.id,
|
||||||
|
"provider": PaymentProvider.MOYASAR,
|
||||||
|
"idempotency_key": request_id,
|
||||||
|
"source": {"type": "stcpay", "mobile": "0500000000"},
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(reverse("payment-list"), payload, content_type="application/json")
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
response_repeat = client.post(reverse("payment-list"), payload, content_type="application/json")
|
||||||
|
assert response_repeat.status_code == 200
|
||||||
|
assert Payment.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_rejects_creditcard_source(booking_entities):
|
||||||
|
customer, booking = booking_entities
|
||||||
|
client = APIClient()
|
||||||
|
client.force_authenticate(user=customer)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"booking_id": booking.id,
|
||||||
|
"provider": PaymentProvider.MOYASAR,
|
||||||
|
"idempotency_key": str(uuid.uuid4()),
|
||||||
|
"source": {"type": "creditcard", "number": "4111111111111111"},
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(reverse("payment-list"), payload, content_type="application/json")
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "source" in response.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_webhook_paid_updates_status(booking_entities, monkeypatch):
|
||||||
|
_, booking = booking_entities
|
||||||
|
monkeypatch.setenv("MOYASAR_WEBHOOK_SECRET", "secret")
|
||||||
|
|
||||||
|
payment = Payment.objects.create(
|
||||||
|
booking=booking,
|
||||||
|
provider=PaymentProvider.MOYASAR,
|
||||||
|
status=PaymentStatus.INITIATED,
|
||||||
|
amount=booking.price_amount,
|
||||||
|
currency=booking.currency,
|
||||||
|
external_id="pay_webhook",
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"type": "payment_paid",
|
||||||
|
"secret_token": "secret",
|
||||||
|
"data": {"id": "pay_webhook"},
|
||||||
|
}
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post(reverse("payment-webhook"), payload, content_type="application/json")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
payment.refresh_from_db()
|
||||||
|
assert payment.status == PaymentStatus.PAID
|
||||||
|
assert payment.paid_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_webhook_invalid_secret_is_rejected(booking_entities, monkeypatch):
|
||||||
|
_, booking = booking_entities
|
||||||
|
monkeypatch.setenv("MOYASAR_WEBHOOK_SECRET", "secret")
|
||||||
|
|
||||||
|
Payment.objects.create(
|
||||||
|
booking=booking,
|
||||||
|
provider=PaymentProvider.MOYASAR,
|
||||||
|
status=PaymentStatus.INITIATED,
|
||||||
|
amount=booking.price_amount,
|
||||||
|
currency=booking.currency,
|
||||||
|
external_id="pay_webhook",
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"type": "payment_paid",
|
||||||
|
"secret_token": "wrong",
|
||||||
|
"data": {"id": "pay_webhook"},
|
||||||
|
}
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post(reverse("payment-webhook"), payload, content_type="application/json")
|
||||||
|
assert response.status_code == 401
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from apps.payments.views import PaymentViewSet
|
from apps.payments.views import PaymentViewSet, payment_webhook
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r"", PaymentViewSet, basename="payment")
|
router.register(r"", PaymentViewSet, basename="payment")
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path("webhook/", payment_webhook, name="payment-webhook"),
|
||||||
path("", include(router.urls)),
|
path("", include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
from rest_framework import permissions, status, viewsets
|
import logging
|
||||||
from rest_framework.response import Response
|
import os
|
||||||
|
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
from rest_framework import permissions, status, viewsets
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from apps.bookings.models import Booking
|
from apps.bookings.models import Booking
|
||||||
from apps.payments.models import Payment
|
from apps.payments.models import Payment, PaymentProvider
|
||||||
from apps.payments.serializers import PaymentCreateSerializer, PaymentSerializer
|
from apps.payments.serializers import PaymentCreateSerializer, PaymentSerializer
|
||||||
|
from apps.payments.services.payments import apply_webhook_event, create_payment_for_booking
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def user_can_access_booking(user, booking: Booking) -> bool:
|
def user_can_access_booking(user, booking: Booking) -> bool:
|
||||||
@@ -41,14 +48,42 @@ class PaymentViewSet(viewsets.ModelViewSet):
|
|||||||
booking = Booking.objects.get(id=serializer.validated_data["booking_id"])
|
booking = Booking.objects.get(id=serializer.validated_data["booking_id"])
|
||||||
if not user_can_access_booking(request.user, booking):
|
if not user_can_access_booking(request.user, booking):
|
||||||
return Response({"detail": _("Not allowed")}, status=status.HTTP_403_FORBIDDEN)
|
return Response({"detail": _("Not allowed")}, status=status.HTTP_403_FORBIDDEN)
|
||||||
payment = serializer.save()
|
payment, created, redirect_url = create_payment_for_booking(
|
||||||
return Response(
|
booking=booking,
|
||||||
{
|
provider=serializer.validated_data["provider"],
|
||||||
"detail": _("Payment record created. Provider integration pending."),
|
idempotency_key=serializer.validated_data["idempotency_key"],
|
||||||
"payment_id": payment.id,
|
source=serializer.validated_data["source"],
|
||||||
"amount": str(payment.amount),
|
callback_url=serializer.validated_data.get("callback_url"),
|
||||||
"currency": payment.currency,
|
|
||||||
"status": payment.status,
|
|
||||||
},
|
|
||||||
status=status.HTTP_201_CREATED,
|
|
||||||
)
|
)
|
||||||
|
response_data = PaymentSerializer(payment).data
|
||||||
|
response_data["redirect_url"] = redirect_url
|
||||||
|
response_data["created"] = created
|
||||||
|
return Response(response_data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["POST"])
|
||||||
|
@permission_classes([permissions.AllowAny])
|
||||||
|
def payment_webhook(request):
|
||||||
|
secret = os.getenv("MOYASAR_WEBHOOK_SECRET")
|
||||||
|
payload = request.data or {}
|
||||||
|
if not secret:
|
||||||
|
return Response({"detail": _("Webhook secret not configured")}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
if payload.get("secret_token") != secret:
|
||||||
|
return Response({"detail": _("Invalid webhook signature")}, status=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
event_type = payload.get("type")
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
external_id = data.get("id")
|
||||||
|
if not external_id:
|
||||||
|
return Response({"detail": _("Missing payment reference")}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
payment = Payment.objects.filter(external_id=external_id, provider=PaymentProvider.MOYASAR).first()
|
||||||
|
if not payment:
|
||||||
|
logger.warning("Moyasar webhook for unknown payment %s", external_id)
|
||||||
|
return Response({"detail": _("Payment not found")}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
applied = apply_webhook_event(payment, event_type, payload)
|
||||||
|
if not applied:
|
||||||
|
return Response({"detail": _("Event ignored")}, status=status.HTTP_200_OK)
|
||||||
|
return Response({"detail": _("Webhook processed")}, status=status.HTTP_200_OK)
|
||||||
|
|||||||
@@ -126,10 +126,10 @@ class Command(BaseCommand):
|
|||||||
booking=booking,
|
booking=booking,
|
||||||
provider=PaymentProvider.MOYASAR,
|
provider=PaymentProvider.MOYASAR,
|
||||||
defaults={
|
defaults={
|
||||||
"status": PaymentStatus.CREATED,
|
"status": PaymentStatus.INITIATED,
|
||||||
"amount": booking.price_amount,
|
"amount": booking.price_amount,
|
||||||
"currency": booking.currency,
|
"currency": booking.currency,
|
||||||
"external_id": "",
|
"external_id": None,
|
||||||
"metadata": {"note": "Demo payment record"},
|
"metadata": {"note": "Demo payment record"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class StaffProfile(models.Model):
|
|||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user.email} - {self.salon.name}"
|
return f"{self.user.display_name} - {self.salon.name}"
|
||||||
|
|
||||||
|
|
||||||
class StaffAvailability(models.Model):
|
class StaffAvailability(models.Model):
|
||||||
@@ -84,7 +84,10 @@ class StaffAvailability(models.Model):
|
|||||||
ordering = ["staff_id", "day_of_week", "start_time"]
|
ordering = ["staff_id", "day_of_week", "start_time"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.staff.user.email} {self.get_day_of_week_display()} {self.start_time}-{self.end_time}"
|
return (
|
||||||
|
f"{self.staff.user.display_name} {self.get_day_of_week_display()} "
|
||||||
|
f"{self.start_time}-{self.end_time}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Review(models.Model):
|
class Review(models.Model):
|
||||||
@@ -95,4 +98,4 @@ class Review(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Review {self.rating} for {self.salon.name}"
|
return f"Review {self.rating} by {self.customer.display_name} for {self.salon.name}"
|
||||||
|
|||||||
@@ -26,9 +26,7 @@ class StaffSerializer(serializers.ModelSerializer):
|
|||||||
fields = ["id", "name", "title", "bio", "is_active"]
|
fields = ["id", "name", "title", "bio", "is_active"]
|
||||||
|
|
||||||
def get_name(self, obj):
|
def get_name(self, obj):
|
||||||
first = obj.user.first_name or ""
|
return obj.user.display_name
|
||||||
last = obj.user.last_name or ""
|
|
||||||
return (first + " " + last).strip() or obj.user.email
|
|
||||||
|
|
||||||
|
|
||||||
class ReviewSerializer(serializers.ModelSerializer):
|
class ReviewSerializer(serializers.ModelSerializer):
|
||||||
@@ -39,9 +37,7 @@ class ReviewSerializer(serializers.ModelSerializer):
|
|||||||
fields = ["id", "rating", "comment", "created_at", "customer_name"]
|
fields = ["id", "rating", "comment", "created_at", "customer_name"]
|
||||||
|
|
||||||
def get_customer_name(self, obj):
|
def get_customer_name(self, obj):
|
||||||
first = obj.customer.first_name or ""
|
return obj.customer.display_name
|
||||||
last = obj.customer.last_name or ""
|
|
||||||
return (first + " " + last).strip() or obj.customer.email
|
|
||||||
|
|
||||||
|
|
||||||
class SalonSerializer(serializers.ModelSerializer):
|
class SalonSerializer(serializers.ModelSerializer):
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
from datetime import timedelta, time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.accounts.models import User, UserRole
|
||||||
|
from apps.bookings.models import Booking
|
||||||
|
from apps.bookings.serializers import BookingSerializer
|
||||||
|
from apps.salons.models import (
|
||||||
|
Salon,
|
||||||
|
Service,
|
||||||
|
StaffAvailability,
|
||||||
|
StaffProfile,
|
||||||
|
Review,
|
||||||
|
)
|
||||||
|
from apps.salons.serializers import ReviewSerializer, StaffSerializer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestDisplayNameFallbacks:
|
||||||
|
def _create_customer(self, phone_number):
|
||||||
|
return User.objects.create_user(phone_number=phone_number)
|
||||||
|
|
||||||
|
def _create_staff_user(self, phone_number):
|
||||||
|
return User.objects.create_user(phone_number=phone_number, role=UserRole.STAFF)
|
||||||
|
|
||||||
|
def _create_salon(self, owner):
|
||||||
|
return Salon.objects.create(
|
||||||
|
owner=owner,
|
||||||
|
name="Test Salon",
|
||||||
|
address="123 Main",
|
||||||
|
city="Riyadh",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_service(self, salon):
|
||||||
|
return Service.objects.create(
|
||||||
|
salon=salon,
|
||||||
|
name="Haircut",
|
||||||
|
description="",
|
||||||
|
duration_minutes=60,
|
||||||
|
price_amount=200,
|
||||||
|
currency="SAR",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_staff_serializer_falls_back_to_phone(self):
|
||||||
|
owner = User.objects.create_user(phone_number="+966500000001", email="owner@example.com")
|
||||||
|
salon = self._create_salon(owner)
|
||||||
|
staff_user = self._create_staff_user(phone_number="+966500000002")
|
||||||
|
staff_profile = StaffProfile.objects.create(user=staff_user, salon=salon)
|
||||||
|
|
||||||
|
serializer = StaffSerializer(staff_profile)
|
||||||
|
|
||||||
|
assert serializer.data["name"] == "+966500000002"
|
||||||
|
|
||||||
|
def test_review_serializer_customer_name_uses_phone(self):
|
||||||
|
owner = User.objects.create_user(phone_number="+966500000003", email="owner2@example.com")
|
||||||
|
salon = self._create_salon(owner)
|
||||||
|
customer = self._create_customer(phone_number="+966500000004")
|
||||||
|
review = Review.objects.create(salon=salon, customer=customer, rating=5, comment="Great")
|
||||||
|
|
||||||
|
serializer = ReviewSerializer(review)
|
||||||
|
|
||||||
|
assert serializer.data["customer_name"] == "+966500000004"
|
||||||
|
assert "+966500000004" in str(review)
|
||||||
|
|
||||||
|
def test_booking_serializer_staff_name_and_str_use_phone(self):
|
||||||
|
owner = User.objects.create_user(phone_number="+966500000005", email="owner3@example.com")
|
||||||
|
salon = self._create_salon(owner)
|
||||||
|
staff_user = self._create_staff_user(phone_number="+966500000006")
|
||||||
|
staff_profile = StaffProfile.objects.create(user=staff_user, salon=salon)
|
||||||
|
service = self._create_service(salon)
|
||||||
|
customer = self._create_customer(phone_number="+966500000007")
|
||||||
|
start = timezone.now()
|
||||||
|
booking = Booking.objects.create(
|
||||||
|
salon=salon,
|
||||||
|
customer=customer,
|
||||||
|
service=service,
|
||||||
|
staff=staff_profile,
|
||||||
|
start_time=start,
|
||||||
|
end_time=start + timedelta(hours=1),
|
||||||
|
price_amount=service.price_amount,
|
||||||
|
currency=service.currency,
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = BookingSerializer(booking)
|
||||||
|
|
||||||
|
assert serializer.data["staff_name"] == "+966500000006"
|
||||||
|
assert "+966500000007" in str(booking)
|
||||||
|
|
||||||
|
def test_staff_model_str_uses_phone(self):
|
||||||
|
owner = User.objects.create_user(phone_number="+966500000008", email="owner4@example.com")
|
||||||
|
salon = self._create_salon(owner)
|
||||||
|
staff_user = self._create_staff_user(phone_number="+966500000009")
|
||||||
|
staff_profile = StaffProfile.objects.create(user=staff_user, salon=salon)
|
||||||
|
availability = StaffAvailability.objects.create(
|
||||||
|
staff=staff_profile,
|
||||||
|
day_of_week=0,
|
||||||
|
start_time=time(9, 0),
|
||||||
|
end_time=time(10, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "+966500000009" in str(staff_profile)
|
||||||
|
assert "+966500000009" in str(availability)
|
||||||
Binary file not shown.
@@ -0,0 +1,251 @@
|
|||||||
|
# Arabic (Saudi Arabia) translations for Salon booking platform.
|
||||||
|
# Copyright (C) 2026
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: 1.0\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2026-03-02 00:45+0300\n"
|
||||||
|
"PO-Revision-Date: 2026-03-02 00:45+0300\n"
|
||||||
|
"Last-Translator: Claude\n"
|
||||||
|
"Language-Team: Arabic (Saudi Arabia)\n"
|
||||||
|
"Language: ar_SA\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:26
|
||||||
|
msgid "Too many OTP requests. Try again later."
|
||||||
|
msgstr "طلبات رمز التحقق كثيرة جداً. حاول مرة أخرى لاحقاً."
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:32
|
||||||
|
msgid "Please wait before requesting another code."
|
||||||
|
msgstr "يرجى الانتظار قبل طلب رمز آخر."
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:71
|
||||||
|
msgid "Twilio credentials are not configured"
|
||||||
|
msgstr "لم يتم تكوين بيانات اعتماد Twilio"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:85
|
||||||
|
msgid "Twilio WhatsApp sender is not configured"
|
||||||
|
msgstr "لم يتم تكوين مُرسِل WhatsApp لـ Twilio"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:100
|
||||||
|
msgid "Unifonic credentials are not configured"
|
||||||
|
msgstr "لم يتم تكوين بيانات اعتماد Unifonic"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:104
|
||||||
|
msgid "Unifonic SMS adapter not implemented yet"
|
||||||
|
msgstr "محول SMS لـ Unifonic لم يتم تنفيذه بعد"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:109
|
||||||
|
msgid "Unifonic WhatsApp sender is not configured"
|
||||||
|
msgstr "لم يتم تكوين مُرسِل WhatsApp لـ Unifonic"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:110
|
||||||
|
msgid "Unifonic WhatsApp adapter not implemented yet"
|
||||||
|
msgstr "محول WhatsApp لـ Unifonic لم يتم تنفيذه بعد"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:126
|
||||||
|
msgid "Authentica API key is not configured"
|
||||||
|
msgstr "لم يتم تكوين مفتاح API لـ Authentica"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:149 apps/accounts/services/otp.py:162
|
||||||
|
msgid "Authentica request failed"
|
||||||
|
msgstr "فشل طلب Authentica"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:159
|
||||||
|
#, python-format
|
||||||
|
msgid "Authentica request failed: %(status)s %(body)s"
|
||||||
|
msgstr "فشل طلب Authentica: %(status)s %(body)s"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:168 apps/accounts/services/otp.py:276
|
||||||
|
msgid "Unsupported OTP channel"
|
||||||
|
msgstr "قناة رمز التحقق غير مدعومة"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:179
|
||||||
|
#, python-format
|
||||||
|
msgid "Authentica verify failed: %(response)s"
|
||||||
|
msgstr "فشل التحقق بـ Authentica: %(response)s"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:184
|
||||||
|
msgid "Authentica sender name is not configured"
|
||||||
|
msgstr "لم يتم تكوين اسم مُرسِل Authentica"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:195
|
||||||
|
msgid "Authentica WhatsApp messaging is not supported"
|
||||||
|
msgstr "مراسلة WhatsApp غير مدعومة لـ Authentica"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:209
|
||||||
|
#, python-format
|
||||||
|
msgid "Unknown OTP provider: %(provider)s"
|
||||||
|
msgstr "مزود رمز التحقق غير معروف: %(provider)s"
|
||||||
|
|
||||||
|
#: apps/accounts/services/otp.py:256
|
||||||
|
#, python-format
|
||||||
|
msgid "Your verification code is %(code)s. It expires in %(minutes)s minutes."
|
||||||
|
msgstr "رمز التحقق الخاص بك هو %(code)s. ينتهي في %(minutes)s دقيقة."
|
||||||
|
|
||||||
|
#: apps/accounts/services/phone.py:8
|
||||||
|
msgid "Phone number is required"
|
||||||
|
msgstr "رقم الهاتف مطلوب"
|
||||||
|
|
||||||
|
#: apps/accounts/services/phone.py:17
|
||||||
|
msgid "Invalid phone number format"
|
||||||
|
msgstr "تنسيق رقم الهاتف غير صالح"
|
||||||
|
|
||||||
|
#: apps/accounts/services/phone.py:28
|
||||||
|
msgid "Phone number must be in E.164 format or a valid Saudi mobile"
|
||||||
|
msgstr "يجب أن يكون رقم الهاتف بصيغة E.164 أو رقم جوال سعودي صالح"
|
||||||
|
|
||||||
|
#: apps/accounts/views.py:75 apps/accounts/views.py:138
|
||||||
|
msgid "Invalid or expired code"
|
||||||
|
msgstr "الرمز غير صالح أو منتهي الصلاحية"
|
||||||
|
|
||||||
|
#: apps/accounts/views.py:82
|
||||||
|
msgid "Phone verified"
|
||||||
|
msgstr "تم التحقق من رقم الهاتف"
|
||||||
|
|
||||||
|
#: apps/accounts/views.py:99
|
||||||
|
msgid "Email already in use."
|
||||||
|
msgstr "البريد الإلكتروني مستخدم بالفعل."
|
||||||
|
|
||||||
|
#: apps/accounts/views.py:142
|
||||||
|
msgid "User not found"
|
||||||
|
msgstr "المستخدم غير موجود"
|
||||||
|
|
||||||
|
#: apps/accounts/views.py:164
|
||||||
|
msgid "Social login not configured yet. Add OAuth provider config."
|
||||||
|
msgstr "لم يتم تكوين تسجيل الدخول الاجتماعي بعد. أضف إعداد مزود OAuth."
|
||||||
|
|
||||||
|
#: apps/bookings/serializers.py:54
|
||||||
|
msgid "Only staff or managers can confirm bookings."
|
||||||
|
msgstr "يمكن للموظفين والمدراء فقط تأكيد الحجوزات."
|
||||||
|
|
||||||
|
#: apps/bookings/serializers.py:56
|
||||||
|
msgid "Only staff or managers can complete bookings."
|
||||||
|
msgstr "يمكن للموظفين والمدراء فقط إكمال الحجوزات."
|
||||||
|
|
||||||
|
#: apps/bookings/serializers.py:58
|
||||||
|
msgid "You are not allowed to cancel this booking."
|
||||||
|
msgstr "لا يُسمح لك بإلغاء هذا الحجز."
|
||||||
|
|
||||||
|
#: apps/bookings/serializers.py:101 apps/bookings/services.py:49
|
||||||
|
msgid "Booking overlaps an existing appointment"
|
||||||
|
msgstr "يتداخل الحجز مع موعد قائم"
|
||||||
|
|
||||||
|
#: apps/bookings/services.py:13
|
||||||
|
msgid "Staff is required for booking"
|
||||||
|
msgstr "يجب تحديد موظف للحجز"
|
||||||
|
|
||||||
|
#: apps/bookings/services.py:16
|
||||||
|
msgid "Selected staff does not belong to this salon"
|
||||||
|
msgstr "الموظف المختار لا ينتمي إلى هذا الصالون"
|
||||||
|
|
||||||
|
#: apps/bookings/services.py:19
|
||||||
|
msgid "End time must be after start time"
|
||||||
|
msgstr "يجب أن يكون وقت الانتهاء بعد وقت البدء"
|
||||||
|
|
||||||
|
#: apps/bookings/services.py:23
|
||||||
|
msgid "End time must match service duration"
|
||||||
|
msgstr "يجب أن يتطابق وقت الانتهاء مع مدة الخدمة"
|
||||||
|
|
||||||
|
#: apps/bookings/services.py:40
|
||||||
|
msgid "Booking is outside staff availability"
|
||||||
|
msgstr "الحجز خارج أوقات توفر الموظف"
|
||||||
|
|
||||||
|
#: apps/notifications/services.py:31
|
||||||
|
#, python-format
|
||||||
|
msgid "Unknown notification provider: %(provider)s"
|
||||||
|
msgstr "مزود الإشعارات غير معروف: %(provider)s"
|
||||||
|
|
||||||
|
#: apps/notifications/services.py:47
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"Your booking request is received for %(service)s at %(salon)s on %(start)s."
|
||||||
|
msgstr "تم استلام طلب حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
|
||||||
|
|
||||||
|
#: apps/notifications/services.py:55
|
||||||
|
#, python-format
|
||||||
|
msgid "Your booking is confirmed for %(service)s at %(salon)s on %(start)s."
|
||||||
|
msgstr "تم تأكيد حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
|
||||||
|
|
||||||
|
#: apps/notifications/services.py:63
|
||||||
|
#, python-format
|
||||||
|
msgid "Your booking was cancelled for %(service)s at %(salon)s on %(start)s."
|
||||||
|
msgstr "تم إلغاء حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
|
||||||
|
|
||||||
|
#: apps/notifications/services.py:70
|
||||||
|
#, python-format
|
||||||
|
msgid "Booking update for %(service)s at %(salon)s on %(start)s."
|
||||||
|
msgstr "تحديث الحجز لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
|
||||||
|
|
||||||
|
#: apps/notifications/services.py:85
|
||||||
|
msgid "Unsupported notification channel"
|
||||||
|
msgstr "قناة الإشعارات غير مدعومة"
|
||||||
|
|
||||||
|
#: apps/payments/serializers.py:49
|
||||||
|
msgid "Booking not found"
|
||||||
|
msgstr "الحجز غير موجود"
|
||||||
|
|
||||||
|
#: apps/payments/serializers.py:56 apps/payments/services/payments.py:79
|
||||||
|
msgid "Provider integration not implemented"
|
||||||
|
msgstr "تكامل المزود غير مُنفَّذ"
|
||||||
|
|
||||||
|
#: apps/payments/serializers.py:58
|
||||||
|
msgid "Payment source is required"
|
||||||
|
msgstr "مصدر الدفع مطلوب"
|
||||||
|
|
||||||
|
#: apps/payments/serializers.py:61
|
||||||
|
msgid "Payment source type is required"
|
||||||
|
msgstr "نوع مصدر الدفع مطلوب"
|
||||||
|
|
||||||
|
#: apps/payments/serializers.py:64
|
||||||
|
msgid "Card data must not be sent to the backend; use frontend tokenization"
|
||||||
|
msgstr "لا يجب إرسال بيانات البطاقة إلى الخادم؛ استخدم التحويل إلى رمز في الواجهة الأمامية"
|
||||||
|
|
||||||
|
#: apps/payments/serializers.py:67
|
||||||
|
msgid "Callback URL is required for token payments"
|
||||||
|
msgstr "رابط الاستجابة مطلوب لمدفوعات الرمز"
|
||||||
|
|
||||||
|
#: apps/payments/services/payments.py:84
|
||||||
|
msgid "Idempotency key already used"
|
||||||
|
msgstr "مفتاح الإيدمبوتنسي مستخدم بالفعل"
|
||||||
|
|
||||||
|
#: apps/payments/services/payments.py:89
|
||||||
|
msgid "Unsupported payment source type"
|
||||||
|
msgstr "نوع مصدر الدفع غير مدعوم"
|
||||||
|
|
||||||
|
#: apps/payments/services/payments.py:130
|
||||||
|
#: apps/payments/services/payments.py:141
|
||||||
|
msgid "Payment provider error"
|
||||||
|
msgstr "خطأ في مزود الدفع"
|
||||||
|
|
||||||
|
#: apps/payments/views.py:50
|
||||||
|
msgid "Not allowed"
|
||||||
|
msgstr "غير مسموح"
|
||||||
|
|
||||||
|
#: apps/payments/views.py:70
|
||||||
|
msgid "Webhook secret not configured"
|
||||||
|
msgstr "لم يتم تكوين رمز الـ webhook"
|
||||||
|
|
||||||
|
#: apps/payments/views.py:73
|
||||||
|
msgid "Invalid webhook signature"
|
||||||
|
msgstr "توقيع الـ webhook غير صالح"
|
||||||
|
|
||||||
|
#: apps/payments/views.py:79
|
||||||
|
msgid "Missing payment reference"
|
||||||
|
msgstr "مرجع الدفع مفقود"
|
||||||
|
|
||||||
|
#: apps/payments/views.py:84
|
||||||
|
msgid "Payment not found"
|
||||||
|
msgstr "لم يتم العثور على الدفعة"
|
||||||
|
|
||||||
|
#: apps/payments/views.py:88
|
||||||
|
msgid "Event ignored"
|
||||||
|
msgstr "تم تجاهل الحدث"
|
||||||
|
|
||||||
|
#: apps/payments/views.py:89
|
||||||
|
msgid "Webhook processed"
|
||||||
|
msgstr "تمت معالجة الـ webhook"
|
||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
DJANGO_SETTINGS_MODULE = salon_api.settings
|
DJANGO_SETTINGS_MODULE = salon_api.settings
|
||||||
python_files = tests.py test_*.py *_tests.py
|
python_files = tests.py test_*.py *_tests.py
|
||||||
addopts = -q
|
addopts = -q -m "not external"
|
||||||
|
markers =
|
||||||
|
external: hits real third-party services (requires explicit env to run)
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ djangorestframework-simplejwt>=5.3
|
|||||||
django-cors-headers>=4.3
|
django-cors-headers>=4.3
|
||||||
psycopg[binary]>=3.1
|
psycopg[binary]>=3.1
|
||||||
python-dotenv>=1.0
|
python-dotenv>=1.0
|
||||||
|
requests>=2.31
|
||||||
|
twilio>=9.0
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -26,6 +27,7 @@ INSTALLED_APPS = [
|
|||||||
"apps.salons",
|
"apps.salons",
|
||||||
"apps.bookings",
|
"apps.bookings",
|
||||||
"apps.payments",
|
"apps.payments",
|
||||||
|
"apps.notifications",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@@ -77,11 +79,14 @@ def parse_database_url(database_url: str):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
running_tests = "PYTEST_CURRENT_TEST" in os.environ or any("pytest" in arg for arg in sys.argv)
|
||||||
if DATABASE_URL:
|
test_database_url = os.getenv("TEST_DATABASE_URL")
|
||||||
parsed_db = parse_database_url(DATABASE_URL)
|
database_url = os.getenv("DATABASE_URL")
|
||||||
|
|
||||||
|
if running_tests:
|
||||||
|
parsed_db = parse_database_url(test_database_url) if test_database_url else None
|
||||||
else:
|
else:
|
||||||
parsed_db = None
|
parsed_db = parse_database_url(database_url) if database_url else None
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": parsed_db
|
"default": parsed_db
|
||||||
@@ -135,8 +140,15 @@ CORS_ALLOWED_ORIGINS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
OTP_PROVIDER = os.getenv("OTP_PROVIDER", "console")
|
OTP_PROVIDER = os.getenv("OTP_PROVIDER", "console")
|
||||||
|
if running_tests:
|
||||||
|
OTP_PROVIDER = os.getenv("TEST_OTP_PROVIDER", "console")
|
||||||
OTP_EXPIRY_MINUTES = int(os.getenv("OTP_EXPIRY_MINUTES", "5"))
|
OTP_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_DEFAULT_CHANNEL = os.getenv("NOTIFICATION_DEFAULT_CHANNEL", "sms")
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# ExecPlan Guide
|
||||||
|
|
||||||
|
ExecPlans are living implementation docs for complex features/refactors.
|
||||||
|
|
||||||
|
## Active ExecPlan
|
||||||
|
- `docs/execplans/auth-phone-first-hardening.md`
|
||||||
|
|
||||||
|
## Other ExecPlans
|
||||||
|
- `docs/execplans/booking-notifications.md` (completed)
|
||||||
|
- `docs/execplans/booking-integrity.md` (completed)
|
||||||
|
- `docs/execplans/payments-moyasar.md` (completed)
|
||||||
|
- `docs/execplans/arabic-localization.md` (foundations completed)
|
||||||
|
|
||||||
|
## When To Use
|
||||||
|
Create/update an ExecPlan when work is cross-app, risky, or multi-step.
|
||||||
|
|
||||||
|
## Required Sections (all ExecPlans)
|
||||||
|
- `Purpose / Big Picture`
|
||||||
|
- `Progress` (checkboxes with timestamps)
|
||||||
|
- `Surprises & Discoveries`
|
||||||
|
- `Decision Log`
|
||||||
|
- `Outcomes & Retrospective`
|
||||||
|
- `Context and Orientation`
|
||||||
|
- `Plan of Work`
|
||||||
|
- `Validation and Acceptance`
|
||||||
|
- `Idempotence and Recovery`
|
||||||
|
|
||||||
|
## Operating Rules
|
||||||
|
- Keep plan self-contained and repo-path specific.
|
||||||
|
- Update the plan as you implement; do not treat it as static.
|
||||||
|
- Acceptance must be observable (API response/test/user-visible behavior).
|
||||||
|
- Include exact commands and working directory for validation.
|
||||||
|
|
||||||
|
## Minimal Skeleton
|
||||||
|
Use this structure for new plans:
|
||||||
|
|
||||||
|
```
|
||||||
|
# <Feature Name>
|
||||||
|
|
||||||
|
## Purpose / Big Picture
|
||||||
|
## Progress
|
||||||
|
- [ ] ...
|
||||||
|
|
||||||
|
## Surprises & Discoveries
|
||||||
|
- Observation: ...
|
||||||
|
Evidence: ...
|
||||||
|
|
||||||
|
## Decision Log
|
||||||
|
- Decision: ...
|
||||||
|
Rationale: ...
|
||||||
|
Date/Author: ...
|
||||||
|
|
||||||
|
## Outcomes & Retrospective
|
||||||
|
## Context and Orientation
|
||||||
|
## Plan of Work
|
||||||
|
## Validation and Acceptance
|
||||||
|
## Idempotence and Recovery
|
||||||
|
```
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Docs Index
|
||||||
|
|
||||||
|
Use this file first.
|
||||||
|
|
||||||
|
## Start Here
|
||||||
|
- Repo setup: `README.md`
|
||||||
|
- Agent rules: `AGENTS.md`
|
||||||
|
- Plan rules + active plan: `docs/PLANS.md`
|
||||||
|
- Active ExecPlan: `docs/execplans/auth-phone-first-hardening.md`
|
||||||
|
- Architecture: `docs/architecture.md`
|
||||||
|
- Risks: `docs/risks.md`
|
||||||
|
|
||||||
|
## Map
|
||||||
|
- `docs/execplans/`: feature execution plans (living docs)
|
||||||
|
- `docs/adr/`: architecture decisions
|
||||||
|
- `docs/runbooks/`: operational response guides
|
||||||
|
- `docs/templates/`: ADR/runbook templates
|
||||||
|
|
||||||
|
## Update Rules (short)
|
||||||
|
- Behavior/flow changes: update architecture + affected runbook.
|
||||||
|
- Cross-cutting hard-to-reverse decision: add ADR.
|
||||||
|
- Significant risk added/closed: update `docs/risks.md`.
|
||||||
|
- Significant feature/refactor: update or add ExecPlan.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# ADR 0001: Synchronous External Calls For MVP
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
OTP sends, booking notifications, and payment provider calls were needed quickly for MVP reliability. Running queue infrastructure early would add operational overhead.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Keep external calls synchronous in request/response paths for MVP, with explicit timeouts and failure handling.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- Faster delivery, fewer moving pieces.
|
||||||
|
- Higher latency risk when providers are slow.
|
||||||
|
- Payment/OTP failures surface to clients immediately.
|
||||||
|
- Notification failures are recorded (`FAILED`) and monitored, not returned to client requests.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
- Full queue (Celery/Redis): deferred.
|
||||||
|
- Hybrid queue for notifications only: valid future step when latency/throughput needs it.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
- `docs/architecture.md`
|
||||||
|
- `docs/runbooks/auth_otp_failures.md`
|
||||||
|
- `docs/runbooks/payments_sanity_check.md`
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# ADR 0002: Moyasar As Payment Gateway
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
MVP requires KSA-focused payment support (SAR + local methods).
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Use Moyasar as the primary gateway for payment creation and webhook reconciliation.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- Strong KSA fit for MVP.
|
||||||
|
- External dependency on Moyasar uptime/API stability.
|
||||||
|
- Gateway abstraction can be expanded later for multi-provider support.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
- Other regional gateways: deferred.
|
||||||
|
- Global-first provider: not selected for MVP priorities.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
- `backend/apps/payments/services/gateway.py`
|
||||||
|
- `docs/runbooks/payments_sanity_check.md`
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# ADR 0003: Authentica As Primary OTP Provider
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
MVP auth is phone-first; production OTP delivery needed for KSA.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Use Authentica as production OTP provider. Keep `console` provider for local development/tests.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- Production auth depends on Authentica credentials + uptime.
|
||||||
|
- Local development remains simple.
|
||||||
|
- Adding backup provider needs new adapter + runbook updates.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
- Console-only: not viable for production.
|
||||||
|
- Multi-provider from day one: deferred for scope control.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
- `backend/apps/accounts/services/otp.py`
|
||||||
|
- `backend/salon_api/settings.py`
|
||||||
|
- `docs/runbooks/auth_otp_failures.md`
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# ADR Index
|
||||||
|
|
||||||
|
Use ADRs for cross-cutting, hard-to-reverse decisions.
|
||||||
|
|
||||||
|
## Existing ADRs
|
||||||
|
- `0001-synchronous-external-calls-mvp.md`
|
||||||
|
- `0002-moyasar-payment-gateway.md`
|
||||||
|
- `0003-authentica-otp-provider.md`
|
||||||
|
|
||||||
|
## Add New ADR
|
||||||
|
- Copy `docs/templates/adr.md`
|
||||||
|
- Use next numeric prefix (`0004`, `0005`, ...)
|
||||||
|
- Keep status updated (`Proposed`, `Accepted`, `Deprecated`, `Superseded`)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Architecture Snapshot
|
||||||
|
|
||||||
|
Compact snapshot for current MVP reliability work.
|
||||||
|
|
||||||
|
## System
|
||||||
|
- Backend: Django + DRF (`backend/`)
|
||||||
|
- Frontend: React + Vite (`frontend/`)
|
||||||
|
- Primary market defaults: KSA (locale + phone patterns)
|
||||||
|
|
||||||
|
## Backend Domains
|
||||||
|
- `apps/accounts`: phone-first users, OTP flows, auth APIs.
|
||||||
|
- `apps/salons`: salons/services/staff/availability/reviews.
|
||||||
|
- `apps/bookings`: booking creation + status transitions + overlap/availability validation.
|
||||||
|
- `apps/payments`: Moyasar payment creation + webhook reconciliation + idempotency.
|
||||||
|
- `apps/notifications`: booking lifecycle notifications (customer/staff).
|
||||||
|
|
||||||
|
## Key Flows
|
||||||
|
1. Phone auth: `/api/auth/phone/request/` -> OTP issue, `/api/auth/phone/verify/` -> JWT.
|
||||||
|
2. Booking: `POST /api/bookings/` enforces staff, duration, availability, overlap.
|
||||||
|
3. Payment: `POST /api/payments/` creates Moyasar payment with idempotency key.
|
||||||
|
4. Webhook: `/api/payments/webhook/` maps provider events to internal payment status.
|
||||||
|
5. Notification: booking create/confirm/cancel dispatches SMS/WhatsApp records.
|
||||||
|
|
||||||
|
## Current Design Decisions
|
||||||
|
- External calls are synchronous for MVP (ADR 0001).
|
||||||
|
- Moyasar is payment provider (ADR 0002).
|
||||||
|
- Authentica is primary OTP provider (ADR 0003).
|
||||||
|
|
||||||
|
## Reliability State
|
||||||
|
- Implemented: booking integrity, payment idempotency+webhooks, lifecycle notifications, locale foundations.
|
||||||
|
- Open: full OAuth linking policy, broader i18n coverage, stronger observability/audit/compliance.
|
||||||
|
|
||||||
|
## Where To Extend Safely
|
||||||
|
- Add business rules in services modules, not views.
|
||||||
|
- Preserve idempotency keys/state transitions for booking/payment changes.
|
||||||
|
- Update runbooks + risks with every production-impacting behavior change.
|
||||||
|
|
||||||
|
## Reference Docs
|
||||||
|
- Plan policy + active plan: `docs/PLANS.md`
|
||||||
|
- Known gaps: `docs/risks.md`
|
||||||
|
- Runbooks: `docs/runbooks/README.md`
|
||||||
|
- ADRs: `docs/adr/README.md`
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Documentation Rules
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
- Single source of truth per topic; link, do not duplicate.
|
||||||
|
- Update docs in same change set as code.
|
||||||
|
- Prefer observable behavior (what to run, what to see).
|
||||||
|
|
||||||
|
## Canonical Locations
|
||||||
|
- Architecture/system boundaries: `docs/architecture.md`
|
||||||
|
- Execution plans: `docs/execplans/` + policy in `docs/PLANS.md`
|
||||||
|
- Operational procedures: `docs/runbooks/`
|
||||||
|
- Cross-cutting decisions: `docs/adr/`
|
||||||
|
- Known gaps: `docs/risks.md`
|
||||||
|
|
||||||
|
## Minimum Review Checklist
|
||||||
|
- Docs touched or explicitly unchanged.
|
||||||
|
- Runbook updated for production-impacting flow changes.
|
||||||
|
- ADR added when decision is hard to reverse.
|
||||||
|
- Risks updated when a major gap opens/closes.
|
||||||
@@ -1,120 +1,45 @@
|
|||||||
# Arabic Localization Readiness (ar-sa First)
|
# Arabic Localization Foundations
|
||||||
|
|
||||||
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
|
This ExecPlan follows `docs/PLANS.md`.
|
||||||
|
|
||||||
The requirements for ExecPlans live in `PLANS.md` at the repository root. This document must be maintained in accordance with that file.
|
|
||||||
|
|
||||||
## Purpose / Big Picture
|
## Purpose / Big Picture
|
||||||
|
Ship i18n plumbing for `ar-sa` first (locale selection, RTL behavior, user preference), with full translation breadth deferred.
|
||||||
After this change, the codebase has localization foundations in place: locale selection, right-to-left layout support, and a user language preference in the backend. Arabic is treated as a first-class locale for structure and behavior, but full translation coverage is intentionally deferred until core backend flows stabilize. You can see it working by starting the backend and frontend, switching the language to Arabic, and observing RTL layout, the page `dir="rtl"`, and the API responding with the correct `Content-Language` when sending `Accept-Language: ar-sa`.
|
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
- [x] (2026-02-27 00:00Z) Plan created.
|
||||||
- [x] (2026-02-27 00:00Z) Created initial ExecPlan for Arabic localization readiness.
|
- [x] (2026-02-28 12:20Z) Backend locale middleware + user preference landed.
|
||||||
- [x] (2026-02-28 12:00Z) Added backend locale settings, LocaleMiddleware, user language preference, and user locale middleware.
|
- [x] (2026-02-28 12:30Z) Frontend i18n + RTL switching landed.
|
||||||
- [x] (2026-02-28 12:10Z) Wrapped backend user-facing strings for future translation (no full catalog yet).
|
- [x] (2026-02-28 12:40Z) Basic tests/docs updated.
|
||||||
- [x] (2026-02-28 12:20Z) Added frontend i18n, RTL support, language persistence, and minimal seed translations.
|
- [ ] Complete translation coverage and broader RTL QA as features expand.
|
||||||
- [x] (2026-02-28 12:30Z) Added targeted backend and frontend tests for locale selection and RTL behavior.
|
|
||||||
- [x] (2026-02-28 12:40Z) Updated documentation and risks for staged localization.
|
|
||||||
|
|
||||||
## Surprises & Discoveries
|
## Surprises & Discoveries
|
||||||
|
- Observation: none critical after implementation.
|
||||||
- Observation: None yet.
|
Evidence: targeted locale/RTL tests passed for initial scope.
|
||||||
Evidence: No implementation work has started.
|
|
||||||
|
|
||||||
## Decision Log
|
## Decision Log
|
||||||
|
- Decision: `ar-sa` default locale with `en` fallback.
|
||||||
- Decision: Use `ar-sa` as the default locale with English as a fallback.
|
Rationale: KSA-first product target.
|
||||||
Rationale: The product is KSA-focused and Arabic should be primary while keeping English for mixed audiences.
|
Date/Author: 2026-02-27/Codex
|
||||||
Date/Author: 2026-02-27, Codex
|
- Decision: deliver foundations first, defer full string coverage.
|
||||||
- Decision: Implement localization foundations now and defer full translation coverage.
|
Rationale: reduce churn while core product flows are still evolving.
|
||||||
Rationale: Early i18n plumbing avoids future refactors, while delaying full translation prevents churn as features evolve.
|
Date/Author: 2026-02-28/Codex
|
||||||
Date/Author: 2026-02-28, Codex
|
|
||||||
- Decision: Use lower-case `ar-sa` for locale identifiers in code and storage.
|
|
||||||
Rationale: Django language codes are lower-case; standardizing avoids mismatches between backend and frontend.
|
|
||||||
Date/Author: 2026-02-28, Codex
|
|
||||||
- Decision: Persist user language preference on the `User` model and fall back to `Accept-Language` for anonymous requests.
|
|
||||||
Rationale: This provides consistent localized behavior for logged-in users while respecting browser preferences for guests.
|
|
||||||
Date/Author: 2026-02-27, Codex
|
|
||||||
- Decision: Localize both backend API messages and frontend UI strings.
|
|
||||||
Rationale: A partial localization would create mismatched language experiences and confuse users.
|
|
||||||
Date/Author: 2026-02-27, Codex
|
|
||||||
- Decision: Use `i18next` + `react-i18next` with a small custom locale selection helper rather than a detection plugin.
|
|
||||||
Rationale: The project is small and can avoid extra dependencies while still meeting locale selection requirements.
|
|
||||||
Date/Author: 2026-02-27, Codex
|
|
||||||
|
|
||||||
## Outcomes & Retrospective
|
## Outcomes & Retrospective
|
||||||
|
Foundations complete; translation completeness remains open.
|
||||||
Localization foundations are now in place across backend and frontend, with user preference support, RTL layout, minimal Arabic strings, and basic tests. Full translation coverage and broader RTL QA remain as future work once core flows stabilize.
|
|
||||||
|
|
||||||
## Context and Orientation
|
## Context and Orientation
|
||||||
|
- Backend locale settings/middleware: `backend/salon_api/settings.py`, `backend/apps/accounts/middleware.py`
|
||||||
The backend is a Django + DRF app in `backend/` with settings in `backend/salon_api/settings.py`. The frontend is a Vite + React app in `frontend/` with the entrypoint at `frontend/src/main.jsx` and global styles in `frontend/src/styles.css`. Localization foundations now exist: Django `LocaleMiddleware` is configured and `apps/accounts/middleware.py` applies user preferences, while the frontend initializes `i18next` in `frontend/src/i18n/index.js` and sets `lang`/`dir` on the root element. User-facing strings have begun to be wrapped for translation, but full Arabic translation coverage remains pending.
|
- Frontend i18n/RTL: `frontend/src/i18n/`, `frontend/src/main.jsx`, `frontend/src/styles.css`
|
||||||
|
|
||||||
## Plan of Work
|
## Plan of Work
|
||||||
|
Next phase: expand string coverage and run end-to-end RTL checks on all added routes/components.
|
||||||
First, add Django locale support. Update `backend/salon_api/settings.py` to define `LANGUAGE_CODE="ar-sa"`, `LANGUAGES` with Arabic and English, `LOCALE_PATHS` pointing to `backend/locale`, and add `django.middleware.locale.LocaleMiddleware` to `MIDDLEWARE` after `SessionMiddleware`. Create `backend/apps/accounts/middleware.py` with `UserLocaleMiddleware` that activates `request.user.preferred_language` after `AuthenticationMiddleware` and sets the response `Content-Language` header. Add a `preferred_language` field to `backend/apps/accounts/models.py` and expose it via `backend/apps/accounts/serializers.py` so `/api/auth/me/` can read and update it.
|
|
||||||
|
|
||||||
Next, wrap all user-facing backend strings in translation wrappers. Use `from django.utils.translation import gettext_lazy as _` in serializers and models, and `gettext` in runtime view responses. Cover custom messages in `apps/accounts`, `apps/bookings`, `apps/payments`, and `apps/salons`. Do not translate the backend catalog yet; full Arabic API messages are a later milestone once core flows stabilize. Update or add tests that confirm language selection by user preference and `Accept-Language` headers.
|
|
||||||
|
|
||||||
Then, add frontend localization. Introduce an `frontend/src/i18n/` module that sets up `i18next` with `en` and `ar-sa` resource files. Update `frontend/src/main.jsx` to initialize i18n before rendering `App`, set `document.documentElement.lang` and `dir` whenever language changes, and persist the selected locale to local storage. Update `frontend/src/api/client.js` to include the `Accept-Language` header using the active locale. Replace hard-coded UI strings in `frontend/src/App.jsx` with `t(...)` keys and add minimal Arabic translations for the current UI.
|
|
||||||
|
|
||||||
Finally, make the UI RTL-safe. Update `frontend/src/styles.css` to use logical properties (`margin-inline`, `padding-inline`, `text-align: start`) where relevant, add `:dir(rtl)` overrides for layout if needed, and add an Arabic-capable font such as `Noto Sans Arabic` to the font stack. Validate end-to-end behavior by running the backend and frontend, switching language, and confirming the UI renders RTL and API responses match the selected locale. Full translation coverage remains a later milestone.
|
|
||||||
|
|
||||||
## Concrete Steps
|
|
||||||
|
|
||||||
Run these commands from the repository root (`/home/kopernikus/kshkool/Salon`).
|
|
||||||
|
|
||||||
1. Add backend locale middleware, settings, and `preferred_language` field, then create a migration.
|
|
||||||
- Update `backend/salon_api/settings.py`, `backend/apps/accounts/models.py`, and add `backend/apps/accounts/middleware.py`.
|
|
||||||
- Run:
|
|
||||||
python3 backend/manage.py makemigrations accounts
|
|
||||||
|
|
||||||
2. (Deferred) Generate and compile Arabic translations for the backend when full translation coverage is ready.
|
|
||||||
- Run:
|
|
||||||
python3 backend/manage.py makemessages -l ar --ignore frontend --ignore node_modules
|
|
||||||
python3 backend/manage.py compilemessages
|
|
||||||
- Edit `backend/locale/ar/LC_MESSAGES/django.po` to translate the newly wrapped strings.
|
|
||||||
|
|
||||||
3. Add frontend i18n resources and wire them into the app.
|
|
||||||
- Update `frontend/package.json`, `frontend/src/main.jsx`, `frontend/src/api/client.js`, `frontend/src/App.jsx`, and create `frontend/src/i18n/index.js` plus translation JSON files.
|
|
||||||
|
|
||||||
4. Run tests and verify behavior.
|
|
||||||
- Backend:
|
|
||||||
python3 -m pytest
|
|
||||||
- Frontend:
|
|
||||||
cd frontend
|
|
||||||
npm run test
|
|
||||||
|
|
||||||
## Validation and Acceptance
|
## Validation and Acceptance
|
||||||
|
- Backend: locale header behavior matches user preference/`Accept-Language`.
|
||||||
Backend acceptance is achieved when `Accept-Language` and user preference change the response language header. For example, an OTP error should carry `Content-Language: ar-sa` even if the message text remains English until translations are added:
|
- Frontend: language toggle sets `lang` + `dir`, persists across refresh.
|
||||||
|
- Commands:
|
||||||
$ curl -s -H "Accept-Language: ar-sa" -X POST http://localhost:8000/api/auth/otp/request/ -H "Content-Type: application/json" -d '{"phone_number":"123","channel":"sms"}'
|
- `cd backend && python3 -m pytest`
|
||||||
{"phone_number":["Phone number is required"]}
|
- `cd frontend && npm run test`
|
||||||
|
|
||||||
Frontend acceptance is achieved when the page renders Arabic text, the root element uses `dir="rtl"`, and the UI remains readable. You should be able to toggle language, reload, and still see Arabic due to stored preference. Running `npm run dev` and visiting the page should show Arabic UI strings when the selected locale is `ar-sa`.
|
|
||||||
|
|
||||||
## Idempotence and Recovery
|
## Idempotence and Recovery
|
||||||
|
Locale config is additive and safe to reapply; translation catalogs can be regenerated.
|
||||||
The locale settings and middleware changes are safe to apply multiple times. Translation commands can be rerun; `makemessages` updates catalogs and `compilemessages` rebuilds `.mo` files. If a translation file is corrupted, re-run `makemessages` and re-apply translations. The migration adding `preferred_language` is additive and reversible via standard Django migration rollback.
|
|
||||||
|
|
||||||
## Artifacts and Notes
|
|
||||||
|
|
||||||
Expected header behavior after implementing locale selection:
|
|
||||||
|
|
||||||
Content-Language: ar-sa
|
|
||||||
|
|
||||||
Example local storage entry for the frontend:
|
|
||||||
|
|
||||||
localStorage["locale"] = "ar-sa"
|
|
||||||
|
|
||||||
## Interfaces and Dependencies
|
|
||||||
|
|
||||||
Add backend localization dependencies by using Django’s built-in translation system and middleware (`django.middleware.locale.LocaleMiddleware`) and a new `apps.accounts.middleware.UserLocaleMiddleware` to enforce user preference. The `User` model gains a language preference field:
|
|
||||||
|
|
||||||
preferred_language = models.CharField(max_length=10, choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE)
|
|
||||||
|
|
||||||
Frontend dependencies must include `i18next` and `react-i18next`. The i18n setup should live in `frontend/src/i18n/index.js`, exporting an initialized i18n instance. The API client in `frontend/src/api/client.js` must attach `Accept-Language` to every request based on the active locale.
|
|
||||||
|
|
||||||
Plan Maintenance Note: Initial plan created on 2026-02-27 to scope Arabic localization readiness across backend and frontend. Updated on 2026-02-28 to stage localization work (foundations now, full translations later).
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Phone-first Auth Hardening
|
||||||
|
|
||||||
|
This ExecPlan follows `docs/PLANS.md`.
|
||||||
|
|
||||||
|
## Purpose / Big Picture
|
||||||
|
Keep phone OTP as canonical login surface, preserve phone-first identity across serializers/admin/UI-facing strings, and lock regression tests around this contract.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
- [x] (2026-03-14 12:00 UTC) Plan created with test-first scope.
|
||||||
|
- [x] (2026-03-14 13:55 UTC) Added tests for display fallback + phone auth error contracts.
|
||||||
|
- [x] (2026-03-14 14:30 UTC) Implemented `User.display_name`, serializer/admin updates, and docs updates.
|
||||||
|
- [ ] Expand tests for OAuth linking policy and remaining phone-first invariants.
|
||||||
|
|
||||||
|
## Surprises & Discoveries
|
||||||
|
- Observation: JWT test key warning appears in suite.
|
||||||
|
Evidence: `InsecureKeyLengthWarning` during accounts/salons pytest runs.
|
||||||
|
|
||||||
|
## Decision Log
|
||||||
|
- Decision: Pre-create user on phone request; verify on phone verify.
|
||||||
|
Rationale: deterministic onboarding lifecycle.
|
||||||
|
Date/Author: 2026-03-14/Codex
|
||||||
|
- Decision: Add `User.display_name` and reuse everywhere.
|
||||||
|
Rationale: stable fallback for phone-only accounts.
|
||||||
|
Date/Author: 2026-03-14/Codex
|
||||||
|
|
||||||
|
## Outcomes & Retrospective
|
||||||
|
Core phone-first hardening landed and tests pass for implemented scope. Remaining work is mainly policy (OAuth linking/conflict) plus extra invariants coverage.
|
||||||
|
|
||||||
|
## Context and Orientation
|
||||||
|
- Auth endpoints: `backend/apps/accounts/views.py`
|
||||||
|
- User model/admin: `backend/apps/accounts/models.py`, `backend/apps/accounts/admin.py`
|
||||||
|
- Cross-app display paths: `backend/apps/salons/`, `backend/apps/bookings/`
|
||||||
|
|
||||||
|
## Plan of Work
|
||||||
|
1. Keep adding invariant tests first.
|
||||||
|
2. Finalize OAuth linking/conflict policy and enforce in auth services.
|
||||||
|
3. Update docs/runbooks/risks with final contract.
|
||||||
|
|
||||||
|
## Validation and Acceptance
|
||||||
|
From `backend/`:
|
||||||
|
- `python3 -m pytest backend/apps/accounts/tests backend/apps/salons/tests`
|
||||||
|
Acceptance:
|
||||||
|
- Phone auth endpoints remain canonical and stable.
|
||||||
|
- Display paths show phone-first labels when email absent.
|
||||||
|
- New invariant tests pass.
|
||||||
|
|
||||||
|
## Idempotence and Recovery
|
||||||
|
Auth hardening changes are additive and test-gated. Roll back by app-level revert if a contract regression is detected.
|
||||||
@@ -1,121 +1,45 @@
|
|||||||
# Booking Integrity (Availability, Schedules, Overlap Prevention)
|
# Booking Integrity
|
||||||
|
|
||||||
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
|
This ExecPlan follows `docs/PLANS.md`.
|
||||||
|
|
||||||
The requirements for ExecPlans live in `PLANS.md` at the repository root. This document must be maintained in accordance with that file.
|
|
||||||
|
|
||||||
## Purpose / Big Picture
|
## Purpose / Big Picture
|
||||||
|
Reject invalid booking windows and staff double-booking; accept only schedule-valid requests.
|
||||||
After this change, bookings cannot be created in invalid time windows or in ways that double-book staff. A manager can rely on the system to prevent overlapping appointments and to enforce staff working hours. You can see it working by attempting to create a booking outside a staff member’s availability window or that overlaps an existing confirmed booking and receiving a clear validation error; creating a booking that fits availability and does not overlap should succeed.
|
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
- [x] (2026-02-28 13:05Z) Plan created.
|
||||||
- [x] (2026-02-28 13:05Z) Created ExecPlan for booking integrity (availability, schedules, overlap prevention).
|
- [x] (2026-02-28 13:30Z) Validation service implemented (duration/availability/overlap).
|
||||||
- [x] (2026-02-28 13:25Z) Added staff availability model, admin registration, and manual migration.
|
- [x] (2026-02-28 13:45Z) Tests added.
|
||||||
- [x] (2026-02-28 13:30Z) Introduced booking validation service for duration, schedule, and overlap checks.
|
- [x] (2026-02-28 13:50Z) Risks updated.
|
||||||
- [x] (2026-02-28 13:32Z) Updated booking create serializer to require staff and enforce validation rules.
|
|
||||||
- [x] (2026-02-28 13:45Z) Added backend tests covering overlap prevention, availability windows, and duration validation.
|
|
||||||
- [x] (2026-02-28 13:50Z) Updated `docs/risks.md` to reflect closed booking-integrity gaps.
|
|
||||||
|
|
||||||
## Surprises & Discoveries
|
## Surprises & Discoveries
|
||||||
|
- Observation: `makemigrations` unavailable in one environment due missing Django.
|
||||||
- Observation: Django is not installed in the environment, so `makemigrations` could not run.
|
Evidence: import error during initial migration step.
|
||||||
Evidence: `ImportError: Couldn't import Django` when running `python3 backend/manage.py makemigrations salons`.
|
|
||||||
|
|
||||||
## Decision Log
|
## Decision Log
|
||||||
|
- Decision: require `staff` on booking creation.
|
||||||
- Decision: Require `staff` on booking creation to enforce schedule and overlap rules deterministically.
|
Rationale: no deterministic schedule validation without staff.
|
||||||
Rationale: Without an assigned staff member, the system cannot guarantee schedule integrity.
|
Date/Author: 2026-02-28/Codex
|
||||||
Date/Author: 2026-02-28, Codex
|
- Decision: no-availability-records => open schedule.
|
||||||
- Decision: Treat staff availability as open-ended if no availability records exist for that staff member.
|
Rationale: backward compatibility while enabling stricter config when data exists.
|
||||||
Rationale: This avoids breaking existing workflows while enabling explicit schedule enforcement when configured.
|
Date/Author: 2026-02-28/Codex
|
||||||
Date/Author: 2026-02-28, Codex
|
|
||||||
- Decision: Enforce that `end_time - start_time` matches the service duration in minutes.
|
|
||||||
Rationale: Prevents inconsistent bookings and ensures predictable slot lengths.
|
|
||||||
Date/Author: 2026-02-28, Codex
|
|
||||||
- Decision: Add the `StaffAvailability` migration manually instead of using `makemigrations`.
|
|
||||||
Rationale: Django was unavailable in the environment; a manual migration keeps schema changes explicit and reviewable.
|
|
||||||
Date/Author: 2026-02-28, Codex
|
|
||||||
|
|
||||||
## Outcomes & Retrospective
|
## Outcomes & Retrospective
|
||||||
|
Completed. Booking integrity checks are active with tests for core failure modes.
|
||||||
Booking integrity is now enforced via staff availability checks, duration validation, and overlap prevention, with test coverage for each rule. This closes the highest-risk booking integrity gap in `docs/risks.md`, while timezone and business-hours enforcement remain future work.
|
|
||||||
|
|
||||||
## Context and Orientation
|
## Context and Orientation
|
||||||
|
- Validation entrypoint: `backend/apps/bookings/services.py`
|
||||||
Booking creation is implemented in `backend/apps/bookings/serializers.py` (`BookingCreateSerializer`) and routed via `backend/apps/bookings/views.py` in a DRF `ModelViewSet`. The booking model lives in `backend/apps/bookings/models.py`, while staff information is in `backend/apps/salons/models.py` as `StaffProfile`. There is no current scheduling model and no overlap validation. This plan introduces a staff availability model and a dedicated booking validation service to keep business logic out of views, in line with project standards.
|
- Create serializer: `backend/apps/bookings/serializers.py`
|
||||||
|
- Staff availability model: `backend/apps/salons/models.py`
|
||||||
|
|
||||||
## Plan of Work
|
## Plan of Work
|
||||||
|
Completed; remaining future policy work is timezone/business-hours specifics.
|
||||||
First, add a staff availability model in `backend/apps/salons/models.py`. Create a `StaffAvailability` model with a foreign key to `StaffProfile`, a day-of-week integer (0-6), and start/end times (as `TimeField`). Use an `is_active` boolean to allow disabling entries without deleting them. Register the model in `backend/apps/salons/admin.py` for basic management. Create and apply a migration in the salons app.
|
|
||||||
|
|
||||||
Next, add a booking validation service in `backend/apps/bookings/services.py`. The service should expose a function like `validate_booking_request(service, staff, start_time, end_time)` that raises `serializers.ValidationError` or a custom domain error translated into DRF validation errors. It should check:
|
|
||||||
|
|
||||||
- `staff` is required and belongs to the same salon as the service.
|
|
||||||
- `start_time < end_time` and duration matches `service.duration_minutes`.
|
|
||||||
- Staff availability: if availability records exist for the staff and day-of-week, ensure the booking window is fully inside one availability window with `is_active=True`.
|
|
||||||
- Overlap: prevent any booking for the same staff with status in `pending` or `confirmed` that overlaps the requested window; `cancelled` and `completed` bookings should not block.
|
|
||||||
|
|
||||||
Then, update `BookingCreateSerializer` in `backend/apps/bookings/serializers.py` to call the validation service and to require `staff`. Keep `create` unchanged beyond relying on validated data.
|
|
||||||
|
|
||||||
Finally, add tests in `backend/apps/bookings/tests/test_booking_integrity.py`. Cover these cases:
|
|
||||||
|
|
||||||
- Reject bookings with no staff assigned.
|
|
||||||
- Reject bookings where `end_time` precedes `start_time`.
|
|
||||||
- Reject bookings where duration does not match `service.duration_minutes`.
|
|
||||||
- Reject bookings outside staff availability when availability records exist.
|
|
||||||
- Allow bookings when no availability records exist.
|
|
||||||
- Reject overlapping bookings for the same staff with `pending` or `confirmed` status; allow overlaps with `cancelled` or `completed` bookings.
|
|
||||||
|
|
||||||
Update `docs/risks.md` to mark booking integrity gaps as addressed once tests pass.
|
|
||||||
|
|
||||||
## Concrete Steps
|
|
||||||
|
|
||||||
Run these commands from the repository root (`/home/m7md/kshkool/Salon`).
|
|
||||||
|
|
||||||
1. Add staff availability model and migration.
|
|
||||||
- Edit `backend/apps/salons/models.py` and `backend/apps/salons/admin.py`.
|
|
||||||
- Run:
|
|
||||||
python3 backend/manage.py makemigrations salons
|
|
||||||
|
|
||||||
2. Add booking validation service and update serializer.
|
|
||||||
- Create `backend/apps/bookings/services.py` and update `backend/apps/bookings/serializers.py`.
|
|
||||||
|
|
||||||
3. Add tests.
|
|
||||||
- Create `backend/apps/bookings/tests/test_booking_integrity.py`.
|
|
||||||
|
|
||||||
4. Run tests.
|
|
||||||
- Backend:
|
|
||||||
python3 -m pytest
|
|
||||||
|
|
||||||
## Validation and Acceptance
|
## Validation and Acceptance
|
||||||
|
From `backend/`:
|
||||||
- Attempting to create a booking without a staff member returns HTTP 400 with a clear validation error.
|
- `python3 -m pytest backend/apps/bookings/tests`
|
||||||
- Creating a booking outside availability returns HTTP 400 with a clear validation error.
|
Acceptance:
|
||||||
- Creating a booking overlapping an existing pending/confirmed booking for the same staff returns HTTP 400.
|
- Invalid duration/availability/overlap cases return 400.
|
||||||
- Creating a booking within an availability window and without overlap returns HTTP 201.
|
- Valid windows return 201.
|
||||||
- Running `python3 -m pytest` passes, and the new booking-integrity tests fail before the changes and pass after.
|
|
||||||
|
|
||||||
## Idempotence and Recovery
|
## Idempotence and Recovery
|
||||||
|
Validation is stateless; schema change is additive and reversible by standard migrations.
|
||||||
Model and serializer changes are additive and safe to reapply. If a migration needs to be re-run, it can be rolled back using standard Django migration rollback and re-applied. The validation service is pure and can be iterated without impacting data. If availability rules are too strict, disabling availability entries will effectively remove the constraint without deleting data.
|
|
||||||
|
|
||||||
## Artifacts and Notes
|
|
||||||
|
|
||||||
Example overlap query used in validation:
|
|
||||||
|
|
||||||
Booking.objects.filter(
|
|
||||||
staff=staff,
|
|
||||||
status__in=[BookingStatus.PENDING, BookingStatus.CONFIRMED],
|
|
||||||
start_time__lt=end_time,
|
|
||||||
end_time__gt=start_time,
|
|
||||||
)
|
|
||||||
|
|
||||||
## Interfaces and Dependencies
|
|
||||||
|
|
||||||
- `backend/apps/salons/models.py` must define a new `StaffAvailability` model with fields: `staff` (FK), `day_of_week` (0-6), `start_time`, `end_time`, `is_active`.
|
|
||||||
- `backend/apps/bookings/services.py` must define `validate_booking_request(service, staff, start_time, end_time)`.
|
|
||||||
- `backend/apps/bookings/serializers.py` must call the validation service and require `staff` on create.
|
|
||||||
|
|
||||||
Plan Maintenance Note: Created on 2026-02-28 to implement booking integrity (availability, schedules, overlap prevention) as the next Phase 1 reliability step.
|
|
||||||
Plan Maintenance Note (Update): Marked milestones complete and recorded the manual migration decision after implementing booking integrity and tests on 2026-02-28.
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Booking Lifecycle Notifications
|
||||||
|
|
||||||
|
This ExecPlan follows `docs/PLANS.md`.
|
||||||
|
|
||||||
|
## Purpose / Big Picture
|
||||||
|
Notify customer and staff on booking created/confirmed/cancelled with auditable notification rows.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
- [x] (2026-02-28 17:05Z) Plan + scope finalized.
|
||||||
|
- [x] (2026-02-28 17:40Z) Notification app + providers + booking wiring implemented.
|
||||||
|
- [x] (2026-02-28 18:05Z) Tests added for create/status-change/no-duplicate behavior.
|
||||||
|
- [x] (2026-02-28 18:10Z) Risks/docs updated.
|
||||||
|
|
||||||
|
## Surprises & Discoveries
|
||||||
|
- Observation: status field had been read-only for update flow.
|
||||||
|
Evidence: `PATCH /api/bookings/<id>` validation failure before serializer update.
|
||||||
|
|
||||||
|
## Decision Log
|
||||||
|
- Decision: persist notification records for every lifecycle send attempt.
|
||||||
|
Rationale: auditability + support traceability.
|
||||||
|
Date/Author: 2026-02-28/Codex
|
||||||
|
- Decision: reuse OTP provider abstraction for notification channels.
|
||||||
|
Rationale: avoid duplicated provider integration.
|
||||||
|
Date/Author: 2026-02-28/Codex
|
||||||
|
|
||||||
|
## Outcomes & Retrospective
|
||||||
|
Completed. Booking lifecycle notifications are live with idempotent records and test coverage.
|
||||||
|
|
||||||
|
## Context and Orientation
|
||||||
|
- Notification domain: `backend/apps/notifications/`
|
||||||
|
- Booking integration points: `backend/apps/bookings/views.py`
|
||||||
|
|
||||||
|
## Plan of Work
|
||||||
|
Completed; future work is provider hardening/monitoring only.
|
||||||
|
|
||||||
|
## Validation and Acceptance
|
||||||
|
From `backend/`:
|
||||||
|
- `python3 -m pytest backend/apps/notifications/tests`
|
||||||
|
Acceptance:
|
||||||
|
- Create/confirm/cancel each emit customer+staff notification rows once per event/channel.
|
||||||
|
|
||||||
|
## Idempotence and Recovery
|
||||||
|
Uniqueness constraints prevent duplicate event/channel sends per recipient/booking; retries update existing rows safely.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Payments Integration (Moyasar)
|
||||||
|
|
||||||
|
This ExecPlan follows `docs/PLANS.md`.
|
||||||
|
|
||||||
|
## Purpose / Big Picture
|
||||||
|
Create payments idempotently, reconcile webhook states safely, and keep payment history auditable.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
- [x] (2026-02-28 14:35Z) Plan created.
|
||||||
|
- [x] (2026-02-28 15:55Z) Payment creation + webhook processing implemented.
|
||||||
|
- [x] (2026-02-28 16:10Z) Tests for create/idempotency/webhooks implemented.
|
||||||
|
- [x] (2026-02-28 16:20Z) Risks updated.
|
||||||
|
|
||||||
|
## Surprises & Discoveries
|
||||||
|
- Observation: missing `requests` dependency blocked gateway calls initially.
|
||||||
|
Evidence: `ModuleNotFoundError: requests`.
|
||||||
|
|
||||||
|
## Decision Log
|
||||||
|
- Decision: enforce request-level idempotency key for payment create.
|
||||||
|
Rationale: prevent duplicate charges on retries.
|
||||||
|
Date/Author: 2026-02-28/Codex
|
||||||
|
- Decision: persist provider + webhook payloads.
|
||||||
|
Rationale: payment auditability/debuggability.
|
||||||
|
Date/Author: 2026-02-28/Codex
|
||||||
|
|
||||||
|
## Outcomes & Retrospective
|
||||||
|
Completed for MVP core flow (create + webhook). Remaining work: richer operational monitoring and optional admin capture/refund APIs.
|
||||||
|
|
||||||
|
## Context and Orientation
|
||||||
|
- Payment model/services/views: `backend/apps/payments/`
|
||||||
|
- Booking dependency: `backend/apps/bookings/`
|
||||||
|
|
||||||
|
## Plan of Work
|
||||||
|
Completed for current scope.
|
||||||
|
|
||||||
|
## Validation and Acceptance
|
||||||
|
From `backend/`:
|
||||||
|
- `python3 -m pytest backend/apps/payments/tests`
|
||||||
|
Acceptance:
|
||||||
|
- New key => new payment.
|
||||||
|
- Same key => existing payment reused.
|
||||||
|
- Valid webhook transitions state idempotently.
|
||||||
|
- Invalid webhook auth rejected.
|
||||||
|
|
||||||
|
## Idempotence and Recovery
|
||||||
|
Idempotency key guards create path; webhook replay is safe.
|
||||||
+18
-23
@@ -1,31 +1,26 @@
|
|||||||
# Risks And Gaps
|
# Risks And Gaps
|
||||||
|
|
||||||
This file tracks known gaps and risks to address in future iterations.
|
Open items only; remove resolved duplicates.
|
||||||
|
|
||||||
## Security And Auth
|
## Auth
|
||||||
- Phone normalization is KSA-focused and minimal; broaden for multi-country use.
|
- KSA-focused phone normalization; multi-country strategy pending.
|
||||||
- OTP protections are basic; add device fingerprinting and IP throttling if needed.
|
- Phone auth abuse controls need production tuning (IP/device thresholds).
|
||||||
- OTP provider adapters (Twilio/Unifonic) are scaffolds only; no real SMS/WhatsApp delivery yet.
|
- Social login/OAuth linking policy still undefined (collision/merge rules).
|
||||||
- Social login is a placeholder.
|
- JWT test warning exists for short test signing key (`InsecureKeyLengthWarning`).
|
||||||
- USERNAME_FIELD is still `email` while email can be null; verify admin/login flows.
|
|
||||||
|
|
||||||
## Booking Integrity
|
## Booking
|
||||||
- Availability checks and overlap prevention are now enforced for staff bookings.
|
- No explicit timezone/business-hours policy beyond current availability checks.
|
||||||
- No timezone handling or business hours enforcement.
|
- Cancellation policy and refund policy not finalized.
|
||||||
- No cancellation rules or refund logic.
|
|
||||||
|
|
||||||
## Payments
|
## Payments
|
||||||
- Payment integration is not implemented. Current API only stores records and a Moyasar scaffold exists.
|
- Core Moyasar flow works; admin capture/refund endpoints not exposed yet.
|
||||||
- Webhook handling and payment status reconciliation missing.
|
- Monitoring/alerting for webhook failures is still basic.
|
||||||
- Idempotency handling for payment creation missing.
|
|
||||||
|
|
||||||
## Data And UX
|
## Localization
|
||||||
- Ratings are not recalculated from reviews.
|
- Foundations exist (`en`, `ar-sa`, RTL), but translation coverage is incomplete.
|
||||||
- No image upload or storage strategy for photos.
|
- RTL QA across all future pages still pending.
|
||||||
- No notifications (email/SMS) beyond OTP scaffolding.
|
|
||||||
- Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending.
|
|
||||||
|
|
||||||
## Ops And Compliance
|
## Ops/Compliance
|
||||||
- No audit logs for admin actions.
|
- No full audit log strategy for privileged actions.
|
||||||
- No multi-tenant isolation or data export tooling.
|
- No PDPL/GDPR retention policy or data export workflow.
|
||||||
- No GDPR/PDPL data retention policies defined.
|
- No formal observability baseline (metrics/SLO dashboards).
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Runbooks Index
|
||||||
|
|
||||||
|
Runbooks for production-impacting flows:
|
||||||
|
- `docs/runbooks/auth_otp_failures.md`
|
||||||
|
- `docs/runbooks/booking_failures.md`
|
||||||
|
- `docs/runbooks/payments_sanity_check.md`
|
||||||
|
|
||||||
|
Rule: if a new production flow is added, add or update a runbook in same change.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Runbook: Auth OTP Failures
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
- Users do not receive OTP.
|
||||||
|
- `/api/auth/otp/request` or `/api/auth/phone/request` fails.
|
||||||
|
- `/api/auth/otp/verify` or `/api/auth/phone/verify` shows invalid/expired unexpectedly.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
Users cannot sign in/verify phone; booking/payment flows may block.
|
||||||
|
|
||||||
|
## Quick Checks
|
||||||
|
- Confirm `OTP_PROVIDER` in `backend/salon_api/settings.py`.
|
||||||
|
- Check OTP provider credentials in `backend/.env`.
|
||||||
|
- Check app logs for provider/timeouts/rate-limit errors.
|
||||||
|
- Validate OTP rate-limit settings:
|
||||||
|
- `OTP_MAX_PER_WINDOW`
|
||||||
|
- `OTP_WINDOW_MINUTES`
|
||||||
|
- `OTP_RESEND_COOLDOWN_SECONDS`
|
||||||
|
- `PHONE_AUTH_IP_MAX_PER_WINDOW`
|
||||||
|
- `PHONE_AUTH_DEVICE_MAX_PER_WINDOW`
|
||||||
|
|
||||||
|
## Mitigation
|
||||||
|
1. Fix env/config mismatch; restart API.
|
||||||
|
2. If provider outage, use `console` only in non-prod.
|
||||||
|
3. If abuse spike/false positives, tune IP/device thresholds.
|
||||||
|
4. Verify server clock and `OTP_EXPIRY_MINUTES`.
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
- Roll back recent auth changes if correlated with deployment.
|
||||||
|
- Escalate to Authentica with request IDs + timestamps.
|
||||||
|
|
||||||
|
## References
|
||||||
|
- OTP logic: `backend/apps/accounts/services/otp.py`
|
||||||
|
- Risks: `docs/risks.md`
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Runbook: Booking Failures
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
- `POST /api/bookings/` fails (400/500).
|
||||||
|
- Booking status update fails.
|
||||||
|
- Booking missing/incorrect in listing.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
Customers cannot book; staff schedule integrity degrades; dependent flows break.
|
||||||
|
|
||||||
|
## Quick Checks
|
||||||
|
- Validate payload: `service`, `staff`, `start_time`, `end_time`.
|
||||||
|
- Check logs for validation/integrity errors.
|
||||||
|
- Confirm staff availability + overlap expectations.
|
||||||
|
- If notifications expected, confirm provider config + notification rows.
|
||||||
|
|
||||||
|
## Mitigation
|
||||||
|
1. Reproduce with known test data.
|
||||||
|
2. Inspect booking validation service and serializer permissions.
|
||||||
|
3. Confirm timezone assumptions for failing case.
|
||||||
|
4. If regression after deploy, roll back booking-related change.
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
Share booking id, user id, timestamps, and failing payload/response with engineering.
|
||||||
|
|
||||||
|
## References
|
||||||
|
- Booking logic: `backend/apps/bookings/`
|
||||||
|
- Notification logic: `backend/apps/notifications/`
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Runbook: Payments Sanity Check (Local Mock)
|
||||||
|
|
||||||
|
Validate payment create + webhook reconciliation without hitting Moyasar.
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
- Venv + backend deps installed.
|
||||||
|
- DB migrated.
|
||||||
|
- Run from repo root unless noted.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Start local mock server on `127.0.0.1:8001` exposing `POST /v1/payments`.
|
||||||
|
2. Seed data:
|
||||||
|
- `source venv/bin/activate`
|
||||||
|
- `cd backend`
|
||||||
|
- `python3 manage.py migrate`
|
||||||
|
- `python3 manage.py seed_demo`
|
||||||
|
3. Run API with mock settings:
|
||||||
|
- `DJANGO_DEBUG=1 MOYASAR_SECRET_KEY=sk_test MOYASAR_PUBLISHABLE_KEY=pk_test MOYASAR_BASE_URL=http://127.0.0.1:8001 MOYASAR_WEBHOOK_SECRET=whsec python3 manage.py runserver 8000`
|
||||||
|
4. Generate JWT in shell (demo user) and store as `<ACCESS>`.
|
||||||
|
5. Create payment:
|
||||||
|
- `POST /api/payments/` with `booking_id`, `provider=moyasar`, `idempotency_key`, valid source.
|
||||||
|
6. Send paid webhook:
|
||||||
|
- `POST /api/payments/webhook/` with `{"type":"payment_paid","secret_token":"whsec","data":{"id":"<external_id>"}}`
|
||||||
|
7. Verify `GET /api/payments/` shows status `paid` and `paid_at` set.
|
||||||
|
|
||||||
|
## Expected Results
|
||||||
|
- Create payment returns `status=initiated` + provider `external_id` + `redirect_url`.
|
||||||
|
- Webhook returns `{"detail":"Webhook processed"}`.
|
||||||
|
- Payment transitions to `paid` idempotently.
|
||||||
|
|
||||||
|
## Edge Checks
|
||||||
|
- Wrong/missing webhook secret -> `401`.
|
||||||
|
- Reused idempotency key -> same payment reused, no duplicate charge.
|
||||||
|
- Unsupported sources rejected by validation.
|
||||||
|
|
||||||
|
## Cleanup
|
||||||
|
Stop Django + mock processes.
|
||||||
Vendored
+19
@@ -0,0 +1,19 @@
|
|||||||
|
# ADR <NNNN>: <Title>
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Proposed | Accepted | Deprecated | Superseded
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Problem, constraints, and forces.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Chosen option.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
Positive/negative impact, including ops impact.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
Viable options and rejection reason.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
Relevant docs/code/runbooks/PRs.
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
# Runbook: <Title>
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
## Impact
|
||||||
|
## Quick Checks
|
||||||
|
## Mitigation Steps
|
||||||
|
## Rollback / Escalation
|
||||||
|
## References
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Frontend Notes
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
- React/Vite app with i18n (`en`, `ar-sa`) and RTL switching.
|
||||||
|
- Still light on routing/domain separation; critical flow test coverage needs expansion.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
- `cd frontend && npm install`
|
||||||
|
- `npm run dev`
|
||||||
|
|
||||||
|
## Test
|
||||||
|
- `npm run test`
|
||||||
|
|
||||||
|
## Pointers
|
||||||
|
- Architecture: `docs/architecture.md`
|
||||||
|
- Risks: `docs/risks.md`
|
||||||
Generated
+4545
File diff suppressed because it is too large
Load Diff
@@ -13,12 +13,13 @@
|
|||||||
"i18next": "^23.11.5",
|
"i18next": "^23.11.5",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-i18next": "^14.1.0"
|
"react-i18next": "^14.1.0",
|
||||||
|
"react-router-dom": "^7.13.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
|
||||||
"@testing-library/jest-dom": "^6.4.2",
|
"@testing-library/jest-dom": "^6.4.2",
|
||||||
"@testing-library/react": "^14.2.1",
|
"@testing-library/react": "^14.2.1",
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
"jsdom": "^24.0.0",
|
"jsdom": "^24.0.0",
|
||||||
"vite": "^5.0.0",
|
"vite": "^5.0.0",
|
||||||
"vitest": "^1.3.1"
|
"vitest": "^1.3.1"
|
||||||
|
|||||||
+24
-90
@@ -1,95 +1,29 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import MainLayout from "./layouts/MainLayout";
|
||||||
import { apiGet } from "./api/client";
|
import HomePage from "./pages/HomePage";
|
||||||
import { setLocale } from "./i18n";
|
import BookPage from "./pages/BookPage";
|
||||||
|
import PaymentPage from "./pages/PaymentPage";
|
||||||
|
import ProfilePage from "./pages/ProfilePage";
|
||||||
|
import BookingsPage from "./pages/BookingsPage";
|
||||||
|
import LoginPage from "./pages/LoginPage";
|
||||||
|
import SalonDetailPage from "./pages/SalonDetailPage";
|
||||||
|
import PaymentReturnPage from "./pages/PaymentReturnPage";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [salons, setSalons] = useState([]);
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [status, setStatus] = useState("idle");
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let ignore = false;
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
setStatus("loading");
|
|
||||||
try {
|
|
||||||
const data = await apiGet(`/salons/?q=${encodeURIComponent(query)}`);
|
|
||||||
if (!ignore) {
|
|
||||||
setSalons(data);
|
|
||||||
setStatus("ready");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!ignore) {
|
|
||||||
setStatus("error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
load();
|
|
||||||
return () => {
|
|
||||||
ignore = true;
|
|
||||||
};
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<BrowserRouter>
|
||||||
<header className="hero">
|
<Routes>
|
||||||
<div className="hero-top">
|
<Route path="/" element={<MainLayout />}>
|
||||||
<p className="eyebrow">{t("hero.eyebrow")}</p>
|
<Route index element={<HomePage />} />
|
||||||
<div className="locale-switch" aria-label={t("locale.label")}>
|
<Route path="salon/:id" element={<SalonDetailPage />} />
|
||||||
<button
|
<Route path="book" element={<BookPage />} />
|
||||||
type="button"
|
<Route path="pay" element={<PaymentPage />} />
|
||||||
className={i18n.language === "ar-sa" ? "active" : ""}
|
<Route path="pay/return" element={<PaymentReturnPage />} />
|
||||||
onClick={() => setLocale("ar-sa")}
|
<Route path="bookings" element={<BookingsPage />} />
|
||||||
>
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
{t("locale.arabic")}
|
<Route path="login" element={<LoginPage />} />
|
||||||
</button>
|
</Route>
|
||||||
<button
|
</Routes>
|
||||||
type="button"
|
</BrowserRouter>
|
||||||
className={i18n.language === "en" ? "active" : ""}
|
|
||||||
onClick={() => setLocale("en")}
|
|
||||||
>
|
|
||||||
{t("locale.english")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h1>{t("hero.title")}</h1>
|
|
||||||
<p className="subtitle">{t("hero.subtitle")}</p>
|
|
||||||
<div className="search">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder={t("hero.searchPlaceholder")}
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="results">
|
|
||||||
<h2>{t("results.title")}</h2>
|
|
||||||
{status === "loading" && <p>{t("results.loading")}</p>}
|
|
||||||
{status === "error" && (
|
|
||||||
<p className="error">{t("results.error")}</p>
|
|
||||||
)}
|
|
||||||
{status === "ready" && salons.length === 0 && <p>{t("results.empty")}</p>}
|
|
||||||
<div className="grid">
|
|
||||||
{salons.map((salon) => (
|
|
||||||
<article className="card" key={salon.id}>
|
|
||||||
<div className="card-header">
|
|
||||||
<h3>{salon.name}</h3>
|
|
||||||
<span className="rating">{salon.rating_avg} / 5</span>
|
|
||||||
</div>
|
|
||||||
<p>{salon.description || t("card.noDescription")}</p>
|
|
||||||
<div className="meta">
|
|
||||||
<span>{salon.city}</span>
|
|
||||||
<span>{salon.phone_number || t("card.phoneUnavailable")}</span>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,35 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { vi } from "vitest";
|
||||||
import App from "./App.jsx";
|
import App from "./App.jsx";
|
||||||
|
import { AuthProvider } from "./contexts/AuthContext";
|
||||||
import i18n from "./i18n";
|
import i18n from "./i18n";
|
||||||
|
|
||||||
|
vi.mock("./api/client", () => ({
|
||||||
|
apiGet: vi.fn().mockResolvedValue([]),
|
||||||
|
apiPost: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
function TestWrapper({ children }) {
|
||||||
|
return <AuthProvider>{children}</AuthProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
describe("App", () => {
|
describe("App", () => {
|
||||||
it("renders the hero copy", async () => {
|
it("renders the hero copy", async () => {
|
||||||
await i18n.changeLanguage("en");
|
await i18n.changeLanguage("en");
|
||||||
render(<App />);
|
render(<App />, { wrapper: TestWrapper });
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Find, compare, and book top salons near you.")
|
await screen.findByText("Find, compare, and book top salons near you.")
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches to Arabic and sets RTL direction", async () => {
|
it("switches to Arabic and sets RTL direction", async () => {
|
||||||
await i18n.changeLanguage("en");
|
await i18n.changeLanguage("en");
|
||||||
render(<App />);
|
render(<App />, { wrapper: TestWrapper });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "العربية" }));
|
const arabicButton = screen.getByRole("button", { name: "العربية" });
|
||||||
|
fireEvent.click(arabicButton);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(document.documentElement.dir).toBe("rtl");
|
expect(document.documentElement.dir).toBe("rtl");
|
||||||
});
|
});
|
||||||
expect(screen.getByText("الصالونات")).toBeInTheDocument();
|
expect(arabicButton).toHaveClass("active");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,19 +2,78 @@ import { getActiveLocale } from "../i18n";
|
|||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || "/api";
|
const API_BASE = import.meta.env.VITE_API_BASE || "/api";
|
||||||
|
|
||||||
async function handleResponse(response) {
|
export class ApiError extends Error {
|
||||||
if (!response.ok) {
|
constructor(message, { status, body } = {}) {
|
||||||
const errorText = await response.text();
|
super(message);
|
||||||
throw new Error(errorText || `Request failed: ${response.status}`);
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
|
this.body = body;
|
||||||
}
|
}
|
||||||
return response.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiGet(path) {
|
async function handleResponse(response) {
|
||||||
|
const text = await response.text();
|
||||||
|
let body = null;
|
||||||
|
try {
|
||||||
|
body = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message =
|
||||||
|
(body?.detail && typeof body.detail === "string" ? body.detail : null) ||
|
||||||
|
text ||
|
||||||
|
`Request failed: ${response.status}`;
|
||||||
|
throw new ApiError(message, { status: response.status, body });
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseHeaders() {
|
||||||
|
return {
|
||||||
|
"Accept-Language": getActiveLocale(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiGet(path, token) {
|
||||||
|
const headers = { ...baseHeaders() };
|
||||||
|
if (token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, { headers });
|
||||||
|
return handleResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPost(path, body, token) {
|
||||||
|
const headers = {
|
||||||
|
...baseHeaders(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
headers: {
|
method: "POST",
|
||||||
"Accept-Language": getActiveLocale(),
|
headers,
|
||||||
},
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
return handleResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPatch(path, body, token) {
|
||||||
|
const headers = {
|
||||||
|
...baseHeaders(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
return handleResponse(response);
|
return handleResponse(response);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { setLocale } from "../i18n";
|
||||||
|
|
||||||
|
export default function LocaleSwitch() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="locale-switch" aria-label={t("locale.label")}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={i18n.language === "ar-sa" ? "active" : ""}
|
||||||
|
onClick={() => setLocale("ar-sa")}
|
||||||
|
>
|
||||||
|
{t("locale.arabic")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={i18n.language === "en" ? "active" : ""}
|
||||||
|
onClick={() => setLocale("en")}
|
||||||
|
>
|
||||||
|
{t("locale.english")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { usePaymentForm } from "../hooks/usePaymentForm";
|
||||||
|
|
||||||
|
export default function PaymentForm({ bookingId = "", token = "" }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const form = usePaymentForm(bookingId, token);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="payments">
|
||||||
|
<div className="payments-header">
|
||||||
|
<div>
|
||||||
|
<h2>{t("payment.title")}</h2>
|
||||||
|
<p className="payments-subtitle">{t("payment.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<span className="payments-badge">{t("payment.badge")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="payments-form"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
form.submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("payment.bookingId")}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={form.bookingIdInput}
|
||||||
|
onChange={(e) => form.setBookingIdInput(e.target.value)}
|
||||||
|
placeholder="123"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("payment.accessToken")}</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.tokenInput}
|
||||||
|
onChange={(e) => form.setTokenInput(e.target.value)}
|
||||||
|
placeholder={t("payment.accessTokenPlaceholder")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("payment.sourceType")}</span>
|
||||||
|
<select
|
||||||
|
value={form.sourceType}
|
||||||
|
onChange={(e) => form.setSourceType(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="stcpay">{t("payment.sources.stcpay")}</option>
|
||||||
|
<option value="token">{t("payment.sources.token")}</option>
|
||||||
|
<option value="applepay">{t("payment.sources.applepay")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("payment.sourceValue")}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.sourceValue}
|
||||||
|
onChange={(e) => form.setSourceValue(e.target.value)}
|
||||||
|
placeholder={t("payment.sourceValuePlaceholder")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("payment.callbackUrl")}</span>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={form.callbackUrl}
|
||||||
|
onChange={(e) => form.setCallbackUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com/payments/return"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="payments-actions">
|
||||||
|
<button type="submit" disabled={form.status === "loading"}>
|
||||||
|
{form.status === "loading"
|
||||||
|
? t("payment.processing")
|
||||||
|
: t("payment.payNow")}
|
||||||
|
</button>
|
||||||
|
<p className="helper">
|
||||||
|
{t("payment.idempotency")}: {form.idempotencyKey}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{form.status === "error" && form.error && (
|
||||||
|
<p className="error">{form.error}</p>
|
||||||
|
)}
|
||||||
|
{form.status === "ready" && form.result && (
|
||||||
|
<pre className="payment-result">{JSON.stringify(form.result, null, 2)}</pre>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Navigate, useLocation } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
|
||||||
|
export default function ProtectedRoute({ children }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isAuthenticated, loading } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="auth-loading">
|
||||||
|
<p>{t("common.loading")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function SalonCard({ salon }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<article className="card" data-testid="salon-card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3>{salon.name}</h3>
|
||||||
|
<span className="rating">{salon.rating_avg} / 5</span>
|
||||||
|
</div>
|
||||||
|
<p>{salon.description || t("card.noDescription")}</p>
|
||||||
|
<div className="meta">
|
||||||
|
<span>{salon.city}</span>
|
||||||
|
<span>{salon.phone_number || t("card.phoneUnavailable")}</span>
|
||||||
|
</div>
|
||||||
|
<Link to={`/salon/${salon.id}`} className="card-link">
|
||||||
|
{t("card.viewDetails")}
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSalonSearch } from "../hooks/useSalonSearch";
|
||||||
|
import SalonCard from "./SalonCard";
|
||||||
|
|
||||||
|
export function SearchInput({ value, onChange }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="search">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("hero.searchPlaceholder")}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
aria-label={t("hero.searchPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SalonSearch({ query }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { salons, status } = useSalonSearch(query);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="results">
|
||||||
|
<h2>{t("results.title")}</h2>
|
||||||
|
{status === "loading" && <p>{t("results.loading")}</p>}
|
||||||
|
{status === "error" && <p className="error">{t("results.error")}</p>}
|
||||||
|
{status === "ready" && salons.length === 0 && <p>{t("results.empty")}</p>}
|
||||||
|
<div className="grid">
|
||||||
|
{salons.map((salon) => (
|
||||||
|
<SalonCard key={salon.id} salon={salon} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { vi } from "vitest";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import SalonSearch from "./SalonSearch";
|
||||||
|
import i18n from "../i18n";
|
||||||
|
|
||||||
|
vi.mock("../api/client", () => ({
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { apiGet } = await import("../api/client");
|
||||||
|
|
||||||
|
function renderWithRouter(ui) {
|
||||||
|
return render(<BrowserRouter>{ui}</BrowserRouter>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SalonSearch", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
apiGet.mockResolvedValue([]);
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loading then empty when no results", async () => {
|
||||||
|
renderWithRouter(<SalonSearch query="test" />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(apiGet).toHaveBeenCalledWith("/salons/?q=test");
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/no salons found/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows salon cards when results returned", async () => {
|
||||||
|
apiGet.mockResolvedValue([
|
||||||
|
{ id: 1, name: "Salon A", city: "Riyadh", rating_avg: 4.5, phone_number: "+966500000000" },
|
||||||
|
]);
|
||||||
|
renderWithRouter(<SalonSearch query="salon" />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Salon A")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Riyadh")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||||
|
import { apiGet, apiPost } from "../api/client";
|
||||||
|
|
||||||
|
const STORAGE_ACCESS = "auth_access";
|
||||||
|
const STORAGE_REFRESH = "auth_refresh";
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [accessToken, setAccessToken] = useState(() => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return localStorage.getItem(STORAGE_ACCESS);
|
||||||
|
});
|
||||||
|
const [refreshToken, setRefreshToken] = useState(() => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return localStorage.getItem(STORAGE_REFRESH);
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const persistTokens = useCallback((access, refresh) => {
|
||||||
|
setAccessToken(access);
|
||||||
|
setRefreshToken(refresh);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
if (access) localStorage.setItem(STORAGE_ACCESS, access);
|
||||||
|
else localStorage.removeItem(STORAGE_ACCESS);
|
||||||
|
if (refresh) localStorage.setItem(STORAGE_REFRESH, refresh);
|
||||||
|
else localStorage.removeItem(STORAGE_REFRESH);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
setUser(null);
|
||||||
|
persistTokens(null, null);
|
||||||
|
}, [persistTokens]);
|
||||||
|
|
||||||
|
const login = useCallback((access, refresh, userData) => {
|
||||||
|
persistTokens(access, refresh);
|
||||||
|
setUser(userData);
|
||||||
|
}, [persistTokens]);
|
||||||
|
|
||||||
|
// Restore user from token on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accessToken) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apiGet("/auth/me/", accessToken)
|
||||||
|
.then((data) => {
|
||||||
|
setUser(data);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Token invalid, try refresh
|
||||||
|
if (!refreshToken) {
|
||||||
|
logout();
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apiPost("/auth/token/refresh/", { refresh: refreshToken })
|
||||||
|
.then(({ access }) => {
|
||||||
|
persistTokens(access, refreshToken);
|
||||||
|
return apiGet("/auth/me/", access);
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
setUser(data);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
logout();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [accessToken, refreshToken, logout, persistTokens]);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
user,
|
||||||
|
accessToken,
|
||||||
|
loading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { apiPost } from "../api/client";
|
||||||
|
|
||||||
|
function generateIdempotencyKey() {
|
||||||
|
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `id-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must match AuthContext's STORAGE_ACCESS so fallback finds the same token
|
||||||
|
const AUTH_TOKEN_KEY = "auth_access";
|
||||||
|
|
||||||
|
export function usePaymentForm(bookingId = "", token = "") {
|
||||||
|
// token: optional auth token from AuthContext; tokenInput: manual override from form
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [bookingIdInput, setBookingIdInput] = useState(bookingId);
|
||||||
|
const [tokenInput, setTokenInput] = useState(() => {
|
||||||
|
if (token) return token;
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
return localStorage.getItem(AUTH_TOKEN_KEY) || "";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
const [sourceType, setSourceType] = useState("stcpay");
|
||||||
|
const [sourceValue, setSourceValue] = useState("");
|
||||||
|
const [callbackUrl, setCallbackUrl] = useState(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
return `${window.location.origin}/pay/return`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
const [status, setStatus] = useState("idle");
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const idempotencyKey = useMemo(generateIdempotencyKey, []);
|
||||||
|
|
||||||
|
// Persist token to localStorage when it changes
|
||||||
|
const setTokenInputAndPersist = (value) => {
|
||||||
|
setTokenInput(value);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
if (value) {
|
||||||
|
localStorage.setItem(AUTH_TOKEN_KEY, value);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setStatus("loading");
|
||||||
|
setError("");
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
if (!bookingIdInput) {
|
||||||
|
setStatus("error");
|
||||||
|
setError(t("payment.errors.bookingRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = { type: sourceType };
|
||||||
|
if (sourceType === "stcpay") {
|
||||||
|
if (!sourceValue) {
|
||||||
|
setStatus("error");
|
||||||
|
setError(t("payment.errors.mobileRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
source.mobile = sourceValue;
|
||||||
|
}
|
||||||
|
if (sourceType === "token") {
|
||||||
|
if (!sourceValue) {
|
||||||
|
setStatus("error");
|
||||||
|
setError(t("payment.errors.tokenRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
source.token = sourceValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
booking_id: Number(bookingIdInput),
|
||||||
|
provider: "moyasar",
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
|
source,
|
||||||
|
};
|
||||||
|
if (callbackUrl) {
|
||||||
|
payload.callback_url = callbackUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use tokenInput (form state) so user edits are respected; token prop only initializes it
|
||||||
|
const authToken = tokenInput;
|
||||||
|
try {
|
||||||
|
const data = await apiPost("/payments/", payload, authToken || undefined);
|
||||||
|
setResult(data);
|
||||||
|
setStatus("ready");
|
||||||
|
if (data?.redirect_url) {
|
||||||
|
window.location.assign(data.redirect_url);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setStatus("error");
|
||||||
|
setError(err.message || t("payment.errors.generic"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bookingIdInput,
|
||||||
|
setBookingIdInput,
|
||||||
|
tokenInput,
|
||||||
|
setTokenInput: setTokenInputAndPersist,
|
||||||
|
sourceType,
|
||||||
|
setSourceType,
|
||||||
|
sourceValue,
|
||||||
|
setSourceValue,
|
||||||
|
callbackUrl,
|
||||||
|
setCallbackUrl,
|
||||||
|
idempotencyKey,
|
||||||
|
status,
|
||||||
|
result,
|
||||||
|
error,
|
||||||
|
submit,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { apiGet } from "../api/client";
|
||||||
|
|
||||||
|
export function useSalonSearch(query) {
|
||||||
|
const [salons, setSalons] = useState([]);
|
||||||
|
const [status, setStatus] = useState("idle");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let ignore = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setStatus("loading");
|
||||||
|
try {
|
||||||
|
const data = await apiGet(`/salons/?q=${encodeURIComponent(query)}`);
|
||||||
|
if (!ignore) {
|
||||||
|
setSalons(data);
|
||||||
|
setStatus("ready");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!ignore) {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
ignore = true;
|
||||||
|
};
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return { salons, status };
|
||||||
|
}
|
||||||
@@ -13,11 +13,41 @@
|
|||||||
},
|
},
|
||||||
"card": {
|
"card": {
|
||||||
"noDescription": "لا يوجد وصف بعد.",
|
"noDescription": "لا يوجد وصف بعد.",
|
||||||
"phoneUnavailable": "الهاتف غير متوفر"
|
"phoneUnavailable": "الهاتف غير متوفر",
|
||||||
|
"viewDetails": "عرض التفاصيل والحجز"
|
||||||
},
|
},
|
||||||
"locale": {
|
"nav":{"home":"الرئيسية","book":"احجز","pay":"ادفع","profile":"الحساب","bookings":"حجوزاتي","login":"تسجيل الدخول","logout":"تسجيل الخروج"},"book":{"title":"احجزي خدمة","placeholder":"تدفق الحجز قريباً.","cta":"احجزي الآن","selectSalon":"اختاري صالوناً من الصفحة الرئيسية للحجز.","service":"الخدمة","staff":"الفريق","date":"التاريخ","time":"الوقت","notes":"ملاحظات","notesPlaceholder":"ملاحظات اختيارية","selectService":"اختاري الخدمة","selectStaff":"اختاري الفني","submit":"تأكيد الحجز","submitting":"جاري الحجز...","errors":{"fillAll":"يرجى تعبئة جميع الحقول.","invalidTime":"تاريخ أو وقت غير صالح.","generic":"فشل الحجز."}},"salon":{"services":"الخدمات","staff":"الفريق","unknownStaff":"موظف {{id}}"},"profile":{"title":"الحساب","placeholder":"الملف والحجوزات قريباً.","myBookings":"حجوزاتي","noContact":"لا توجد معلومات اتصال"},"bookings":{"title":"حجوزاتي","subtitle":"مواعيدك القادمة والسابقة.","empty":"لا توجد حجوزات بعد.","pay":"ادفع الآن"},"paymentReturn":{"title":"حالة الدفع","success":"تم الدفع بنجاح","successMessage":"تم إتمام الدفع. ستستلمين تأكيداً عبر SMS أو واتساب.","checkStatus":"تحققي من بريدك أو الصالون لحالة الدفع.","reference":"المرجع: {{id}}","viewBookings":"عرض حجوزاتي"},"auth":{"title":"تسجيل الدخول","subtitle":"أدخلي رقم جوالك لاستلام رمز التحقق.","phone":"رقم الجوال","channel":"الإرسال عبر","sms":"رسالة نصية","whatsapp":"واتساب","sendCode":"إرسال الرمز","sending":"جاري الإرسال...","verifyTitle":"أدخلي الرمز","verifySubtitle":"أرسلنا رمزاً إلى {{phone}}","code":"رمز التحقق","verify":"تحقق","verifying":"جاري التحقق...","back":"تغيير الرقم","errors":{"generic":"حدث خطأ.","retryAfter":"انتظري {{seconds}} ثانية قبل المحاولة مرة أخرى."}},"locale": {
|
||||||
"label": "اللغة",
|
"label": "اللغة",
|
||||||
"arabic": "العربية",
|
"arabic": "العربية",
|
||||||
"english": "الإنجليزية"
|
"english": "الإنجليزية"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "جاري التحميل..."
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"title": "المدفوعات (تجريبي)",
|
||||||
|
"subtitle": "إرسال عملية دفع عبر Moyasar لحجز موجود.",
|
||||||
|
"badge": "المدفوعات",
|
||||||
|
"bookingId": "رقم الحجز",
|
||||||
|
"accessToken": "رمز الوصول",
|
||||||
|
"accessTokenPlaceholder": "الصقي رمز JWT",
|
||||||
|
"sourceType": "نوع المصدر",
|
||||||
|
"sourceValue": "قيمة المصدر",
|
||||||
|
"sourceValuePlaceholder": "رقم الجوال أو الرمز",
|
||||||
|
"callbackUrl": "رابط العودة",
|
||||||
|
"payNow": "ادفع الآن",
|
||||||
|
"processing": "جارٍ المعالجة...",
|
||||||
|
"idempotency": "مفتاح التكرار",
|
||||||
|
"sources": {
|
||||||
|
"stcpay": "stc pay (جوال)",
|
||||||
|
"token": "دفع عبر رمز",
|
||||||
|
"applepay": "Apple Pay"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"bookingRequired": "رقم الحجز مطلوب.",
|
||||||
|
"mobileRequired": "رقم الجوال مطلوب لـ stc pay.",
|
||||||
|
"tokenRequired": "الرمز مطلوب للدفع عبر الرمز.",
|
||||||
|
"generic": "فشل طلب الدفع."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,41 @@
|
|||||||
},
|
},
|
||||||
"card": {
|
"card": {
|
||||||
"noDescription": "No description yet.",
|
"noDescription": "No description yet.",
|
||||||
"phoneUnavailable": "Phone unavailable"
|
"phoneUnavailable": "Phone unavailable",
|
||||||
|
"viewDetails": "View details & book"
|
||||||
},
|
},
|
||||||
"locale": {
|
"nav":{"home":"Home","book":"Book","pay":"Pay","profile":"Profile","bookings":"My bookings","login":"Sign in","logout":"Sign out"},"book":{"title":"Book a Service","placeholder":"Booking flow coming soon.","cta":"Book now","selectSalon":"Select a salon from the home page to book.","service":"Service","staff":"Staff","date":"Date","time":"Time","notes":"Notes","notesPlaceholder":"Optional notes","selectService":"Select service","selectStaff":"Select staff","submit":"Confirm booking","submitting":"Booking...","errors":{"fillAll":"Please fill all required fields.","invalidTime":"Invalid date or time.","generic":"Booking failed."}},"salon":{"services":"Services","staff":"Staff","unknownStaff":"Staff {{id}}"},"profile":{"title":"Profile","placeholder":"Profile and bookings coming soon.","myBookings":"My bookings","noContact":"No contact info"},"bookings":{"title":"My bookings","subtitle":"Your upcoming and past appointments.","empty":"No bookings yet.","pay":"Pay now"},"paymentReturn":{"title":"Payment status","success":"Payment successful","successMessage":"Your payment was completed. You will receive a confirmation by SMS or WhatsApp.","checkStatus":"Check your email or the salon for payment status.","reference":"Reference: {{id}}","viewBookings":"View my bookings"},"auth":{"title":"Sign in","subtitle":"Enter your phone number to receive a verification code.","phone":"Phone number","channel":"Send via","sms":"SMS","whatsapp":"WhatsApp","sendCode":"Send code","sending":"Sending...","verifyTitle":"Enter code","verifySubtitle":"We sent a code to {{phone}}","code":"Verification code","verify":"Verify","verifying":"Verifying...","back":"Change number","errors":{"generic":"Something went wrong.","retryAfter":"Please wait {{seconds}} seconds before trying again."}},"locale": {
|
||||||
"label": "Language",
|
"label": "Language",
|
||||||
"arabic": "العربية",
|
"arabic": "العربية",
|
||||||
"english": "English"
|
"english": "English"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Loading..."
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"title": "Payment (Beta)",
|
||||||
|
"subtitle": "Send a Moyasar payment for an existing booking.",
|
||||||
|
"badge": "Payments",
|
||||||
|
"bookingId": "Booking ID",
|
||||||
|
"accessToken": "Access token",
|
||||||
|
"accessTokenPlaceholder": "Paste JWT access token",
|
||||||
|
"sourceType": "Source type",
|
||||||
|
"sourceValue": "Source value",
|
||||||
|
"sourceValuePlaceholder": "Mobile number or token",
|
||||||
|
"callbackUrl": "Callback URL",
|
||||||
|
"payNow": "Pay now",
|
||||||
|
"processing": "Processing...",
|
||||||
|
"idempotency": "Idempotency key",
|
||||||
|
"sources": {
|
||||||
|
"stcpay": "stc pay (mobile)",
|
||||||
|
"token": "tokenized payment",
|
||||||
|
"applepay": "Apple Pay"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"bookingRequired": "Booking ID is required.",
|
||||||
|
"mobileRequired": "Mobile number is required for stc pay.",
|
||||||
|
"tokenRequired": "Token is required for token payments.",
|
||||||
|
"generic": "Payment request failed."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Outlet, Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import LocaleSwitch from "../components/LocaleSwitch";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
|
||||||
|
export default function MainLayout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isAuthenticated, logout } = useAuth();
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<header className="main-header">
|
||||||
|
<nav className="main-nav">
|
||||||
|
<Link to="/" className="nav-brand">
|
||||||
|
{t("nav.home")}
|
||||||
|
</Link>
|
||||||
|
<Link to="/book" className="nav-link">
|
||||||
|
{t("nav.book")}
|
||||||
|
</Link>
|
||||||
|
<Link to="/pay" className="nav-link">
|
||||||
|
{t("nav.pay")}
|
||||||
|
</Link>
|
||||||
|
<Link to="/profile" className="nav-link">
|
||||||
|
{t("nav.profile")}
|
||||||
|
</Link>
|
||||||
|
<Link to="/bookings" className="nav-link">
|
||||||
|
{t("nav.bookings")}
|
||||||
|
</Link>
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<button type="button" className="nav-link nav-logout" onClick={logout}>
|
||||||
|
{t("nav.logout")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link to="/login" className="nav-link">
|
||||||
|
{t("nav.login")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
<LocaleSwitch />
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App.jsx";
|
import App from "./App.jsx";
|
||||||
|
import { AuthProvider } from "./contexts/AuthContext";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { apiGet, apiPost } from "../api/client";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import ProtectedRoute from "../components/ProtectedRoute";
|
||||||
|
|
||||||
|
export default function BookPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const salonId = searchParams.get("salon");
|
||||||
|
|
||||||
|
const [salon, setSalon] = useState(null);
|
||||||
|
const [serviceId, setServiceId] = useState("");
|
||||||
|
const [staffId, setStaffId] = useState("");
|
||||||
|
const [date, setDate] = useState("");
|
||||||
|
const [time, setTime] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!salonId) return;
|
||||||
|
apiGet(`/salons/${salonId}/`)
|
||||||
|
.then(setSalon)
|
||||||
|
.catch(() => setSalon(null));
|
||||||
|
}, [salonId]);
|
||||||
|
|
||||||
|
if (!salonId) {
|
||||||
|
return (
|
||||||
|
<section className="book-page">
|
||||||
|
<h1>{t("book.title")}</h1>
|
||||||
|
<p>{t("book.selectSalon")}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedService = salon?.services?.find((s) => String(s.id) === serviceId);
|
||||||
|
const duration = selectedService?.duration_minutes || 0;
|
||||||
|
|
||||||
|
function computeEndTime(startISO) {
|
||||||
|
if (!startISO || !duration) return null;
|
||||||
|
const start = new Date(startISO);
|
||||||
|
const end = new Date(start.getTime() + duration * 60 * 1000);
|
||||||
|
return end.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
if (!serviceId || !staffId || !date || !time) {
|
||||||
|
setError(t("book.errors.fillAll"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Use Asia/Riyadh offset for backend (KSA)
|
||||||
|
const startISO = `${date}T${time}:00+03:00`;
|
||||||
|
const endISO = computeEndTime(startISO);
|
||||||
|
if (!endISO) {
|
||||||
|
setError(t("book.errors.invalidTime"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const booking = await apiPost(
|
||||||
|
"/bookings/",
|
||||||
|
{
|
||||||
|
service: Number(serviceId),
|
||||||
|
staff: Number(staffId),
|
||||||
|
start_time: startISO,
|
||||||
|
end_time: endISO,
|
||||||
|
notes,
|
||||||
|
},
|
||||||
|
accessToken
|
||||||
|
);
|
||||||
|
navigate(`/pay?booking=${booking.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || t("book.errors.generic"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<section className="book-page">
|
||||||
|
<h1>{t("book.title")}</h1>
|
||||||
|
{salon && <p className="book-salon">{salon.name}</p>}
|
||||||
|
|
||||||
|
{!salon ? (
|
||||||
|
<p>{t("results.loading")}</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="book-form">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("book.service")}</span>
|
||||||
|
<select
|
||||||
|
value={serviceId}
|
||||||
|
onChange={(e) => setServiceId(e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">{t("book.selectService")}</option>
|
||||||
|
{salon.services?.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name} – {s.duration_minutes} min, {s.price_amount} {s.currency}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("book.staff")}</span>
|
||||||
|
<select
|
||||||
|
value={staffId}
|
||||||
|
onChange={(e) => setStaffId(e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">{t("book.selectStaff")}</option>
|
||||||
|
{salon.staff?.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name || s.title || t("salon.unknownStaff", { id: s.id })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("book.date")}</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("book.time")}</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
onChange={(e) => setTime(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("book.notes")}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder={t("book.notesPlaceholder")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<button type="submit" disabled={loading}>
|
||||||
|
{loading ? t("book.submitting") : t("book.submit")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <ProtectedRoute>{content}</ProtectedRoute>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { apiGet } from "../api/client";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import ProtectedRoute from "../components/ProtectedRoute";
|
||||||
|
import { getActiveLocale } from "../i18n/index";
|
||||||
|
|
||||||
|
function formatDateTime(iso, locale) {
|
||||||
|
if (!iso) return "";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString(locale, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BookingsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const [bookings, setBookings] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
apiGet("/bookings/", accessToken)
|
||||||
|
.then(setBookings)
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<section className="bookings-page">
|
||||||
|
<h1>{t("bookings.title")}</h1>
|
||||||
|
<p className="bookings-subtitle">{t("bookings.subtitle")}</p>
|
||||||
|
|
||||||
|
{loading && <p>{t("results.loading")}</p>}
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
{!loading && !error && bookings.length === 0 && (
|
||||||
|
<p>{t("bookings.empty")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && bookings.length > 0 && (
|
||||||
|
<ul className="bookings-list">
|
||||||
|
{bookings.map((b) => (
|
||||||
|
<li key={b.id} className="booking-card">
|
||||||
|
<div className="booking-header">
|
||||||
|
<span className="booking-status">{b.status}</span>
|
||||||
|
<span className="booking-salon">{b.salon_name}</span>
|
||||||
|
</div>
|
||||||
|
<p className="booking-service">{b.service_name}</p>
|
||||||
|
<p className="booking-time">
|
||||||
|
{formatDateTime(b.start_time, getActiveLocale())} – {formatDateTime(b.end_time, getActiveLocale())}
|
||||||
|
</p>
|
||||||
|
<p className="booking-price">
|
||||||
|
{b.price_amount} {b.currency}
|
||||||
|
</p>
|
||||||
|
<Link to={`/pay?booking=${b.id}`} className="booking-pay-link">
|
||||||
|
{t("bookings.pay")}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <ProtectedRoute>{content}</ProtectedRoute>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { SearchInput, default as SalonSearch } from "../components/SalonSearch";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="hero">
|
||||||
|
<div className="hero-top">
|
||||||
|
<p className="eyebrow">{t("hero.eyebrow")}</p>
|
||||||
|
</div>
|
||||||
|
<h1>{t("hero.title")}</h1>
|
||||||
|
<p className="subtitle">{t("hero.subtitle")}</p>
|
||||||
|
<SearchInput value={query} onChange={setQuery} />
|
||||||
|
</header>
|
||||||
|
<SalonSearch query={query} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { apiPost, ApiError } from "../api/client";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [step, setStep] = useState("phone");
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
|
const [channel, setChannel] = useState("sms");
|
||||||
|
const [requestId, setRequestId] = useState("");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const from = location.state?.from?.pathname || "/";
|
||||||
|
|
||||||
|
async function handleRequestOtp(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiPost("/auth/phone/request/", {
|
||||||
|
phone_number: phone,
|
||||||
|
channel,
|
||||||
|
});
|
||||||
|
setRequestId(res.request_id);
|
||||||
|
setStep("verify");
|
||||||
|
} catch (err) {
|
||||||
|
const body = err instanceof ApiError ? err.body : null;
|
||||||
|
if (body?.retry_after_seconds) {
|
||||||
|
setError(t("auth.errors.retryAfter", { seconds: body.retry_after_seconds }));
|
||||||
|
} else {
|
||||||
|
setError(err.message || t("auth.errors.generic"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVerify(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiPost("/auth/phone/verify/", {
|
||||||
|
request_id: requestId,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
login(res.access, res.refresh, res.user);
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
const body = err instanceof ApiError ? err.body : null;
|
||||||
|
if (body?.retry_after_seconds) {
|
||||||
|
setError(t("auth.errors.retryAfter", { seconds: body.retry_after_seconds }));
|
||||||
|
} else {
|
||||||
|
setError(err.message || t("auth.errors.generic"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === "phone") {
|
||||||
|
return (
|
||||||
|
<section className="auth-page">
|
||||||
|
<h1>{t("auth.title")}</h1>
|
||||||
|
<p className="auth-subtitle">{t("auth.subtitle")}</p>
|
||||||
|
<form onSubmit={handleRequestOtp} className="auth-form">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("auth.phone")}</span>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
placeholder="+966512345678"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("auth.channel")}</span>
|
||||||
|
<select value={channel} onChange={(e) => setChannel(e.target.value)}>
|
||||||
|
<option value="sms">{t("auth.sms")}</option>
|
||||||
|
<option value="whatsapp">{t("auth.whatsapp")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<button type="submit" disabled={loading}>
|
||||||
|
{loading ? t("auth.sending") : t("auth.sendCode")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="auth-page">
|
||||||
|
<h1>{t("auth.verifyTitle")}</h1>
|
||||||
|
<p className="auth-subtitle">{t("auth.verifySubtitle", { phone })}</p>
|
||||||
|
<form onSubmit={handleVerify} className="auth-form">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("auth.code")}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
|
||||||
|
placeholder="123456"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<div className="auth-actions">
|
||||||
|
<button type="submit" disabled={loading || code.length < 6}>
|
||||||
|
{loading ? t("auth.verifying") : t("auth.verify")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="auth-back"
|
||||||
|
onClick={() => {
|
||||||
|
setStep("phone");
|
||||||
|
setCode("");
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("auth.back")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { vi } from "vitest";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import LoginPage from "./LoginPage";
|
||||||
|
import { AuthProvider } from "../contexts/AuthContext";
|
||||||
|
import i18n from "../i18n";
|
||||||
|
|
||||||
|
vi.mock("../api/client", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal();
|
||||||
|
return { ...actual, apiPost: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { apiPost } = await import("../api/client");
|
||||||
|
|
||||||
|
function renderLogin() {
|
||||||
|
return render(
|
||||||
|
<AuthProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<LoginPage />
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LoginPage", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders phone input and send code button", () => {
|
||||||
|
renderLogin();
|
||||||
|
expect(screen.getByLabelText(/phone number/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Send code" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows verify step after successful OTP request", async () => {
|
||||||
|
apiPost.mockResolvedValueOnce({ request_id: "abc-123", expires_at: "2025-01-01T12:00:00Z" });
|
||||||
|
renderLogin();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/phone number/i), { target: { value: "+966512345678" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Send code" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error when OTP request fails", async () => {
|
||||||
|
apiPost.mockRejectedValueOnce(new Error("Rate limited"));
|
||||||
|
renderLogin();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/phone number/i), { target: { value: "+966512345678" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Send code" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Rate limited")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import PaymentForm from "../components/PaymentForm";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
|
||||||
|
export default function PaymentPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const bookingIdFromUrl = searchParams.get("booking") || "";
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
return <PaymentForm bookingId={bookingIdFromUrl} token={accessToken || ""} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useSearchParams, Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function PaymentReturnPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const status = searchParams.get("status") || "";
|
||||||
|
const id = searchParams.get("id") || "";
|
||||||
|
|
||||||
|
const isSuccess = ["paid", "captured", "authorized"].includes(status.toLowerCase());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="payment-return">
|
||||||
|
<h1>{isSuccess ? t("paymentReturn.success") : t("paymentReturn.title")}</h1>
|
||||||
|
<p>
|
||||||
|
{isSuccess
|
||||||
|
? t("paymentReturn.successMessage")
|
||||||
|
: t("paymentReturn.checkStatus")}
|
||||||
|
</p>
|
||||||
|
{id && <p className="payment-return-id">{t("paymentReturn.reference", { id })}</p>}
|
||||||
|
<Link to="/profile" className="book-cta">
|
||||||
|
{t("paymentReturn.viewBookings")}
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user