diff --git a/README.md b/README.md index 1500280..c3e47a4 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,12 @@ Location: `backend/` 2. Copy `backend/.env.example` to `backend/.env` and adjust values. 3. Run migrations and start the server. +### Demo data + +After migrations, you can seed demo data: + +- `python manage.py seed_demo` + ### Core API endpoints (current scaffold) - `POST /api/auth/register/` @@ -40,3 +46,7 @@ Location: `frontend/` 2. Run `npm run dev`. The dev server proxies `/api` to `http://localhost:8000`. + +## Project Notes + +- Known gaps and risks: `docs/risks.md` diff --git a/backend/.env.example b/backend/.env.example index 9060fb9..4ea9289 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,3 +6,13 @@ CORS_ALLOWED_ORIGINS=http://localhost:5173 OTP_PROVIDER=console OTP_EXPIRY_MINUTES=5 DEFAULT_CURRENCY=SAR +TWILIO_ACCOUNT_SID= +TWILIO_AUTH_TOKEN= +TWILIO_FROM_NUMBER= +TWILIO_WHATSAPP_FROM= +UNIFONIC_APP_SID= +UNIFONIC_SENDER_ID= +UNIFONIC_WHATSAPP_SENDER= +MOYASAR_SECRET_KEY= +MOYASAR_PUBLISHABLE_KEY= +MOYASAR_BASE_URL= diff --git a/backend/apps/accounts/migrations/0001_initial.py b/backend/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..0e040f5 --- /dev/null +++ b/backend/apps/accounts/migrations/0001_initial.py @@ -0,0 +1,93 @@ +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("password", models.CharField(max_length=128, verbose_name="password")), + ("last_login", models.DateTimeField(blank=True, null=True, verbose_name="last login")), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ("email", models.EmailField(max_length=254, unique=True)), + ("phone_number", models.CharField(blank=True, max_length=20, null=True, unique=True)), + ( + "role", + models.CharField( + choices=[ + ("admin", "Admin"), + ("manager", "Salon Manager"), + ("staff", "Staff"), + ("customer", "Customer"), + ], + default="customer", + max_length=20, + ), + ), + ("first_name", models.CharField(blank=True, max_length=150)), + ("last_name", models.CharField(blank=True, max_length=150)), + ("is_phone_verified", models.BooleanField(default=False)), + ("is_active", models.BooleanField(default=True)), + ("is_staff", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={"abstract": False}, + ), + migrations.CreateModel( + name="PhoneOTP", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("phone_number", models.CharField(max_length=20)), + ( + "channel", + models.CharField( + choices=[("sms", "SMS"), ("whatsapp", "WhatsApp")], + max_length=20, + ), + ), + ("provider", models.CharField(max_length=50)), + ("code_hash", models.CharField(max_length=128)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("expires_at", models.DateTimeField()), + ("verified_at", models.DateTimeField(blank=True, null=True)), + ], + ), + ] diff --git a/backend/apps/accounts/services/otp.py b/backend/apps/accounts/services/otp.py index 098bbaa..206fb7d 100644 --- a/backend/apps/accounts/services/otp.py +++ b/backend/apps/accounts/services/otp.py @@ -1,4 +1,5 @@ import logging +import os import secrets from dataclasses import dataclass @@ -33,8 +34,53 @@ class ConsoleOtpProvider(BaseOtpProvider): logger.info("OTP WhatsApp to %s: %s", to_number, message) +class TwilioOtpProvider(BaseOtpProvider): + def __init__(self) -> None: + self.account_sid = os.getenv("TWILIO_ACCOUNT_SID") + self.auth_token = os.getenv("TWILIO_AUTH_TOKEN") + self.from_number = os.getenv("TWILIO_FROM_NUMBER") + self.whatsapp_from = os.getenv("TWILIO_WHATSAPP_FROM") + + def _assert_config(self) -> None: + if not self.account_sid or not self.auth_token or not self.from_number: + raise ValueError("Twilio credentials are not configured") + + def send_sms(self, to_number: str, message: str) -> None: + self._assert_config() + raise NotImplementedError("Twilio SMS adapter not implemented yet") + + def send_whatsapp(self, to_number: str, message: str) -> None: + self._assert_config() + if not self.whatsapp_from: + raise ValueError("Twilio WhatsApp sender is not configured") + 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 = { "console": ConsoleOtpProvider, + "twilio": TwilioOtpProvider, + "unifonic": UnifonicOtpProvider, } diff --git a/backend/apps/bookings/migrations/0001_initial.py b/backend/apps/bookings/migrations/0001_initial.py new file mode 100644 index 0000000..045a3aa --- /dev/null +++ b/backend/apps/bookings/migrations/0001_initial.py @@ -0,0 +1,56 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("salons", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Booking", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("start_time", models.DateTimeField()), + ("end_time", models.DateTimeField()), + ( + "status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("confirmed", "Confirmed"), + ("cancelled", "Cancelled"), + ("completed", "Completed"), + ], + default="pending", + max_length=20, + ), + ), + ("price_amount", models.DecimalField(decimal_places=2, max_digits=10)), + ("currency", models.CharField(default="SAR", max_length=10)), + ("notes", models.TextField(blank=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "customer", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="bookings", to=settings.AUTH_USER_MODEL), + ), + ( + "salon", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="bookings", to="salons.salon"), + ), + ( + "service", + models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="salons.service"), + ), + ( + "staff", + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="salons.staffprofile"), + ), + ], + ), + ] diff --git a/backend/apps/payments/migrations/0001_initial.py b/backend/apps/payments/migrations/0001_initial.py new file mode 100644 index 0000000..b717a34 --- /dev/null +++ b/backend/apps/payments/migrations/0001_initial.py @@ -0,0 +1,58 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("bookings", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Payment", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "provider", + models.CharField( + choices=[ + ("hyperpay", "HyperPay"), + ("paytabs", "PayTabs"), + ("moyasar", "Moyasar"), + ("tap", "Tap"), + ("amazon_payment_services", "Amazon Payment Services"), + ("checkout", "Checkout.com"), + ("other", "Other"), + ], + max_length=50, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("created", "Created"), + ("authorized", "Authorized"), + ("captured", "Captured"), + ("failed", "Failed"), + ("refunded", "Refunded"), + ], + default="created", + max_length=20, + ), + ), + ("amount", models.DecimalField(decimal_places=2, max_digits=10)), + ("currency", models.CharField(default="SAR", max_length=10)), + ("external_id", models.CharField(blank=True, max_length=200)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "booking", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="payments", to="bookings.booking"), + ), + ], + ), + ] diff --git a/backend/apps/payments/services/__init__.py b/backend/apps/payments/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/payments/services/gateway.py b/backend/apps/payments/services/gateway.py new file mode 100644 index 0000000..9683a68 --- /dev/null +++ b/backend/apps/payments/services/gateway.py @@ -0,0 +1,43 @@ +import os +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class PaymentInitResult: + external_id: str + redirect_url: Optional[str] + + +class BasePaymentGateway: + def create_payment(self, amount: str, currency: str, description: str) -> PaymentInitResult: + raise NotImplementedError + + def capture_payment(self, external_id: str) -> None: + raise NotImplementedError + + def refund_payment(self, external_id: str, amount: Optional[str] = None) -> None: + raise NotImplementedError + + +class MoyasarGateway(BasePaymentGateway): + def __init__(self) -> None: + self.secret_key = os.getenv("MOYASAR_SECRET_KEY") + self.publishable_key = os.getenv("MOYASAR_PUBLISHABLE_KEY") + self.base_url = os.getenv("MOYASAR_BASE_URL", "https://api.moyasar.com") + + def _assert_config(self) -> None: + if not self.secret_key or not self.publishable_key: + raise ValueError("Moyasar credentials are not configured") + + def create_payment(self, amount: str, currency: str, description: str) -> PaymentInitResult: + self._assert_config() + raise NotImplementedError("Moyasar gateway integration not implemented yet") + + def capture_payment(self, external_id: str) -> None: + self._assert_config() + raise NotImplementedError("Moyasar capture not implemented yet") + + def refund_payment(self, external_id: str, amount: Optional[str] = None) -> None: + self._assert_config() + raise NotImplementedError("Moyasar refund not implemented yet") diff --git a/backend/apps/salons/management/__init__.py b/backend/apps/salons/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/salons/management/commands/__init__.py b/backend/apps/salons/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/salons/management/commands/seed_demo.py b/backend/apps/salons/management/commands/seed_demo.py new file mode 100644 index 0000000..bd70522 --- /dev/null +++ b/backend/apps/salons/management/commands/seed_demo.py @@ -0,0 +1,137 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand +from django.utils import timezone + +from apps.bookings.models import Booking, BookingStatus +from apps.payments.models import Payment, PaymentProvider, PaymentStatus +from apps.salons.models import Review, Salon, SalonPhoto, Service, StaffProfile + + +class Command(BaseCommand): + help = "Seed demo data for local development" + + def handle(self, *args, **options): + User = get_user_model() + + admin, _ = User.objects.get_or_create( + email="admin@example.com", + defaults={"role": "admin", "is_staff": True, "is_superuser": True}, + ) + if not admin.has_usable_password(): + admin.set_password("Admin123!") + admin.save(update_fields=["password"]) + + manager, _ = User.objects.get_or_create( + email="manager@example.com", + defaults={"role": "manager", "first_name": "Rania", "last_name": "Mansour"}, + ) + if not manager.has_usable_password(): + manager.set_password("Manager123!") + manager.save(update_fields=["password"]) + + staff_user, _ = User.objects.get_or_create( + email="stylist@example.com", + defaults={"role": "staff", "first_name": "Lina", "last_name": "Hassan"}, + ) + if not staff_user.has_usable_password(): + staff_user.set_password("Staff123!") + staff_user.save(update_fields=["password"]) + + customer, _ = User.objects.get_or_create( + email="customer@example.com", + defaults={"role": "customer", "first_name": "Yara", "last_name": "Saleh"}, + ) + if not customer.has_usable_password(): + customer.set_password("Customer123!") + customer.save(update_fields=["password"]) + + salon, _ = Salon.objects.get_or_create( + owner=manager, + name="Luxe Riyadh Studio", + defaults={ + "description": "Premium styling and beauty services in the heart of Riyadh.", + "address": "Olaya Street", + "city": "Riyadh", + "phone_number": "+966500000000", + "email": "hello@luxeriayadh.example", + "website": "https://luxeriayadh.example", + }, + ) + + SalonPhoto.objects.get_or_create( + salon=salon, + image_url="https://images.unsplash.com/photo-1522335789203-aabd1fc54bc9", + defaults={"alt_text": "Salon interior", "sort_order": 1}, + ) + + service_blowout, _ = Service.objects.get_or_create( + salon=salon, + name="Signature Blowout", + defaults={ + "description": "45-minute styling session with wash and blowdry.", + "duration_minutes": 45, + "price_amount": 180, + "currency": "SAR", + }, + ) + + service_color, _ = Service.objects.get_or_create( + salon=salon, + name="Color Refresh", + defaults={ + "description": "Full color refresh with toning and gloss.", + "duration_minutes": 90, + "price_amount": 420, + "currency": "SAR", + }, + ) + + staff_profile, _ = StaffProfile.objects.get_or_create( + user=staff_user, + salon=salon, + defaults={"title": "Senior Stylist", "bio": "Specialist in modern cuts and color."}, + ) + + Review.objects.get_or_create( + salon=salon, + customer=customer, + defaults={"rating": 5, "comment": "Loved the service and attention to detail."}, + ) + + salon.rating_avg = 5 + salon.rating_count = 1 + salon.save(update_fields=["rating_avg", "rating_count"]) + + start_time = timezone.now() + timedelta(days=1) + end_time = start_time + timedelta(minutes=service_blowout.duration_minutes) + + booking, _ = Booking.objects.get_or_create( + salon=salon, + customer=customer, + service=service_blowout, + staff=staff_profile, + start_time=start_time, + end_time=end_time, + defaults={ + "status": BookingStatus.CONFIRMED, + "price_amount": service_blowout.price_amount, + "currency": service_blowout.currency, + "notes": "Prefers natural volume.", + }, + ) + + Payment.objects.get_or_create( + booking=booking, + provider=PaymentProvider.MOYASAR, + defaults={ + "status": PaymentStatus.CREATED, + "amount": booking.price_amount, + "currency": booking.currency, + "external_id": "", + "metadata": {"note": "Demo payment record"}, + }, + ) + + self.stdout.write(self.style.SUCCESS("Demo data seeded.")) diff --git a/backend/apps/salons/migrations/0001_initial.py b/backend/apps/salons/migrations/0001_initial.py new file mode 100644 index 0000000..5e752ad --- /dev/null +++ b/backend/apps/salons/migrations/0001_initial.py @@ -0,0 +1,99 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Salon", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=200)), + ("description", models.TextField(blank=True)), + ("address", models.CharField(max_length=255)), + ("city", models.CharField(max_length=100)), + ("latitude", models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ("longitude", models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ("phone_number", models.CharField(blank=True, max_length=20)), + ("email", models.EmailField(blank=True, max_length=254)), + ("website", models.URLField(blank=True)), + ("rating_avg", models.DecimalField(decimal_places=2, default=0, max_digits=3)), + ("rating_count", models.PositiveIntegerField(default=0)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "owner", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="owned_salons", to=settings.AUTH_USER_MODEL), + ), + ], + ), + migrations.CreateModel( + name="SalonPhoto", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("image_url", models.URLField()), + ("alt_text", models.CharField(blank=True, max_length=200)), + ("sort_order", models.PositiveIntegerField(default=0)), + ( + "salon", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="photos", to="salons.salon"), + ), + ], + ), + migrations.CreateModel( + name="Service", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=200)), + ("description", models.TextField(blank=True)), + ("duration_minutes", models.PositiveIntegerField()), + ("price_amount", models.DecimalField(decimal_places=2, max_digits=10)), + ("currency", models.CharField(default="SAR", max_length=10)), + ("is_active", models.BooleanField(default=True)), + ( + "salon", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="services", to="salons.salon"), + ), + ], + ), + migrations.CreateModel( + name="StaffProfile", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("title", models.CharField(blank=True, max_length=200)), + ("bio", models.TextField(blank=True)), + ("is_active", models.BooleanField(default=True)), + ( + "salon", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="staff", to="salons.salon"), + ), + ( + "user", + models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ], + ), + migrations.CreateModel( + name="Review", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("rating", models.PositiveSmallIntegerField()), + ("comment", models.TextField(blank=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "customer", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="reviews", to=settings.AUTH_USER_MODEL), + ), + ( + "salon", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="reviews", to="salons.salon"), + ), + ], + ), + ] diff --git a/backend/salon_api/settings.py b/backend/salon_api/settings.py index c56d093..b4d7c4e 100644 --- a/backend/salon_api/settings.py +++ b/backend/salon_api/settings.py @@ -3,8 +3,12 @@ from pathlib import Path from datetime import timedelta from urllib.parse import urlparse +from dotenv import load_dotenv + BASE_DIR = Path(__file__).resolve().parent.parent +load_dotenv(BASE_DIR / ".env") + SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "unsafe-dev-key") DEBUG = os.getenv("DJANGO_DEBUG", "0") == "1" ALLOWED_HOSTS = [h.strip() for h in os.getenv("DJANGO_ALLOWED_HOSTS", "").split(",") if h.strip()] diff --git a/docs/risks.md b/docs/risks.md new file mode 100644 index 0000000..818bfc1 --- /dev/null +++ b/docs/risks.md @@ -0,0 +1,29 @@ +# Risks And Gaps + +This file tracks known gaps and risks to address in future iterations. + +## Security And Auth +- Phone auth only verifies existing users. Add phone-first sign-up flow. +- OTP rate limiting, resend cooldown, and abuse protections are missing. +- Phone normalization/validation (E.164) not implemented. +- Social login is a placeholder. + +## Booking Integrity +- No availability checks or overlap prevention for staff/salon schedules. +- No timezone handling or business hours enforcement. +- No cancellation rules or refund logic. + +## Payments +- Payment integration is not implemented. Current API only stores records. +- Webhook handling and payment status reconciliation missing. +- Idempotency handling for payment creation missing. + +## Data And UX +- Ratings are not recalculated from reviews. +- No image upload or storage strategy for photos. +- No notifications (email/SMS) beyond OTP scaffolding. + +## Ops And Compliance +- No audit logs for admin actions. +- No multi-tenant isolation or data export tooling. +- No GDPR/PDPL data retention policies defined.