Summary of what I changed and added:

Added initial migrations for accounts, salons, bookings, payments.
0001_initial.py
0001_initial.py
0001_initial.py
0001_initial.py
Added demo seeding command.
seed_demo.py
OTP provider abstraction now includes Twilio + Unifonic adapter scaffolds (not implemented yet, they raise).
otp.py
Moyasar gateway scaffold added (not implemented yet, raises).
gateway.py
Added .env loading in settings and expanded .env.example for OTP and Moyasar.
settings.py
.env.example
Captured current gaps/risks in a tracked doc as requested.
risks.md
Updated README with seeding guidance and risk doc pointer.
README.md
This commit is contained in:
2026-02-27 15:10:30 +03:00
parent fc06bb6fcd
commit dc68ecfe4c
14 changed files with 585 additions and 0 deletions
+10
View File
@@ -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=
@@ -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)),
],
),
]
+46
View File
@@ -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,
}
@@ -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"),
),
],
),
]
@@ -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"),
),
],
),
]
+43
View File
@@ -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")
@@ -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."))
@@ -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"),
),
],
),
]
+4
View File
@@ -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()]