From e86bbb5011879283df9637ee0ce311a7d2f0f90c Mon Sep 17 00:00:00 2001 From: Victor Date: Mon, 24 Nov 2025 09:56:52 +0100 Subject: [PATCH] Create BusShift and BusStop models and admin interface --- Makefile | 3 + padam_django/apps/fleet/admin.py | 31 ++++-- padam_django/apps/fleet/factories.py | 29 ++++- padam_django/apps/fleet/forms.py | 62 +++++++++++ .../fleet/migrations/0003_busshift_busstop.py | 82 ++++++++++++++ padam_django/apps/fleet/models.py | 105 +++++++++++++++++- padam_django/apps/fleet/tests/__init__.py | 0 padam_django/apps/fleet/tests/test_admin.py | 76 +++++++++++++ padam_django/apps/fleet/tests/test_models.py | 80 +++++++++++++ requirements.txt | 28 ++++- 10 files changed, 482 insertions(+), 14 deletions(-) create mode 100644 padam_django/apps/fleet/forms.py create mode 100644 padam_django/apps/fleet/migrations/0003_busshift_busstop.py create mode 100644 padam_django/apps/fleet/tests/__init__.py create mode 100644 padam_django/apps/fleet/tests/test_admin.py create mode 100644 padam_django/apps/fleet/tests/test_models.py diff --git a/Makefile b/Makefile index 4062f4c4..8c67f0b9 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,8 @@ run: ## Run the test server. python manage.py runserver_plus +migrate: ## Apply migrations. + python manage.py migrate + install: ## Install the python requirements. pip install -r requirements.txt diff --git a/padam_django/apps/fleet/admin.py b/padam_django/apps/fleet/admin.py index 3fba5023..42fd1cc4 100644 --- a/padam_django/apps/fleet/admin.py +++ b/padam_django/apps/fleet/admin.py @@ -1,13 +1,30 @@ from django.contrib import admin -from . import models +from . import forms, models +admin.site.register(models.Bus) +admin.site.register(models.Driver) -@admin.register(models.Bus) -class BusAdmin(admin.ModelAdmin): - pass +class BusStopInline(admin.TabularInline): + model = models.BusStop + ordering = ["arrival_at"] + formset = forms.BusStopInlineFormSet -@admin.register(models.Driver) -class DriverAdmin(admin.ModelAdmin): - pass + def get_extra(self, request, obj=None, **kwargs): + return 0 if obj else 2 + + +@admin.register(models.BusShift) +class BusShiftAdmin(admin.ModelAdmin): + list_display = ( + "id", + "bus", + "driver", + "departure_at", + "first_place_name", + "arrival_at", + "last_place_name", + "shift_duration", + ) + inlines = [BusStopInline] diff --git a/padam_django/apps/fleet/factories.py b/padam_django/apps/fleet/factories.py index c78c832e..eb61c1d1 100644 --- a/padam_django/apps/fleet/factories.py +++ b/padam_django/apps/fleet/factories.py @@ -1,14 +1,22 @@ +from datetime import datetime + import factory +from django.utils import timezone from faker import Faker +from padam_django.apps.geography.factories import PlaceFactory + from . import models +fake = Faker(["fr"]) + -fake = Faker(['fr']) +def set_datetime(h=0, m=0): + return timezone.make_aware(datetime(2025, 11, 22, h, m)) class DriverFactory(factory.django.DjangoModelFactory): - user = factory.SubFactory('padam_django.apps.users.factories.UserFactory') + user = factory.SubFactory("padam_django.apps.users.factories.UserFactory") class Meta: model = models.Driver @@ -19,3 +27,20 @@ class BusFactory(factory.django.DjangoModelFactory): class Meta: model = models.Bus + + +class BusShiftFactory(factory.django.DjangoModelFactory): + bus = factory.SubFactory(BusFactory) + driver = factory.SubFactory(DriverFactory) + + class Meta: + model = models.BusShift + + +class BusStopFactory(factory.django.DjangoModelFactory): + shift = factory.SubFactory(BusShiftFactory) + place = factory.SubFactory(PlaceFactory) + arrival_at = factory.LazyFunction(lambda: set_datetime(10)) + + class Meta: + model = models.BusStop diff --git a/padam_django/apps/fleet/forms.py b/padam_django/apps/fleet/forms.py new file mode 100644 index 00000000..b59b0ce4 --- /dev/null +++ b/padam_django/apps/fleet/forms.py @@ -0,0 +1,62 @@ +from readline import insert_text + +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ + + +class BusStopInlineFormSet(BaseInlineFormSet): + def check_bus_shift_period(self, min_arrival_at, max_arrival_at): + """Check wheter the bus already has a shift overlap period.""" + if ( + self.instance.bus.shifts.exclude(id=self.instance.pk) + .filter_intersecting_period(min_arrival_at, max_arrival_at) + .exists() + ): + raise ValidationError( + _("This bus is already assigned to another shift during this period") + ) + + def check_driver_shift_period(self, min_arrival_at, max_arrival_at): + """Check whether the driver already has a journey period that overlaps with their shift.""" + if ( + self.instance.driver.shifts.exclude(id=self.instance.pk) + .filter_intersecting_period(min_arrival_at, max_arrival_at) + .exists() + ): + raise ValidationError( + _("This driver is already assigned to another shift during this period") + ) + + def clean(self): + super().clean() + + if not self.instance.bus: + raise ValidationError(_("Please select a bus")) + + if not self.instance.driver: + raise ValidationError(_("Please select a driver")) + + stops = [ + form.cleaned_data + for form in self.forms + if form.cleaned_data and not form.cleaned_data.get("DELETE") + ] + if len(stops) < 2: + raise ValidationError(_("A shift must contain at least 2 stops.")) + + nb_places = len(set([cleaned_data["place"].id for cleaned_data in stops])) + if nb_places < 2: + raise ValidationError(_("A shift must contain at least 2 different stops.")) + + arrivals_at = [cleaned_data["arrival_at"] for cleaned_data in stops] + min_arrival_at = min(arrivals_at) + max_arrival_at = max(arrivals_at) + + self.check_bus_shift_period( + min_arrival_at=min_arrival_at, max_arrival_at=max_arrival_at + ) + + self.check_driver_shift_period( + min_arrival_at=min_arrival_at, max_arrival_at=max_arrival_at + ) diff --git a/padam_django/apps/fleet/migrations/0003_busshift_busstop.py b/padam_django/apps/fleet/migrations/0003_busshift_busstop.py new file mode 100644 index 00000000..e01544aa --- /dev/null +++ b/padam_django/apps/fleet/migrations/0003_busshift_busstop.py @@ -0,0 +1,82 @@ +# Generated by Django 4.2.16 on 2025-11-23 20:52 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("geography", "0001_initial"), + ("fleet", "0002_auto_20211109_1456"), + ] + + operations = [ + migrations.CreateModel( + name="BusShift", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "bus", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="shifts", + to="fleet.bus", + ), + ), + ( + "driver", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="shifts", + to="fleet.driver", + ), + ), + ], + ), + migrations.CreateModel( + name="BusStop", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("arrival_at", models.DateTimeField()), + ( + "place", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="shift_stops", + to="geography.place", + ), + ), + ( + "shift", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="stops", + to="fleet.busshift", + ), + ), + ], + options={ + "ordering": ["arrival_at"], + "unique_together": {("shift", "arrival_at")}, + }, + ), + ] diff --git a/padam_django/apps/fleet/models.py b/padam_django/apps/fleet/models.py index 4cd3f19d..cf405007 100644 --- a/padam_django/apps/fleet/models.py +++ b/padam_django/apps/fleet/models.py @@ -1,8 +1,11 @@ from django.db import models +from django.utils.translation import gettext_lazy as _ class Driver(models.Model): - user = models.OneToOneField('users.User', on_delete=models.CASCADE, related_name='driver') + user = models.OneToOneField( + "users.User", on_delete=models.CASCADE, related_name="driver" + ) def __str__(self): return f"Driver: {self.user.username} (id: {self.pk})" @@ -16,3 +19,103 @@ class Meta: def __str__(self): return f"Bus: {self.licence_plate} (id: {self.pk})" + + +class BusShiftQuerySet(models.QuerySet): + def filter_intersecting_period(self, min_arrival_at, max_arrival_at): + return self.alias( + min_arrival_at=models.Min("stops__arrival_at"), + max_arrival_at=models.Max("stops__arrival_at"), + ).filter(min_arrival_at__lte=max_arrival_at, max_arrival_at__gte=min_arrival_at) + + +class BusShiftManager(models.Manager): + def get_queryset(self): + return BusShiftQuerySet(self.model, using=self._db) + + def filter_intersecting_period(self, min_arrival_at, max_arrival_at): + return self.get_queryset().filter_intersecting_period( + min_arrival_at, max_arrival_at + ) + + +class BusShift(models.Model): + bus = models.ForeignKey( + Bus, on_delete=models.SET_NULL, null=True, related_name="shifts" + ) + + driver = models.ForeignKey( + Driver, on_delete=models.SET_NULL, null=True, related_name="shifts" + ) + + objects = BusShiftManager() + + @property + def first_stop(self): + return self.stops.first() + + @property + def last_stop(self): + return self.stops.last() + + @property + def departure_at(self): + first_stop = self.first_stop + return first_stop.arrival_at if first_stop else None + + @property + def arrival_at(self): + last_stop = self.last_stop + return last_stop.arrival_at if last_stop else None + + def get_shift_duration(self): + """Shift duration in minutes.""" + arrival_at = self.arrival_at + departure_at = self.departure_at + if arrival_at is None or departure_at is None: + return None + + return round((self.arrival_at - self.departure_at).total_seconds() // 60) + + get_shift_duration.short_description = _("Duration (in minutes)") + shift_duration = property(get_shift_duration) + + @property + def first_place(self): + first_stop = self.first_stop + return first_stop.place if first_stop else None + + @property + def first_place_name(self): + first_place = self.first_place + return first_place.name if first_place else None + + @property + def last_place(self): + last_stop = self.last_stop + return last_stop.place if last_stop else None + + @property + def last_place_name(self): + last_place = self.last_place + return last_place.name if last_place else None + + def __str__(self): + return f"Shift: {self.bus} - {self.driver} (id: {self.pk})" + + +class BusStop(models.Model): + shift = models.ForeignKey(BusShift, on_delete=models.CASCADE, related_name="stops") + + place = models.ForeignKey( + "geography.Place", on_delete=models.CASCADE, related_name="shift_stops" + ) + + arrival_at = models.DateTimeField() + + class Meta: + ordering = ["arrival_at"] + unique_together = ("shift", "arrival_at") + + def __str__(self): + return f"Stop: {self.place} - {self.arrival_at} (id: {self.pk})" diff --git a/padam_django/apps/fleet/tests/__init__.py b/padam_django/apps/fleet/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/fleet/tests/test_admin.py b/padam_django/apps/fleet/tests/test_admin.py new file mode 100644 index 00000000..71d137f1 --- /dev/null +++ b/padam_django/apps/fleet/tests/test_admin.py @@ -0,0 +1,76 @@ +from datetime import datetime + +from django.test import Client, TestCase +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from padam_django.apps.fleet.factories import ( + BusShiftFactory, + BusStopFactory, + set_datetime, +) +from padam_django.apps.geography.factories import PlaceFactory +from padam_django.apps.users.factories import UserFactory + + +class AdminBusStopFormsetTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.shift = BusShiftFactory() + cls.bus = cls.shift.bus + cls.driver = cls.shift.driver + cls.place1 = PlaceFactory() + cls.place2 = PlaceFactory() + + cls.user = UserFactory(is_staff=True, is_superuser=True) + cls.client = Client() + + def setUp(self): + self.client.force_login(self.user) + + def build_formset_data(self, entries): + data = { + "bus": self.bus.id, + "driver": self.driver.id, + "stops-TOTAL_FORMS": str(len(entries)), + "stops-INITIAL_FORMS": "0", + "stops-MIN_NUM_FORMS": "0", + "stops-MAX_NUM_FORMS": "1000", + } + for i, entry in enumerate(entries): + for key, value in entry.items(): + if type(value) is datetime: + data[f"stops-{i}-{key}_0"] = value.date() + data[f"stops-{i}-{key}_1"] = value.time() + else: + data[f"stops-{i}-{key}"] = value + + return data + + def test_one_place(self): + url = reverse("admin:fleet_busshift_change", args=(self.shift.id,)) + data = self.build_formset_data( + [{"place": self.place1.id, "arrival_at": set_datetime(12)}] + ) + + response = self.client.post(url, data) + + self.assertContains(response, _("A shift must contain at least 2 stops.")) + + def test_check_bus_shift_period(self): + shift = BusShiftFactory(bus=self.bus) + BusStopFactory(shift=shift, arrival_at=set_datetime(12)) + url = reverse("admin:fleet_busshift_change", args=(self.shift.id,)) + data = self.build_formset_data( + [ + {"place": self.place1.id, "arrival_at": set_datetime(12)}, + {"place": self.place2.id, "arrival_at": set_datetime(12, 30)}, + ] + ) + + response = self.client.post(url, data) + + self.assertContains( + response, + _("This bus is already assigned to another shift during this period"), + ) diff --git a/padam_django/apps/fleet/tests/test_models.py b/padam_django/apps/fleet/tests/test_models.py new file mode 100644 index 00000000..c93d1931 --- /dev/null +++ b/padam_django/apps/fleet/tests/test_models.py @@ -0,0 +1,80 @@ +from django.test import TestCase + +from padam_django.apps.fleet.factories import ( + BusShiftFactory, + BusStopFactory, + set_datetime, +) +from padam_django.apps.fleet.models import BusShift + + +class BusShiftQuerySetTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.shift = BusShiftFactory() + + cls.first_stop = BusStopFactory(shift=cls.shift, arrival_at=set_datetime(10)) + BusStopFactory(shift=cls.shift, arrival_at=set_datetime(10, 30)) + cls.last_stop = BusStopFactory(shift=cls.shift, arrival_at=set_datetime(11)) + + def test_filter_intersecting_period_overlapping_left(self): + self.assertTrue( + BusShift.objects.filter_intersecting_period( + set_datetime(9), set_datetime(10) + ) + ) + + def test_filter_intersecting_period_overlapping_right(self): + self.assertTrue( + BusShift.objects.filter_intersecting_period( + set_datetime(11), set_datetime(12) + ) + ) + + def test_filter_intersecting_period_inside_period(self): + self.assertTrue( + BusShift.objects.filter_intersecting_period( + set_datetime(10, 20), set_datetime(10, 30) + ) + ) + + def test_filter_intersecting_period_no_match_before(self): + self.assertFalse( + BusShift.objects.filter_intersecting_period( + set_datetime(9), set_datetime(9, 59) + ) + ) + + def test_filter_intersecting_period_no_match_after(self): + self.assertFalse( + BusShift.objects.filter_intersecting_period( + set_datetime(11, 1), set_datetime(12) + ) + ) + + def test_departure_at(self): + self.assertEqual(self.shift.departure_at, set_datetime(10)) + + def test_arrival_at(self): + self.assertEqual(self.shift.arrival_at, set_datetime(11)) + + def test_shift_duration(self): + self.assertEqual(self.shift.shift_duration, 60) + + def test_first_place_name(self): + self.assertEqual(self.shift.first_place_name, self.first_stop.place.name) + + def test_last_place_name(self): + self.assertEqual(self.shift.last_place_name, self.last_stop.place.name) + + +class BusStopTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.bus_stop = BusStopFactory() + + def test_unique_together_shift_arrival_at(self): + with self.assertRaises(Exception): + BusStopFactory( + shift=self.bus_stop.shift, arrival_at=self.bus_stop.arrival_at + ) diff --git a/requirements.txt b/requirements.txt index 863fd63d..594af4e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,28 @@ +asgiref==3.11.0 +asttokens==3.0.1 +decorator==5.2.1 Django==4.2.16 - django-extensions==3.2.1 -Werkzeug==3.1.3 -ipython==8.29.0 - +exceptiongroup==1.3.1 +executing==2.2.1 factory-boy==3.2.0 Faker==8.10.1 +ipython==8.29.0 +jedi==0.19.2 +MarkupSafe==3.0.3 +matplotlib-inline==0.2.1 +parso==0.8.5 +pexpect==4.9.0 +prompt_toolkit==3.0.52 +ptyprocess==0.7.0 +pure_eval==0.2.3 +Pygments==2.19.2 +python-dateutil==2.9.0.post0 +six==1.17.0 +sqlparse==0.5.3 +stack-data==0.6.3 +text-unidecode==1.3 +traitlets==5.14.3 +typing_extensions==4.15.0 +wcwidth==0.2.14 +Werkzeug==3.1.3