fix error in gitignore
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# reservations app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ReservationsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.reservations"
|
||||
@@ -0,0 +1,86 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-15 09:29
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Facility',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=200)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Holiday',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('date', models.DateField(unique=True)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProcessingLine',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('facility', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='reservations.facility')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Slot',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('date', models.DateField(db_index=True)),
|
||||
('start_time', models.TimeField()),
|
||||
('end_time', models.TimeField()),
|
||||
('capacity', models.PositiveIntegerField(default=1)),
|
||||
('max_weight_kg', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
|
||||
('booked_count', models.PositiveIntegerField(default=0)),
|
||||
('blocked', models.BooleanField(default=False)),
|
||||
('facility', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='slots', to='reservations.facility')),
|
||||
('line', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='slots', to='reservations.processingline')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Reservation',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('status', models.CharField(choices=[('requested', 'Requested'), ('confirmed', 'Confirmed'), ('arrived', 'Arrived'), ('weighed', 'Weighed'), ('cancelled', 'Cancelled'), ('expired', 'Expired')], default='requested', max_length=16)),
|
||||
('notes', models.TextField(blank=True)),
|
||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='accounts.customer')),
|
||||
('slot', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='reservations', to='reservations.slot')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='slot',
|
||||
constraint=models.UniqueConstraint(fields=('facility', 'line', 'date', 'start_time'), name='uniq_slot_time'),
|
||||
),
|
||||
]
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-15 11:56
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('reservations', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='reservation',
|
||||
name='approved_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='reservation',
|
||||
name='approved_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_reservations', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='reservation',
|
||||
name='title',
|
||||
field=models.CharField(blank=True, max_length=200),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='reservation',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('pending', 'Pending approval'), ('confirmed', 'Confirmed'), ('rejected', 'Rejected'), ('arrived', 'Arrived'), ('weighed', 'Weighed'), ('cancelled', 'Cancelled'), ('expired', 'Expired')], default='pending', max_length=16),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FacilitySchedule',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('date', models.DateField(db_index=True)),
|
||||
('is_open', models.BooleanField(default=True)),
|
||||
('capacity', models.PositiveIntegerField(default=2)),
|
||||
('start_time', models.TimeField(blank=True, null=True)),
|
||||
('end_time', models.TimeField(blank=True, null=True)),
|
||||
('note', models.CharField(blank=True, max_length=200)),
|
||||
('facility', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='schedules', to='reservations.facility')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-date'],
|
||||
'constraints': [models.UniqueConstraint(fields=('facility', 'date'), name='uniq_facility_schedule_date')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.models import TimeStampedModel, UUIDModel
|
||||
|
||||
|
||||
class Facility(UUIDModel, TimeStampedModel):
|
||||
name = models.CharField(max_length=200)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class ProcessingLine(UUIDModel, TimeStampedModel):
|
||||
facility = models.ForeignKey(Facility, on_delete=models.CASCADE, related_name="lines")
|
||||
name = models.CharField(max_length=200)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.facility.name} / {self.name}"
|
||||
|
||||
|
||||
class Holiday(UUIDModel):
|
||||
date = models.DateField(unique=True)
|
||||
|
||||
|
||||
class FacilitySchedule(UUIDModel, TimeStampedModel):
|
||||
"""Per-facility, per-date availability: capacity for the day and days off.
|
||||
|
||||
If a schedule row exists for a date and ``is_open`` is False, no slots are
|
||||
generated for that day. ``capacity``/``start_time``/``end_time`` override the
|
||||
global Reservation settings for that date.
|
||||
"""
|
||||
|
||||
facility = models.ForeignKey(Facility, on_delete=models.CASCADE, related_name="schedules")
|
||||
date = models.DateField(db_index=True)
|
||||
is_open = models.BooleanField(default=True)
|
||||
capacity = models.PositiveIntegerField(default=2)
|
||||
start_time = models.TimeField(null=True, blank=True)
|
||||
end_time = models.TimeField(null=True, blank=True)
|
||||
note = models.CharField(max_length=200, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-date"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["facility", "date"], name="uniq_facility_schedule_date")
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.facility.name} {self.date}"
|
||||
|
||||
|
||||
class Slot(UUIDModel):
|
||||
facility = models.ForeignKey(Facility, on_delete=models.CASCADE, related_name="slots")
|
||||
line = models.ForeignKey(
|
||||
ProcessingLine, on_delete=models.CASCADE, related_name="slots", null=True, blank=True
|
||||
)
|
||||
date = models.DateField(db_index=True)
|
||||
start_time = models.TimeField()
|
||||
end_time = models.TimeField()
|
||||
capacity = models.PositiveIntegerField(default=1)
|
||||
max_weight_kg = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
|
||||
booked_count = models.PositiveIntegerField(default=0)
|
||||
blocked = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["facility", "line", "date", "start_time"], name="uniq_slot_time")
|
||||
]
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return (
|
||||
not self.blocked
|
||||
and self.booked_count < self.capacity
|
||||
and self.date >= timezone.localdate()
|
||||
)
|
||||
|
||||
|
||||
class Reservation(UUIDModel, TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
PENDING = "pending", "Pending approval"
|
||||
CONFIRMED = "confirmed", "Confirmed"
|
||||
REJECTED = "rejected", "Rejected"
|
||||
ARRIVED = "arrived", "Arrived"
|
||||
WEIGHED = "weighed", "Weighed"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
EXPIRED = "expired", "Expired"
|
||||
|
||||
title = models.CharField(max_length=200, blank=True)
|
||||
customer = models.ForeignKey("accounts.Customer", on_delete=models.CASCADE, related_name="reservations")
|
||||
slot = models.ForeignKey(Slot, on_delete=models.PROTECT, related_name="reservations")
|
||||
status = models.CharField(max_length=16, choices=Status.choices, default=Status.PENDING)
|
||||
notes = models.TextField(blank=True)
|
||||
approved_by = models.ForeignKey(
|
||||
"accounts.User", null=True, blank=True, on_delete=models.SET_NULL, related_name="approved_reservations"
|
||||
)
|
||||
approved_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.title or f"Reservation {self.id}"
|
||||
@@ -0,0 +1,53 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.reservations.models import Facility, FacilitySchedule, Reservation, Slot
|
||||
|
||||
|
||||
class FacilitySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Facility
|
||||
fields = ["id", "name"]
|
||||
|
||||
|
||||
class SlotSerializer(serializers.ModelSerializer):
|
||||
facility_name = serializers.CharField(source="facility.name", read_only=True)
|
||||
available = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Slot
|
||||
fields = [
|
||||
"id", "facility", "facility_name", "line", "date", "start_time",
|
||||
"end_time", "capacity", "max_weight_kg", "booked_count", "blocked", "available",
|
||||
]
|
||||
|
||||
def get_available(self, obj):
|
||||
return obj.is_available()
|
||||
|
||||
|
||||
class FacilityScheduleSerializer(serializers.ModelSerializer):
|
||||
facility_name = serializers.CharField(source="facility.name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = FacilitySchedule
|
||||
fields = [
|
||||
"id", "facility", "facility_name", "date", "is_open",
|
||||
"capacity", "start_time", "end_time", "note",
|
||||
]
|
||||
|
||||
|
||||
class ReservationSerializer(serializers.ModelSerializer):
|
||||
slot = SlotSerializer(read_only=True)
|
||||
customer_id = serializers.UUIDField(source="customer.id", read_only=True)
|
||||
customer_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Reservation
|
||||
fields = [
|
||||
"id", "title", "customer", "customer_id", "customer_name", "slot",
|
||||
"status", "notes", "approved_at", "created_at",
|
||||
]
|
||||
read_only_fields = ["customer", "status", "approved_at", "created_at"]
|
||||
|
||||
def get_customer_name(self, obj):
|
||||
user = obj.customer.user
|
||||
return obj.customer.company_name or user.get_full_name() or user.username
|
||||
@@ -0,0 +1,91 @@
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.errors import ApiError
|
||||
from apps.reservations.models import Reservation, Slot
|
||||
|
||||
|
||||
class ReservationService:
|
||||
"""Booking with backend-enforced availability, double-booking prevention,
|
||||
and admin approval (which triggers a customer SMS)."""
|
||||
|
||||
@transaction.atomic
|
||||
def book(self, customer, slot, title: str = "", notes: str = "") -> Reservation:
|
||||
slot = Slot.objects.select_for_update().get(pk=slot.pk)
|
||||
if slot.blocked:
|
||||
raise ApiError("SLOT_BLOCKED", "This slot is blocked.")
|
||||
if slot.date < timezone.localdate():
|
||||
raise ApiError("SLOT_PAST", "Cannot book a slot in the past.")
|
||||
if slot.booked_count >= slot.capacity:
|
||||
raise ApiError("SLOT_FULL", "This slot is fully booked.")
|
||||
slot.booked_count += 1
|
||||
slot.save(update_fields=["booked_count"])
|
||||
return Reservation.objects.create(
|
||||
customer=customer,
|
||||
slot=slot,
|
||||
title=title,
|
||||
status=Reservation.Status.PENDING,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def approve(self, reservation, user) -> Reservation:
|
||||
reservation = Reservation.objects.select_for_update().select_related("slot", "customer").get(pk=reservation.pk)
|
||||
if reservation.status != Reservation.Status.PENDING:
|
||||
raise ApiError("INVALID_STATUS", "Only pending reservations can be approved.")
|
||||
reservation.status = Reservation.Status.CONFIRMED
|
||||
reservation.approved_by = user
|
||||
reservation.approved_at = timezone.now()
|
||||
reservation.save(update_fields=["status", "approved_by", "approved_at"])
|
||||
|
||||
from apps.notifications.services import NotificationService
|
||||
|
||||
customer = reservation.customer
|
||||
NotificationService().send_event(
|
||||
"reservation.approved",
|
||||
{
|
||||
"customer_name": customer.company_name or customer.user.get_full_name() or customer.user.username,
|
||||
"reservation_title": reservation.title,
|
||||
"slot_date": str(reservation.slot.date),
|
||||
"slot_time": str(reservation.slot.start_time),
|
||||
},
|
||||
recipient=customer.user.phone or customer.user.username,
|
||||
)
|
||||
return reservation
|
||||
|
||||
@transaction.atomic
|
||||
def reject(self, reservation, user) -> Reservation:
|
||||
reservation = Reservation.objects.select_for_update().select_related("slot").get(pk=reservation.pk)
|
||||
if reservation.status not in (Reservation.Status.PENDING, Reservation.Status.CONFIRMED):
|
||||
raise ApiError("INVALID_STATUS", "This reservation cannot be rejected.")
|
||||
reservation.status = Reservation.Status.REJECTED
|
||||
reservation.approved_by = user
|
||||
reservation.approved_at = timezone.now()
|
||||
reservation.save(update_fields=["status", "approved_by", "approved_at"])
|
||||
slot = reservation.slot
|
||||
slot.booked_count = max(0, slot.booked_count - 1)
|
||||
slot.save(update_fields=["booked_count"])
|
||||
return reservation
|
||||
|
||||
@transaction.atomic
|
||||
def cancel(self, reservation) -> Reservation:
|
||||
reservation = (
|
||||
Reservation.objects.select_for_update()
|
||||
.select_related("slot")
|
||||
.get(pk=reservation.pk)
|
||||
)
|
||||
if reservation.status == Reservation.Status.CANCELLED:
|
||||
return reservation
|
||||
if reservation.status == Reservation.Status.WEIGHED:
|
||||
raise ApiError("RESERVATION_LOCKED", "Cannot cancel a reservation that has been weighed.")
|
||||
reservation.status = Reservation.Status.CANCELLED
|
||||
reservation.save(update_fields=["status"])
|
||||
slot = reservation.slot
|
||||
slot.booked_count = max(0, slot.booked_count - 1)
|
||||
slot.save(update_fields=["booked_count"])
|
||||
return reservation
|
||||
|
||||
@transaction.atomic
|
||||
def reschedule(self, reservation, new_slot) -> Reservation:
|
||||
self.cancel(reservation)
|
||||
return self.book(reservation.customer, new_slot, title=reservation.title)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Dynamic slot generation.
|
||||
|
||||
Slots are generated from runtime settings (working hours, slot duration,
|
||||
default capacity) and per-day `FacilitySchedule` overrides (capacity for the
|
||||
day, days off). Generation is idempotent — re-running updates existing slots
|
||||
instead of duplicating them.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from apps.core.settings_service import SettingsService
|
||||
from apps.reservations.models import FacilitySchedule, Slot
|
||||
|
||||
|
||||
class SlotService:
|
||||
def generate_slots(self, facility, day, line=None) -> int:
|
||||
schedule = FacilitySchedule.objects.filter(facility=facility, date=day).first()
|
||||
if schedule is not None and not schedule.is_open:
|
||||
return 0 # day off
|
||||
|
||||
service = SettingsService()
|
||||
if schedule is not None and schedule.start_time:
|
||||
start_str = schedule.start_time.strftime("%H:%M")
|
||||
else:
|
||||
start_str = service.get("reservation.working_hours_start", "08:00")
|
||||
if schedule is not None and schedule.end_time:
|
||||
end_str = schedule.end_time.strftime("%H:%M")
|
||||
else:
|
||||
end_str = service.get("reservation.working_hours_end", "17:00")
|
||||
duration = int(service.get("reservation.slot_duration_minutes", 30))
|
||||
if schedule is not None:
|
||||
capacity = schedule.capacity
|
||||
else:
|
||||
capacity = int(service.get("reservation.default_capacity", 2))
|
||||
|
||||
if duration <= 0:
|
||||
raise ValueError("Slot duration must be positive.")
|
||||
|
||||
start_t = datetime.strptime(start_str, "%H:%M").time()
|
||||
end_t = datetime.strptime(end_str, "%H:%M").time()
|
||||
current = datetime.combine(day, start_t)
|
||||
end_dt = datetime.combine(day, end_t)
|
||||
|
||||
created = 0
|
||||
while current + timedelta(minutes=duration) <= end_dt:
|
||||
slot_end = (current + timedelta(minutes=duration)).time()
|
||||
_, was_created = Slot.objects.update_or_create(
|
||||
facility=facility,
|
||||
line=line,
|
||||
date=day,
|
||||
start_time=current.time(),
|
||||
defaults={"end_time": slot_end, "capacity": capacity},
|
||||
)
|
||||
if was_created:
|
||||
created += 1
|
||||
current += timedelta(minutes=duration)
|
||||
return created
|
||||
@@ -0,0 +1,82 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import Customer
|
||||
from apps.core.errors import ApiError
|
||||
from apps.reservations.models import Facility, Slot
|
||||
from apps.reservations.services import ReservationService
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ReservationTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="c1", password="x")
|
||||
self.customer = Customer.objects.create(user=self.user)
|
||||
self.facility = Facility.objects.create(name="F1")
|
||||
self.slot = Slot.objects.create(
|
||||
facility=self.facility,
|
||||
date=timezone.localdate(),
|
||||
start_time="08:00",
|
||||
end_time="08:30",
|
||||
capacity=1,
|
||||
)
|
||||
self.service = ReservationService()
|
||||
|
||||
def test_double_booking_prevented(self):
|
||||
self.service.book(self.customer, self.slot)
|
||||
with self.assertRaises(ApiError) as ctx:
|
||||
self.service.book(self.customer, self.slot)
|
||||
self.assertEqual(ctx.exception.code, "SLOT_FULL")
|
||||
|
||||
def test_cancel_frees_slot(self):
|
||||
reservation = self.service.book(self.customer, self.slot)
|
||||
self.service.cancel(reservation)
|
||||
self.slot.refresh_from_db()
|
||||
self.assertEqual(self.slot.booked_count, 0)
|
||||
|
||||
def test_cannot_book_blocked_slot(self):
|
||||
self.slot.blocked = True
|
||||
self.slot.save()
|
||||
with self.assertRaises(ApiError) as ctx:
|
||||
self.service.book(self.customer, self.slot)
|
||||
self.assertEqual(ctx.exception.code, "SLOT_BLOCKED")
|
||||
|
||||
def test_generate_slots_from_dynamic_settings(self):
|
||||
from apps.core.settings_service import SettingsService
|
||||
from apps.reservations.slot_service import SlotService
|
||||
|
||||
facility2 = Facility.objects.create(name="F2")
|
||||
svc = SettingsService()
|
||||
svc.set("reservation.working_hours_start", "08:00")
|
||||
svc.set("reservation.working_hours_end", "09:00")
|
||||
svc.set("reservation.slot_duration_minutes", 30)
|
||||
svc.set("reservation.default_capacity", 3)
|
||||
|
||||
created = SlotService().generate_slots(facility2, timezone.localdate())
|
||||
self.assertEqual(created, 2)
|
||||
slots = Slot.objects.filter(facility=facility2).order_by("start_time")
|
||||
self.assertEqual(slots.count(), 2)
|
||||
self.assertEqual(slots.first().capacity, 3)
|
||||
self.assertEqual(str(slots.first().end_time), "08:30:00")
|
||||
|
||||
def test_day_off_generates_no_slots(self):
|
||||
from apps.reservations.models import FacilitySchedule
|
||||
from apps.reservations.slot_service import SlotService
|
||||
|
||||
facility3 = Facility.objects.create(name="F3")
|
||||
FacilitySchedule.objects.create(facility=facility3, date=timezone.localdate(), is_open=False)
|
||||
created = SlotService().generate_slots(facility3, timezone.localdate())
|
||||
self.assertEqual(created, 0)
|
||||
|
||||
def test_approve_sends_sms(self):
|
||||
from apps.notifications.models import NotificationLog, NotificationTemplate
|
||||
|
||||
NotificationTemplate.objects.create(event="reservation.approved", channel="sms", body="Hi {{customer_name}}")
|
||||
reservation = self.service.book(self.customer, self.slot, title="Batch A")
|
||||
self.assertEqual(reservation.status, "pending")
|
||||
self.service.approve(reservation, self.user)
|
||||
reservation.refresh_from_db()
|
||||
self.assertEqual(reservation.status, "confirmed")
|
||||
self.assertEqual(NotificationLog.objects.filter(status="sent").count(), 1)
|
||||
@@ -0,0 +1,16 @@
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.reservations.views import (
|
||||
FacilityScheduleViewSet,
|
||||
FacilityViewSet,
|
||||
ReservationViewSet,
|
||||
SlotViewSet,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("facilities", FacilityViewSet, basename="facility")
|
||||
router.register("schedules", FacilityScheduleViewSet, basename="facility-schedule")
|
||||
router.register("slots", SlotViewSet, basename="slot")
|
||||
router.register("", ReservationViewSet, basename="reservation")
|
||||
|
||||
urlpatterns = router.urls
|
||||
@@ -0,0 +1,171 @@
|
||||
from datetime import date as date_cls
|
||||
|
||||
from rest_framework import decorators, mixins, response, status, viewsets
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
from apps.core.errors import ApiError, error_response
|
||||
from apps.rbac.permissions import ActionPermission
|
||||
from apps.reservations.models import Facility, FacilitySchedule, ProcessingLine, Reservation, Slot
|
||||
from apps.reservations.serializers import (
|
||||
FacilityScheduleSerializer,
|
||||
FacilitySerializer,
|
||||
ReservationSerializer,
|
||||
SlotSerializer,
|
||||
)
|
||||
from apps.reservations.services import ReservationService
|
||||
from apps.reservations.slot_service import SlotService
|
||||
|
||||
|
||||
class FacilityViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = Facility.objects.all()
|
||||
serializer_class = FacilitySerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
|
||||
class FacilityScheduleViewSet(viewsets.ModelViewSet):
|
||||
queryset = FacilitySchedule.objects.select_related("facility").all()
|
||||
serializer_class = FacilityScheduleSerializer
|
||||
permission_classes = [ActionPermission]
|
||||
filterset_fields = ["facility", "date"]
|
||||
code_map = {
|
||||
"list": "reservation.view",
|
||||
"retrieve": "reservation.view",
|
||||
"create": "slot.manage",
|
||||
"update": "slot.manage",
|
||||
"partial_update": "slot.manage",
|
||||
"destroy": "slot.manage",
|
||||
}
|
||||
|
||||
|
||||
class SlotViewSet(
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
queryset = Slot.objects.select_related("facility", "line").all()
|
||||
serializer_class = SlotSerializer
|
||||
permission_classes = [ActionPermission]
|
||||
filterset_fields = ["facility", "date", "blocked"]
|
||||
code_map = {
|
||||
"list": "reservation.view",
|
||||
"retrieve": "reservation.view",
|
||||
"create": "slot.manage",
|
||||
"update": "slot.manage",
|
||||
"partial_update": "slot.manage",
|
||||
"destroy": "slot.manage",
|
||||
"generate": "slot.manage",
|
||||
}
|
||||
|
||||
@decorators.action(detail=False, methods=["post"])
|
||||
def generate(self, request):
|
||||
facility_id = request.data.get("facility")
|
||||
date_str = request.data.get("date")
|
||||
line_id = request.data.get("line")
|
||||
if not facility_id or not date_str:
|
||||
return response.Response(
|
||||
error_response("INVALID_INPUT", "facility and date are required."),
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
try:
|
||||
facility = Facility.objects.get(pk=facility_id)
|
||||
except Facility.DoesNotExist:
|
||||
return response.Response(
|
||||
error_response("FACILITY_NOT_FOUND", "Facility not found."),
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
line = None
|
||||
if line_id:
|
||||
try:
|
||||
line = ProcessingLine.objects.get(pk=line_id)
|
||||
except ProcessingLine.DoesNotExist:
|
||||
return response.Response(
|
||||
error_response("LINE_NOT_FOUND", "Processing line not found."),
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
try:
|
||||
day = date_cls.fromisoformat(date_str)
|
||||
except ValueError:
|
||||
return response.Response(
|
||||
error_response("INVALID_DATE", "Invalid date."),
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
try:
|
||||
created = SlotService().generate_slots(facility, day, line)
|
||||
except ValueError as exc:
|
||||
return response.Response(
|
||||
error_response("INVALID_SETTINGS", str(exc)),
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return response.Response({"created": created})
|
||||
|
||||
|
||||
class ReservationViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ReservationSerializer
|
||||
permission_classes = [ActionPermission]
|
||||
filterset_fields = ["status"]
|
||||
code_map = {
|
||||
"list": "reservation.view",
|
||||
"retrieve": "reservation.view",
|
||||
"create": "reservation.create",
|
||||
"cancel": "reservation.cancel",
|
||||
"approve": "reservation.approve",
|
||||
"reject": "reservation.approve",
|
||||
}
|
||||
service = ReservationService()
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
if user.is_superuser or user.user_roles.filter(role__slug__in=["administrator", "facility_manager"]).exists():
|
||||
return Reservation.objects.select_related("customer__user", "slot").all()
|
||||
return Reservation.objects.select_related("customer__user", "slot").filter(customer__user=user)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
slot_id = self.request.data.get("slot")
|
||||
if not slot_id:
|
||||
raise ApiError("SLOT_REQUIRED", "A slot id is required.")
|
||||
try:
|
||||
reservation = self.service.book(
|
||||
self._customer(),
|
||||
Slot.objects.get(pk=slot_id),
|
||||
title=self.request.data.get("title", ""),
|
||||
notes=self.request.data.get("notes", ""),
|
||||
)
|
||||
except Slot.DoesNotExist:
|
||||
raise ApiError("SLOT_NOT_FOUND", "Slot not found.")
|
||||
serializer.instance = reservation
|
||||
|
||||
def _customer(self):
|
||||
from apps.accounts.models import Customer
|
||||
|
||||
customer, _ = Customer.objects.get_or_create(user=self.request.user)
|
||||
return customer
|
||||
|
||||
@decorators.action(detail=True, methods=["post"])
|
||||
def cancel(self, request, pk=None):
|
||||
reservation = self.get_object()
|
||||
try:
|
||||
self.service.cancel(reservation)
|
||||
except ApiError as exc:
|
||||
return response.Response(error_response(exc.code, exc.message, exc.details), status=exc.status)
|
||||
return response.Response(ReservationSerializer(reservation).data)
|
||||
|
||||
@decorators.action(detail=True, methods=["post"])
|
||||
def approve(self, request, pk=None):
|
||||
reservation = self.get_object()
|
||||
try:
|
||||
reservation = self.service.approve(reservation, request.user)
|
||||
except ApiError as exc:
|
||||
return response.Response(error_response(exc.code, exc.message, exc.details), status=exc.status)
|
||||
return response.Response(ReservationSerializer(reservation).data)
|
||||
|
||||
@decorators.action(detail=True, methods=["post"])
|
||||
def reject(self, request, pk=None):
|
||||
reservation = self.get_object()
|
||||
try:
|
||||
reservation = self.service.reject(reservation, request.user)
|
||||
except ApiError as exc:
|
||||
return response.Response(error_response(exc.code, exc.message, exc.details), status=exc.status)
|
||||
return response.Response(ReservationSerializer(reservation).data)
|
||||
Reference in New Issue
Block a user