Initial commit
This commit is contained in:
+19
@@ -0,0 +1,19 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.sqlite3
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Django
|
||||||
|
/staticfiles/
|
||||||
|
/media/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Salon Booking Platform
|
||||||
|
|
||||||
|
Scaffolded Django + React starter for a salon booking platform.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
Location: `backend/`
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. Create a virtualenv and install dependencies.
|
||||||
|
2. Copy `backend/.env.example` to `backend/.env` and adjust values.
|
||||||
|
3. Run migrations and start the server.
|
||||||
|
|
||||||
|
### 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/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`.
|
||||||
|
|
||||||
|
The dev server proxies `/api` to `http://localhost:8000`.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
DJANGO_SECRET_KEY=changeme
|
||||||
|
DJANGO_DEBUG=1
|
||||||
|
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5432/salon
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:5173
|
||||||
|
OTP_PROVIDER=console
|
||||||
|
OTP_EXPIRY_MINUTES=5
|
||||||
|
DEFAULT_CURRENCY=SAR
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
|
||||||
|
|
||||||
|
from apps.accounts.models import PhoneOTP, User
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(User)
|
||||||
|
class UserAdmin(DjangoUserAdmin):
|
||||||
|
model = User
|
||||||
|
list_display = ("email", "phone_number", "role", "is_staff", "is_phone_verified")
|
||||||
|
list_filter = ("role", "is_staff", "is_phone_verified")
|
||||||
|
ordering = ("email",)
|
||||||
|
search_fields = ("email", "phone_number")
|
||||||
|
fieldsets = (
|
||||||
|
(None, {"fields": ("email", "password")}),
|
||||||
|
("Personal", {"fields": ("first_name", "last_name", "phone_number")}),
|
||||||
|
("Roles", {"fields": ("role", "is_phone_verified")}),
|
||||||
|
("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
|
||||||
|
("Dates", {"fields": ("last_login",)}),
|
||||||
|
)
|
||||||
|
add_fieldsets = (
|
||||||
|
(None, {
|
||||||
|
"classes": ("wide",),
|
||||||
|
"fields": ("email", "password1", "password2", "role"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PhoneOTP)
|
||||||
|
class PhoneOTPAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("phone_number", "channel", "provider", "created_at", "expires_at", "verified_at")
|
||||||
|
list_filter = ("channel", "provider")
|
||||||
|
search_fields = ("phone_number",)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AccountsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.accounts"
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(models.TextChoices):
|
||||||
|
ADMIN = "admin", "Admin"
|
||||||
|
MANAGER = "manager", "Salon Manager"
|
||||||
|
STAFF = "staff", "Staff"
|
||||||
|
CUSTOMER = "customer", "Customer"
|
||||||
|
|
||||||
|
|
||||||
|
class UserManager(BaseUserManager):
|
||||||
|
def create_user(self, email, password=None, **extra_fields):
|
||||||
|
if not email:
|
||||||
|
raise ValueError("Email is required")
|
||||||
|
email = self.normalize_email(email)
|
||||||
|
user = self.model(email=email, **extra_fields)
|
||||||
|
user.set_password(password)
|
||||||
|
user.save(using=self._db)
|
||||||
|
return user
|
||||||
|
|
||||||
|
def create_superuser(self, email, password=None, **extra_fields):
|
||||||
|
extra_fields.setdefault("is_staff", True)
|
||||||
|
extra_fields.setdefault("is_superuser", True)
|
||||||
|
extra_fields.setdefault("role", UserRole.ADMIN)
|
||||||
|
if extra_fields.get("is_staff") is not True:
|
||||||
|
raise ValueError("Superuser must have is_staff=True")
|
||||||
|
if extra_fields.get("is_superuser") is not True:
|
||||||
|
raise ValueError("Superuser must have is_superuser=True")
|
||||||
|
return self.create_user(email, password, **extra_fields)
|
||||||
|
|
||||||
|
|
||||||
|
class User(AbstractBaseUser, PermissionsMixin):
|
||||||
|
email = models.EmailField(unique=True)
|
||||||
|
phone_number = models.CharField(max_length=20, unique=True, null=True, blank=True)
|
||||||
|
role = models.CharField(max_length=20, choices=UserRole.choices, default=UserRole.CUSTOMER)
|
||||||
|
first_name = models.CharField(max_length=150, blank=True)
|
||||||
|
last_name = models.CharField(max_length=150, blank=True)
|
||||||
|
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)
|
||||||
|
|
||||||
|
objects = UserManager()
|
||||||
|
|
||||||
|
USERNAME_FIELD = "email"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.email
|
||||||
|
|
||||||
|
|
||||||
|
class OtpChannel(models.TextChoices):
|
||||||
|
SMS = "sms", "SMS"
|
||||||
|
WHATSAPP = "whatsapp", "WhatsApp"
|
||||||
|
|
||||||
|
|
||||||
|
class PhoneOTP(models.Model):
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
phone_number = models.CharField(max_length=20)
|
||||||
|
channel = models.CharField(max_length=20, choices=OtpChannel.choices)
|
||||||
|
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(null=True, blank=True)
|
||||||
|
|
||||||
|
def is_expired(self):
|
||||||
|
return timezone.now() >= self.expires_at
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def expiry_at(cls):
|
||||||
|
return timezone.now() + timedelta(minutes=settings.OTP_EXPIRY_MINUTES)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.accounts.models import OtpChannel, PhoneOTP
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ["id", "email", "phone_number", "first_name", "last_name", "role", "is_phone_verified"]
|
||||||
|
read_only_fields = ["id", "role", "is_phone_verified"]
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterSerializer(serializers.ModelSerializer):
|
||||||
|
password = serializers.CharField(write_only=True, min_length=8)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ["email", "password", "phone_number", "first_name", "last_name"]
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
return User.objects.create_user(**validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
class OTPRequestSerializer(serializers.Serializer):
|
||||||
|
phone_number = serializers.CharField(max_length=20)
|
||||||
|
channel = serializers.ChoiceField(choices=OtpChannel.choices)
|
||||||
|
|
||||||
|
|
||||||
|
class OTPVerifySerializer(serializers.Serializer):
|
||||||
|
request_id = serializers.UUIDField()
|
||||||
|
code = serializers.CharField(max_length=6)
|
||||||
|
|
||||||
|
|
||||||
|
class OTPStatusSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = PhoneOTP
|
||||||
|
fields = ["id", "phone_number", "channel", "provider", "created_at", "expires_at", "verified_at"]
|
||||||
|
read_only_fields = fields
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.hashers import check_password, make_password
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.accounts.models import OtpChannel, PhoneOTP
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OtpSendResult:
|
||||||
|
request_id: str
|
||||||
|
expires_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class BaseOtpProvider:
|
||||||
|
def send_sms(self, to_number: str, message: str) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def send_whatsapp(self, to_number: str, message: str) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class ConsoleOtpProvider(BaseOtpProvider):
|
||||||
|
def send_sms(self, to_number: str, message: str) -> None:
|
||||||
|
logger.info("OTP SMS to %s: %s", to_number, message)
|
||||||
|
|
||||||
|
def send_whatsapp(self, to_number: str, message: str) -> None:
|
||||||
|
logger.info("OTP WhatsApp to %s: %s", to_number, message)
|
||||||
|
|
||||||
|
|
||||||
|
PROVIDERS = {
|
||||||
|
"console": ConsoleOtpProvider,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider() -> BaseOtpProvider:
|
||||||
|
provider_key = settings.OTP_PROVIDER
|
||||||
|
provider_cls = PROVIDERS.get(provider_key)
|
||||||
|
if not provider_cls:
|
||||||
|
raise ValueError(f"Unknown OTP provider: {provider_key}")
|
||||||
|
return provider_cls()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_code(length: int = 6) -> str:
|
||||||
|
digits = "0123456789"
|
||||||
|
return "".join(secrets.choice(digits) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
|
def create_and_send_otp(phone_number: str, channel: str) -> OtpSendResult:
|
||||||
|
provider = get_provider()
|
||||||
|
code = generate_code()
|
||||||
|
otp = PhoneOTP.objects.create(
|
||||||
|
phone_number=phone_number,
|
||||||
|
channel=channel,
|
||||||
|
provider=settings.OTP_PROVIDER,
|
||||||
|
code_hash=make_password(code),
|
||||||
|
expires_at=PhoneOTP.expiry_at(),
|
||||||
|
)
|
||||||
|
|
||||||
|
message = f"Your verification code is {code}. It expires in {settings.OTP_EXPIRY_MINUTES} minutes."
|
||||||
|
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())
|
||||||
|
|
||||||
|
|
||||||
|
def verify_otp(otp: PhoneOTP, code: str) -> bool:
|
||||||
|
if otp.verified_at or otp.is_expired():
|
||||||
|
return False
|
||||||
|
if not check_password(code, otp.code_hash):
|
||||||
|
return False
|
||||||
|
otp.verified_at = timezone.now()
|
||||||
|
otp.save(update_fields=["verified_at"])
|
||||||
|
return True
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||||
|
|
||||||
|
from apps.accounts.views import MeView, OTPRequestView, OTPVerifyView, RegisterView, SocialLoginPlaceholderView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("register/", RegisterView.as_view(), name="register"),
|
||||||
|
path("me/", MeView.as_view(), name="me"),
|
||||||
|
path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
|
||||||
|
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
|
||||||
|
path("otp/request/", OTPRequestView.as_view(), name="otp_request"),
|
||||||
|
path("otp/verify/", OTPVerifyView.as_view(), name="otp_verify"),
|
||||||
|
path("social/<str:provider>/", SocialLoginPlaceholderView.as_view(), name="social_login"),
|
||||||
|
]
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from rest_framework import generics, permissions, status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from apps.accounts.models import PhoneOTP
|
||||||
|
from apps.accounts.serializers import (
|
||||||
|
OTPRequestSerializer,
|
||||||
|
OTPVerifySerializer,
|
||||||
|
RegisterSerializer,
|
||||||
|
UserSerializer,
|
||||||
|
)
|
||||||
|
from apps.accounts.services.otp import create_and_send_otp, verify_otp
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterView(generics.CreateAPIView):
|
||||||
|
serializer_class = RegisterSerializer
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
|
||||||
|
class MeView(generics.RetrieveUpdateAPIView):
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
return self.request.user
|
||||||
|
|
||||||
|
|
||||||
|
class OTPRequestView(APIView):
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
serializer = OTPRequestSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
data = serializer.validated_data
|
||||||
|
result = create_and_send_otp(data["phone_number"], data["channel"])
|
||||||
|
return Response(
|
||||||
|
{"request_id": result.request_id, "expires_at": result.expires_at},
|
||||||
|
status=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OTPVerifyView(APIView):
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
serializer = OTPVerifySerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
data = serializer.validated_data
|
||||||
|
otp = get_object_or_404(PhoneOTP, id=data["request_id"])
|
||||||
|
if not verify_otp(otp, data["code"]):
|
||||||
|
return Response({"detail": "Invalid or expired code"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
user = User.objects.filter(phone_number=otp.phone_number).first()
|
||||||
|
if user and not user.is_phone_verified:
|
||||||
|
user.is_phone_verified = True
|
||||||
|
user.save(update_fields=["is_phone_verified"])
|
||||||
|
|
||||||
|
return Response({"detail": "Phone verified"}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class SocialLoginPlaceholderView(APIView):
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def post(self, request, provider):
|
||||||
|
return Response(
|
||||||
|
{"detail": "Social login not configured yet. Add OAuth provider config."},
|
||||||
|
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.bookings.models import Booking
|
||||||
|
|
||||||
|
admin.site.register(Booking)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BookingsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.bookings"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from apps.salons.models import Salon, Service, StaffProfile
|
||||||
|
|
||||||
|
|
||||||
|
class BookingStatus(models.TextChoices):
|
||||||
|
PENDING = "pending", "Pending"
|
||||||
|
CONFIRMED = "confirmed", "Confirmed"
|
||||||
|
CANCELLED = "cancelled", "Cancelled"
|
||||||
|
COMPLETED = "completed", "Completed"
|
||||||
|
|
||||||
|
|
||||||
|
class Booking(models.Model):
|
||||||
|
salon = models.ForeignKey(Salon, on_delete=models.CASCADE, related_name="bookings")
|
||||||
|
customer = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="bookings",
|
||||||
|
)
|
||||||
|
service = models.ForeignKey(Service, on_delete=models.PROTECT)
|
||||||
|
staff = models.ForeignKey(StaffProfile, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
start_time = models.DateTimeField()
|
||||||
|
end_time = models.DateTimeField()
|
||||||
|
status = models.CharField(max_length=20, choices=BookingStatus.choices, default=BookingStatus.PENDING)
|
||||||
|
price_amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
currency = models.CharField(max_length=10, default=getattr(settings, "DEFAULT_CURRENCY", "SAR"))
|
||||||
|
notes = models.TextField(blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.customer.email} - {self.service.name}"
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.bookings.models import Booking
|
||||||
|
from apps.salons.models import Service, StaffProfile
|
||||||
|
|
||||||
|
|
||||||
|
class BookingSerializer(serializers.ModelSerializer):
|
||||||
|
salon_name = serializers.CharField(source="salon.name", read_only=True)
|
||||||
|
service_name = serializers.CharField(source="service.name", read_only=True)
|
||||||
|
staff_name = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Booking
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"salon",
|
||||||
|
"salon_name",
|
||||||
|
"service",
|
||||||
|
"service_name",
|
||||||
|
"staff",
|
||||||
|
"staff_name",
|
||||||
|
"start_time",
|
||||||
|
"end_time",
|
||||||
|
"status",
|
||||||
|
"price_amount",
|
||||||
|
"currency",
|
||||||
|
"notes",
|
||||||
|
"created_at",
|
||||||
|
]
|
||||||
|
read_only_fields = ["id", "salon", "status", "price_amount", "currency", "created_at"]
|
||||||
|
|
||||||
|
def get_staff_name(self, obj):
|
||||||
|
if not obj.staff:
|
||||||
|
return None
|
||||||
|
first = obj.staff.user.first_name or ""
|
||||||
|
last = obj.staff.user.last_name or ""
|
||||||
|
return (first + " " + last).strip() or obj.staff.user.email
|
||||||
|
|
||||||
|
|
||||||
|
class BookingCreateSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Booking
|
||||||
|
fields = ["service", "staff", "start_time", "end_time", "notes"]
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
service: Service = attrs["service"]
|
||||||
|
staff = attrs.get("staff")
|
||||||
|
if staff and staff.salon_id != service.salon_id:
|
||||||
|
raise serializers.ValidationError("Selected staff does not belong to this salon")
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
request = self.context["request"]
|
||||||
|
service = validated_data["service"]
|
||||||
|
return Booking.objects.create(
|
||||||
|
salon=service.salon,
|
||||||
|
customer=request.user,
|
||||||
|
service=service,
|
||||||
|
staff=validated_data.get("staff"),
|
||||||
|
start_time=validated_data["start_time"],
|
||||||
|
end_time=validated_data["end_time"],
|
||||||
|
notes=validated_data.get("notes", ""),
|
||||||
|
price_amount=service.price_amount,
|
||||||
|
currency=service.currency,
|
||||||
|
)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from django.urls import include, path
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from apps.bookings.views import BookingViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"", BookingViewSet, basename="booking")
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", include(router.urls)),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from rest_framework import permissions, viewsets
|
||||||
|
|
||||||
|
from apps.bookings.models import Booking
|
||||||
|
from apps.bookings.serializers import BookingCreateSerializer, BookingSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class BookingViewSet(viewsets.ModelViewSet):
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
user = self.request.user
|
||||||
|
if getattr(user, "is_superuser", False) or user.role == "admin":
|
||||||
|
return Booking.objects.all().order_by("-created_at")
|
||||||
|
if user.role == "manager":
|
||||||
|
return Booking.objects.filter(salon__owner=user).order_by("-created_at")
|
||||||
|
if user.role == "staff":
|
||||||
|
return Booking.objects.filter(staff__user=user).order_by("-created_at")
|
||||||
|
return Booking.objects.filter(customer=user).order_by("-created_at")
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == "create":
|
||||||
|
return BookingCreateSerializer
|
||||||
|
return BookingSerializer
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.payments.models import Payment
|
||||||
|
|
||||||
|
admin.site.register(Payment)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.payments"
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from apps.bookings.models import Booking
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentProvider(models.TextChoices):
|
||||||
|
HYPERPAY = "hyperpay", "HyperPay"
|
||||||
|
PAYTABS = "paytabs", "PayTabs"
|
||||||
|
MOYASAR = "moyasar", "Moyasar"
|
||||||
|
TAP = "tap", "Tap"
|
||||||
|
AMAZON_PAYMENT_SERVICES = "amazon_payment_services", "Amazon Payment Services"
|
||||||
|
CHECKOUT = "checkout", "Checkout.com"
|
||||||
|
OTHER = "other", "Other"
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentStatus(models.TextChoices):
|
||||||
|
CREATED = "created", "Created"
|
||||||
|
AUTHORIZED = "authorized", "Authorized"
|
||||||
|
CAPTURED = "captured", "Captured"
|
||||||
|
FAILED = "failed", "Failed"
|
||||||
|
REFUNDED = "refunded", "Refunded"
|
||||||
|
|
||||||
|
|
||||||
|
class Payment(models.Model):
|
||||||
|
booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name="payments")
|
||||||
|
provider = models.CharField(max_length=50, choices=PaymentProvider.choices)
|
||||||
|
status = models.CharField(max_length=20, choices=PaymentStatus.choices, default=PaymentStatus.CREATED)
|
||||||
|
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
currency = models.CharField(max_length=10, default=getattr(settings, "DEFAULT_CURRENCY", "SAR"))
|
||||||
|
external_id = models.CharField(max_length=200, blank=True)
|
||||||
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.provider} {self.amount} {self.currency}"
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.bookings.models import Booking
|
||||||
|
from apps.payments.models import Payment, PaymentProvider, PaymentStatus
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentSerializer(serializers.ModelSerializer):
|
||||||
|
booking_id = serializers.IntegerField(source="booking.id", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Payment
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"booking_id",
|
||||||
|
"provider",
|
||||||
|
"status",
|
||||||
|
"amount",
|
||||||
|
"currency",
|
||||||
|
"external_id",
|
||||||
|
"metadata",
|
||||||
|
"created_at",
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentCreateSerializer(serializers.ModelSerializer):
|
||||||
|
booking_id = serializers.IntegerField(write_only=True)
|
||||||
|
provider = serializers.ChoiceField(choices=PaymentProvider.choices)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Payment
|
||||||
|
fields = ["booking_id", "provider"]
|
||||||
|
|
||||||
|
def validate_booking_id(self, value):
|
||||||
|
if not Booking.objects.filter(id=value).exists():
|
||||||
|
raise serializers.ValidationError("Booking not found")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
booking = Booking.objects.get(id=validated_data["booking_id"])
|
||||||
|
return Payment.objects.create(
|
||||||
|
booking=booking,
|
||||||
|
provider=validated_data["provider"],
|
||||||
|
status=PaymentStatus.CREATED,
|
||||||
|
amount=booking.price_amount,
|
||||||
|
currency=booking.currency,
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from django.urls import include, path
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from apps.payments.views import PaymentViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"", PaymentViewSet, basename="payment")
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", include(router.urls)),
|
||||||
|
]
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from rest_framework import permissions, status, viewsets
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from apps.bookings.models import Booking
|
||||||
|
from apps.payments.models import Payment
|
||||||
|
from apps.payments.serializers import PaymentCreateSerializer, PaymentSerializer
|
||||||
|
|
||||||
|
|
||||||
|
def user_can_access_booking(user, booking: Booking) -> bool:
|
||||||
|
if getattr(user, "is_superuser", False) or user.role == "admin":
|
||||||
|
return True
|
||||||
|
if user.role == "manager":
|
||||||
|
return booking.salon.owner_id == user.id
|
||||||
|
if user.role == "staff":
|
||||||
|
return booking.staff_id and booking.staff.user_id == user.id
|
||||||
|
return booking.customer_id == user.id
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentViewSet(viewsets.ModelViewSet):
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
user = self.request.user
|
||||||
|
if getattr(user, "is_superuser", False) or user.role == "admin":
|
||||||
|
return Payment.objects.all().order_by("-created_at")
|
||||||
|
if user.role == "manager":
|
||||||
|
return Payment.objects.filter(booking__salon__owner=user).order_by("-created_at")
|
||||||
|
if user.role == "staff":
|
||||||
|
return Payment.objects.filter(booking__staff__user=user).order_by("-created_at")
|
||||||
|
return Payment.objects.filter(booking__customer=user).order_by("-created_at")
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == "create":
|
||||||
|
return PaymentCreateSerializer
|
||||||
|
return PaymentSerializer
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
booking = Booking.objects.get(id=serializer.validated_data["booking_id"])
|
||||||
|
if not user_can_access_booking(request.user, booking):
|
||||||
|
return Response({"detail": "Not allowed"}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
payment = serializer.save()
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"detail": "Payment record created. Provider integration pending.",
|
||||||
|
"payment_id": payment.id,
|
||||||
|
"amount": str(payment.amount),
|
||||||
|
"currency": payment.currency,
|
||||||
|
"status": payment.status,
|
||||||
|
},
|
||||||
|
status=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.salons.models import Review, Salon, SalonPhoto, Service, StaffProfile
|
||||||
|
|
||||||
|
admin.site.register(Salon)
|
||||||
|
admin.site.register(SalonPhoto)
|
||||||
|
admin.site.register(Service)
|
||||||
|
admin.site.register(StaffProfile)
|
||||||
|
admin.site.register(Review)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class SalonsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.salons"
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Salon(models.Model):
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="owned_salons",
|
||||||
|
)
|
||||||
|
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(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||||
|
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||||
|
phone_number = models.CharField(max_length=20, blank=True)
|
||||||
|
email = models.EmailField(blank=True)
|
||||||
|
website = models.URLField(blank=True)
|
||||||
|
rating_avg = models.DecimalField(max_digits=3, decimal_places=2, default=0)
|
||||||
|
rating_count = models.PositiveIntegerField(default=0)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class SalonPhoto(models.Model):
|
||||||
|
salon = models.ForeignKey(Salon, on_delete=models.CASCADE, related_name="photos")
|
||||||
|
image_url = models.URLField()
|
||||||
|
alt_text = models.CharField(max_length=200, blank=True)
|
||||||
|
sort_order = models.PositiveIntegerField(default=0)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.salon.name} photo"
|
||||||
|
|
||||||
|
|
||||||
|
class Service(models.Model):
|
||||||
|
salon = models.ForeignKey(Salon, on_delete=models.CASCADE, related_name="services")
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
duration_minutes = models.PositiveIntegerField()
|
||||||
|
price_amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
currency = models.CharField(max_length=10, default=getattr(settings, "DEFAULT_CURRENCY", "SAR"))
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} - {self.salon.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class StaffProfile(models.Model):
|
||||||
|
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||||
|
salon = models.ForeignKey(Salon, on_delete=models.CASCADE, related_name="staff")
|
||||||
|
title = models.CharField(max_length=200, blank=True)
|
||||||
|
bio = models.TextField(blank=True)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.email} - {self.salon.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class Review(models.Model):
|
||||||
|
salon = models.ForeignKey(Salon, on_delete=models.CASCADE, related_name="reviews")
|
||||||
|
customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reviews")
|
||||||
|
rating = models.PositiveSmallIntegerField()
|
||||||
|
comment = models.TextField(blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Review {self.rating} for {self.salon.name}"
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.salons.models import Review, Salon, SalonPhoto, Service, StaffProfile
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class SalonPhotoSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = SalonPhoto
|
||||||
|
fields = ["id", "image_url", "alt_text", "sort_order"]
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Service
|
||||||
|
fields = ["id", "name", "description", "duration_minutes", "price_amount", "currency", "is_active"]
|
||||||
|
|
||||||
|
|
||||||
|
class StaffSerializer(serializers.ModelSerializer):
|
||||||
|
name = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = StaffProfile
|
||||||
|
fields = ["id", "name", "title", "bio", "is_active"]
|
||||||
|
|
||||||
|
def get_name(self, obj):
|
||||||
|
first = obj.user.first_name or ""
|
||||||
|
last = obj.user.last_name or ""
|
||||||
|
return (first + " " + last).strip() or obj.user.email
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewSerializer(serializers.ModelSerializer):
|
||||||
|
customer_name = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Review
|
||||||
|
fields = ["id", "rating", "comment", "created_at", "customer_name"]
|
||||||
|
|
||||||
|
def get_customer_name(self, obj):
|
||||||
|
first = obj.customer.first_name or ""
|
||||||
|
last = obj.customer.last_name or ""
|
||||||
|
return (first + " " + last).strip() or obj.customer.email
|
||||||
|
|
||||||
|
|
||||||
|
class SalonSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Salon
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"address",
|
||||||
|
"city",
|
||||||
|
"phone_number",
|
||||||
|
"email",
|
||||||
|
"website",
|
||||||
|
"rating_avg",
|
||||||
|
"rating_count",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SalonDetailSerializer(SalonSerializer):
|
||||||
|
photos = SalonPhotoSerializer(many=True, read_only=True)
|
||||||
|
services = ServiceSerializer(many=True, read_only=True)
|
||||||
|
staff = StaffSerializer(many=True, read_only=True)
|
||||||
|
reviews = ReviewSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta(SalonSerializer.Meta):
|
||||||
|
fields = SalonSerializer.Meta.fields + ["photos", "services", "staff", "reviews"]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from django.urls import include, path
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from apps.salons.views import SalonReviewsView, SalonServicesView, SalonStaffView, SalonViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"", SalonViewSet, basename="salon")
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", include(router.urls)),
|
||||||
|
path("<int:salon_id>/services/", SalonServicesView.as_view(), name="salon_services"),
|
||||||
|
path("<int:salon_id>/staff/", SalonStaffView.as_view(), name="salon_staff"),
|
||||||
|
path("<int:salon_id>/reviews/", SalonReviewsView.as_view(), name="salon_reviews"),
|
||||||
|
]
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from django.db.models import Q
|
||||||
|
from rest_framework import generics, permissions, viewsets
|
||||||
|
|
||||||
|
from apps.salons.models import Review, Salon, Service, StaffProfile
|
||||||
|
from apps.salons.serializers import (
|
||||||
|
ReviewSerializer,
|
||||||
|
SalonDetailSerializer,
|
||||||
|
SalonSerializer,
|
||||||
|
ServiceSerializer,
|
||||||
|
StaffSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SalonViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = Salon.objects.all()
|
||||||
|
city = self.request.query_params.get("city")
|
||||||
|
query = self.request.query_params.get("q")
|
||||||
|
service = self.request.query_params.get("service")
|
||||||
|
if city:
|
||||||
|
queryset = queryset.filter(city__iexact=city)
|
||||||
|
if query:
|
||||||
|
queryset = queryset.filter(Q(name__icontains=query) | Q(description__icontains=query))
|
||||||
|
if service:
|
||||||
|
queryset = queryset.filter(services__name__icontains=service)
|
||||||
|
return queryset.distinct()
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == "retrieve":
|
||||||
|
return SalonDetailSerializer
|
||||||
|
return SalonSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class SalonServicesView(generics.ListAPIView):
|
||||||
|
serializer_class = ServiceSerializer
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Service.objects.filter(salon_id=self.kwargs["salon_id"], is_active=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SalonStaffView(generics.ListAPIView):
|
||||||
|
serializer_class = StaffSerializer
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return StaffProfile.objects.filter(salon_id=self.kwargs["salon_id"], is_active=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SalonReviewsView(generics.ListAPIView):
|
||||||
|
serializer_class = ReviewSerializer
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Review.objects.filter(salon_id=self.kwargs["salon_id"]).order_by("-created_at")
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "salon_api.settings")
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Django>=5.0
|
||||||
|
djangorestframework>=3.14
|
||||||
|
djangorestframework-simplejwt>=5.3
|
||||||
|
django-cors-headers>=4.3
|
||||||
|
psycopg[binary]>=3.1
|
||||||
|
python-dotenv>=1.0
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import os
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "salon_api.settings")
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import timedelta
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
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()]
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"django.contrib.admin",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"django.contrib.staticfiles",
|
||||||
|
"rest_framework",
|
||||||
|
"corsheaders",
|
||||||
|
"apps.accounts",
|
||||||
|
"apps.salons",
|
||||||
|
"apps.bookings",
|
||||||
|
"apps.payments",
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = "salon_api.urls"
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.debug",
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = "salon_api.wsgi.application"
|
||||||
|
ASGI_APPLICATION = "salon_api.asgi.application"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_database_url(database_url: str):
|
||||||
|
parsed = urlparse(database_url)
|
||||||
|
if parsed.scheme not in {"postgres", "postgresql"}:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"ENGINE": "django.db.backends.postgresql",
|
||||||
|
"NAME": parsed.path.lstrip("/"),
|
||||||
|
"USER": parsed.username,
|
||||||
|
"PASSWORD": parsed.password,
|
||||||
|
"HOST": parsed.hostname,
|
||||||
|
"PORT": parsed.port or "5432",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||||
|
if DATABASE_URL:
|
||||||
|
parsed_db = parse_database_url(DATABASE_URL)
|
||||||
|
else:
|
||||||
|
parsed_db = None
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default": parsed_db
|
||||||
|
or {
|
||||||
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
|
"NAME": BASE_DIR / "db.sqlite3",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||||
|
]
|
||||||
|
|
||||||
|
LANGUAGE_CODE = "en-us"
|
||||||
|
TIME_ZONE = "Asia/Riyadh"
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
STATIC_URL = "static/"
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
|
AUTH_USER_MODEL = "accounts.User"
|
||||||
|
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||||
|
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||||
|
),
|
||||||
|
"DEFAULT_PERMISSION_CLASSES": (
|
||||||
|
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
SIMPLE_JWT = {
|
||||||
|
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=30),
|
||||||
|
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
|
||||||
|
"AUTH_HEADER_TYPES": ("Bearer",),
|
||||||
|
}
|
||||||
|
|
||||||
|
CORS_ALLOWED_ORIGINS = [
|
||||||
|
origin.strip()
|
||||||
|
for origin in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",")
|
||||||
|
if origin.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
OTP_PROVIDER = os.getenv("OTP_PROVIDER", "console")
|
||||||
|
OTP_EXPIRY_MINUTES = int(os.getenv("OTP_EXPIRY_MINUTES", "5"))
|
||||||
|
DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "SAR")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import include, path
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("admin/", admin.site.urls),
|
||||||
|
path("api/auth/", include("apps.accounts.urls")),
|
||||||
|
path("api/salons/", include("apps.salons.urls")),
|
||||||
|
path("api/bookings/", include("apps.bookings.urls")),
|
||||||
|
path("api/payments/", include("apps.payments.urls")),
|
||||||
|
]
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import os
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "salon_api.settings")
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Salon Booking</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "salon-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { apiGet } from "./api/client";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [salons, setSalons] = useState([]);
|
||||||
|
const [query, setQuery] = 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 (error) {
|
||||||
|
if (!ignore) {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
ignore = true;
|
||||||
|
};
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<header className="hero">
|
||||||
|
<p className="eyebrow">Salon Booking Platform</p>
|
||||||
|
<h1>Find, compare, and book top salons near you.</h1>
|
||||||
|
<p className="subtitle">
|
||||||
|
Search by city or service, compare pricing, and lock in your slot in seconds.
|
||||||
|
</p>
|
||||||
|
<div className="search">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by salon or service"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="results">
|
||||||
|
<h2>Salons</h2>
|
||||||
|
{status === "loading" && <p>Loading salons...</p>}
|
||||||
|
{status === "error" && (
|
||||||
|
<p className="error">Unable to load salons. Start the backend API to see results.</p>
|
||||||
|
)}
|
||||||
|
{status === "ready" && salons.length === 0 && <p>No salons found.</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 || "No description yet."}</p>
|
||||||
|
<div className="meta">
|
||||||
|
<span>{salon.city}</span>
|
||||||
|
<span>{salon.phone_number || "Phone unavailable"}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || "/api";
|
||||||
|
|
||||||
|
async function handleResponse(response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText || `Request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiGet(path) {
|
||||||
|
const response = await fetch(`${API_BASE}${path}`);
|
||||||
|
return handleResponse(response);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App.jsx";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&display=swap");
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color: #1c1b1f;
|
||||||
|
background: linear-gradient(160deg, #fdf1e5 0%, #f7f2ec 40%, #eef1ff 100%);
|
||||||
|
font-family: "Space Grotesk", "Trebuchet MS", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 24px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
box-shadow: 0 20px 40px rgba(26, 26, 26, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 40px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search input {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #dad3ca;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results {
|
||||||
|
margin-top: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 12px 24px rgba(21, 21, 21, 0.08);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
background: #ffcc80;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: #5c5a5f;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #b00020;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:8000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user