Initial commit

This commit is contained in:
2026-02-27 15:01:06 +03:00
commit fc06bb6fcd
52 changed files with 1355 additions and 0 deletions
+8
View File
@@ -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
View File
View File
+33
View File
@@ -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",)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"
+78
View File
@@ -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)
+41
View File
@@ -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
+83
View File
@@ -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
+14
View File
@@ -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"),
]
+72
View File
@@ -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,
)
View File
+5
View File
@@ -0,0 +1,5 @@
from django.contrib import admin
from apps.bookings.models import Booking
admin.site.register(Booking)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class BookingsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.bookings"
+32
View File
@@ -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}"
+65
View File
@@ -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,
)
+11
View File
@@ -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)),
]
+23
View File
@@ -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
View File
+5
View File
@@ -0,0 +1,5 @@
from django.contrib import admin
from apps.payments.models import Payment
admin.site.register(Payment)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class PaymentsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.payments"
+36
View File
@@ -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}"
+48
View File
@@ -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={},
)
+11
View File
@@ -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)),
]
+53
View File
@@ -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,
)
View File
+9
View File
@@ -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)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class SalonsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.salons"
+70
View File
@@ -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}"
+71
View File
@@ -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"]
+14
View File
@@ -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"),
]
+57
View File
@@ -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")
+19
View File
@@ -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()
+6
View File
@@ -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
View File
+6
View File
@@ -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()
+128
View File
@@ -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")
+10
View File
@@ -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")),
]
+6
View File
@@ -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()