From a53e51e38c02e31fdad188c7abe77a05b7411f1c Mon Sep 17 00:00:00 2001 From: Plume ROBERTS <–38692785+PlumeRoberts@users.noreply.github.com> Date: Fri, 8 Nov 2024 13:35:35 +0100 Subject: [PATCH 1/4] added busplanning/models --- padam_django/apps/busplanning/__init__.py | 0 padam_django/apps/busplanning/apps.py | 6 +++ .../apps/busplanning/migrations/__init__.py | 0 padam_django/apps/busplanning/models.py | 45 +++++++++++++++++++ padam_django/settings.py | 1 + 5 files changed, 52 insertions(+) create mode 100644 padam_django/apps/busplanning/__init__.py create mode 100644 padam_django/apps/busplanning/apps.py create mode 100644 padam_django/apps/busplanning/migrations/__init__.py create mode 100644 padam_django/apps/busplanning/models.py diff --git a/padam_django/apps/busplanning/__init__.py b/padam_django/apps/busplanning/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/busplanning/apps.py b/padam_django/apps/busplanning/apps.py new file mode 100644 index 00000000..59f3f399 --- /dev/null +++ b/padam_django/apps/busplanning/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BusplanningConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'padam_django.apps.busplanning' diff --git a/padam_django/apps/busplanning/migrations/__init__.py b/padam_django/apps/busplanning/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/busplanning/models.py b/padam_django/apps/busplanning/models.py new file mode 100644 index 00000000..b53cf709 --- /dev/null +++ b/padam_django/apps/busplanning/models.py @@ -0,0 +1,45 @@ +from django.db import models +from ..fleet.models import Bus, Driver +from ..geography.models import Place + + +# Create your models here. +class BusStop(models.Model): + name = models.CharField("Stop name", max_length=63) + + location = models.OneToOneField( + Place, + on_delete=models.CASCADE + ) + + def __str__(self): + return f"Stop name: {self.name} (id: {self.pk})" + + +class BusShift(models.Model): + driver = models.ForeignKey(Driver, on_delete=models.SET_NULL, null=True) # can be null but must raise an error + + bus = models.ForeignKey(Bus, on_delete=models.SET_NULL, null=True) # can be null but must raise an error + + start_time = models.TimeField("Departure time", null=False) + end_time = models.TimeField("Arrival time", null=False) + + +class BusShiftStops(models.Model): + class Meta: + db_table = 'bus_shift_stops' + unique_together = ( + ("shift", "stop"), # A stop cannot be twice in a same shift/path + ("shift", "index"), + ) + + shift = models.ForeignKey( + BusShift, + on_delete=models.CASCADE + ) + stop = models.ForeignKey( + BusStop, + on_delete=models.CASCADE + ) + + index = models.PositiveIntegerField() # for ordering purposes diff --git a/padam_django/settings.py b/padam_django/settings.py index 129e922c..c45a94e9 100644 --- a/padam_django/settings.py +++ b/padam_django/settings.py @@ -41,6 +41,7 @@ # Third party apps 'django_extensions', # Internal apps + 'padam_django.apps.busplanning', 'padam_django.apps.common', 'padam_django.apps.fleet', 'padam_django.apps.geography', From 6a8fb7d8528cc682b0966815d60fe4709fc9511d Mon Sep 17 00:00:00 2001 From: Plume ROBERTS <–38692785+PlumeRoberts@users.noreply.github.com> Date: Fri, 8 Nov 2024 15:04:11 +0100 Subject: [PATCH 2/4] Added constraints --- .../busplanning/migrations/0001_initial.py | 57 ++++++++++++++++ .../migrations/0002_auto_20241108_1456.py | 26 +++++++ padam_django/apps/busplanning/models.py | 14 +++- padam_django/apps/busplanning/tests.py | 67 +++++++++++++++++++ 4 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 padam_django/apps/busplanning/migrations/0001_initial.py create mode 100644 padam_django/apps/busplanning/migrations/0002_auto_20241108_1456.py create mode 100644 padam_django/apps/busplanning/tests.py diff --git a/padam_django/apps/busplanning/migrations/0001_initial.py b/padam_django/apps/busplanning/migrations/0001_initial.py new file mode 100644 index 00000000..1ea33ded --- /dev/null +++ b/padam_django/apps/busplanning/migrations/0001_initial.py @@ -0,0 +1,57 @@ +# Generated by Django 3.2.5 on 2024-11-08 13:05 + +from django.db import migrations, models +import django.db.models.deletion +import django.db.models.expressions + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('fleet', '0002_auto_20211109_1456'), + ('geography', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='BusShift', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('start_time', models.TimeField(verbose_name='Departure time')), + ('end_time', models.TimeField(verbose_name='Arrival time')), + ('bus', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='fleet.bus')), + ('driver', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='fleet.driver')), + ], + ), + migrations.CreateModel( + name='BusStop', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=63, verbose_name='Stop name')), + ('location', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='geography.place')), + ], + ), + migrations.CreateModel( + name='BusShiftStops', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('index', models.PositiveIntegerField()), + ('shift', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='busplanning.busshift')), + ('stop', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='busplanning.busstop')), + ], + options={ + 'db_table': 'bus_shift_stops', + 'unique_together': {('shift', 'index'), ('shift', 'stop')}, + }, + ), + migrations.AddConstraint( + model_name='busshift', + constraint=models.CheckConstraint(check=models.Q(('driver', django.db.models.expressions.F('driver')), ('start_time__gte', django.db.models.expressions.F('start_time')), ('start_time__lte', django.db.models.expressions.F('start_time')), ('driver', django.db.models.expressions.F('driver')), ('end_time__gte', django.db.models.expressions.F('start_time')), ('end_time__lte', django.db.models.expressions.F('start_time'))), name='driver_planning_constraint'), + ), + migrations.AddConstraint( + model_name='busshift', + constraint=models.CheckConstraint(check=models.Q(('bus', django.db.models.expressions.F('bus')), ('start_time__gte', django.db.models.expressions.F('start_time')), ('start_time__lte', django.db.models.expressions.F('start_time')), ('bus', django.db.models.expressions.F('bus')), ('end_time__gte', django.db.models.expressions.F('start_time')), ('end_time__lte', django.db.models.expressions.F('start_time'))), name='bus_planning_constraint'), + ), + ] diff --git a/padam_django/apps/busplanning/migrations/0002_auto_20241108_1456.py b/padam_django/apps/busplanning/migrations/0002_auto_20241108_1456.py new file mode 100644 index 00000000..8d24cbe6 --- /dev/null +++ b/padam_django/apps/busplanning/migrations/0002_auto_20241108_1456.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.5 on 2024-11-08 13:56 + +from django.db import migrations, models +import django.db.models.expressions + + +class Migration(migrations.Migration): + + dependencies = [ + ('busplanning', '0001_initial'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='busshift', + name='driver_planning_constraint', + ), + migrations.RemoveConstraint( + model_name='busshift', + name='bus_planning_constraint', + ), + migrations.AddConstraint( + model_name='busshift', + constraint=models.CheckConstraint(check=models.Q(('start_time__lte', django.db.models.expressions.F('end_time'))), name='start_before_end_constraint'), + ), + ] diff --git a/padam_django/apps/busplanning/models.py b/padam_django/apps/busplanning/models.py index b53cf709..174f4055 100644 --- a/padam_django/apps/busplanning/models.py +++ b/padam_django/apps/busplanning/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.db.models import CheckConstraint, Q, F from ..fleet.models import Bus, Driver from ..geography.models import Place @@ -9,7 +10,8 @@ class BusStop(models.Model): location = models.OneToOneField( Place, - on_delete=models.CASCADE + on_delete=models.CASCADE, + unique=True ) def __str__(self): @@ -17,6 +19,16 @@ def __str__(self): class BusShift(models.Model): + class Meta: + constraints = [ + # A driver can only drive outside already allocated time + CheckConstraint( + check=Q(start_time__lte=F('end_time')), + name="start_before_end_constraint" + ), + # TODO create custom constraints check ing against the DB existing records to avoid Duplicates schedule + ] + driver = models.ForeignKey(Driver, on_delete=models.SET_NULL, null=True) # can be null but must raise an error bus = models.ForeignKey(Bus, on_delete=models.SET_NULL, null=True) # can be null but must raise an error diff --git a/padam_django/apps/busplanning/tests.py b/padam_django/apps/busplanning/tests.py new file mode 100644 index 00000000..d393758e --- /dev/null +++ b/padam_django/apps/busplanning/tests.py @@ -0,0 +1,67 @@ +from django.db import IntegrityError, transaction +from django.db.models import QuerySet +from django.test import TestCase +from .models import BusStop, BusShift, BusShiftStops +from ..fleet.factories import DriverFactory, BusFactory +from ..fleet.models import Driver, Bus +from ..geography.factories import PlaceFactory +from ..geography.models import Place +from ..users.factories import UserFactory + + +# Create your tests here. +class BusStopsTestCase(TestCase): + def test_create(self): + PlaceFactory.create_batch(size=10) + places: QuerySet = Place.objects.all()[2:7] + stops: list[BusStop] = [] + + for place in places: + stop = BusStop(place.name, place) + stops.append(stop) + + +class BusShiftTestCase(TestCase): + def test_create(self): + UserFactory.create_batch(size=10) + DriverFactory.create_batch(size=5) + BusFactory.create_batch(size=5) + + driver: Driver = Driver.objects.filter()[1] + bus: Bus = Bus.objects.filter()[1] + + stops = BusStop.objects.filter()[0:4] + + start_time_0 = '01:00:00' + end_time_0 = '02:00:00' + + start_time_1 = '11:00:00' + end_time_1 = '12:00:00' + + start_time_2 = '21:00:00' + end_time_2 = '22:00:00' + + bs0: BusShift = BusShift(driver=driver, bus=bus, start_time=start_time_0, end_time=end_time_0) + bs0.save() + + bs2: BusShift = BusShift(driver=driver, bus=bus, start_time=start_time_2, end_time=end_time_2) + bs2.save() + + bs1: BusShift = BusShift(driver=driver, bus=bus, start_time=start_time_1, end_time=end_time_1) + bs1.save() + + e0: BusShift = BusShift(driver=driver, bus=bus, start_time=end_time_0, end_time=start_time_0) + try: + with transaction.atomic(): + e0.save() + self.fail("end before start") + except IntegrityError: + pass + + e1: BusShift = BusShift(driver=driver, bus=bus, start_time=start_time_0, end_time=end_time_2) + try: + with transaction.atomic(): + e1.save() + self.fail("No concurrent shifts allowed") + except IntegrityError: + pass From c7abfe1a1904b4b73fb90720fb8fb1f4cb96769e Mon Sep 17 00:00:00 2001 From: Plume ROBERTS <–38692785+PlumeRoberts@users.noreply.github.com> Date: Sat, 9 Nov 2024 19:02:25 +0100 Subject: [PATCH 3/4] Added the admin access to add the bus shifts --- padam_django/apps/busplanning/admin.py | 18 ++++++++ padam_django/apps/busplanning/forms.py | 41 +++++++++++++++++++ .../migrations/0003_auto_20241109_1751.py | 21 ++++++++++ padam_django/apps/busplanning/models.py | 9 ++-- padam_django/apps/busplanning/tests.py | 10 +++++ padam_django/settings.py | 3 ++ 6 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 padam_django/apps/busplanning/admin.py create mode 100644 padam_django/apps/busplanning/forms.py create mode 100644 padam_django/apps/busplanning/migrations/0003_auto_20241109_1751.py diff --git a/padam_django/apps/busplanning/admin.py b/padam_django/apps/busplanning/admin.py new file mode 100644 index 00000000..793d475f --- /dev/null +++ b/padam_django/apps/busplanning/admin.py @@ -0,0 +1,18 @@ +from django.contrib import admin +from .models import BusShift, BusStop, BusShiftStop +from .forms import BusShiftForm + +# Register your models here. +@admin.register(BusShift) +class ShiftAdmin(admin.ModelAdmin): + form = BusShiftForm + + +@admin.register(BusStop) +class StopsAdmin(admin.ModelAdmin): + pass + + +@admin.register(BusShiftStop) +class ShiftStopsAdmin(admin.ModelAdmin): + pass diff --git a/padam_django/apps/busplanning/forms.py b/padam_django/apps/busplanning/forms.py new file mode 100644 index 00000000..e9bba8c4 --- /dev/null +++ b/padam_django/apps/busplanning/forms.py @@ -0,0 +1,41 @@ +from django import forms +from django.core.exceptions import ValidationError +from django.db.models import Q + +from padam_django.apps.busplanning.models import BusShift +from padam_django.apps.fleet.models import Driver + + +class BusShiftForm(forms.ModelForm): + class Meta: + model = BusShift + fields = "__all__" + # TODO add ModelChoiceField to add the BusStops at the creation + + def clean(self): + cleaned_data = super().clean() + driver = cleaned_data["driver"] + bus = cleaned_data["bus"] + start_time = cleaned_data["start_time"] + end_time = cleaned_data["end_time"] + + # get overlapping schedules for driver or buses + overlapping_shifts = BusShift.objects.filter( + Q( + Q(driver=driver) | Q(bus=bus) + ) & + Q( + Q(start_time__lte=start_time, end_time__gte=start_time) | + Q(start_time__lte=end_time, end_time__gte=end_time) | + Q(start_time__gte=start_time, end_time__lte=end_time) + ) + ) + print(overlapping_shifts) + if len(overlapping_shifts) > 0: + raise ValidationError( + "Schedule is overlapping with other schedules: %(schedules)s", + code="overlapping", + params={"schedules": overlapping_shifts} + ) + + return self.cleaned_data diff --git a/padam_django/apps/busplanning/migrations/0003_auto_20241109_1751.py b/padam_django/apps/busplanning/migrations/0003_auto_20241109_1751.py new file mode 100644 index 00000000..51a18b2b --- /dev/null +++ b/padam_django/apps/busplanning/migrations/0003_auto_20241109_1751.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.5 on 2024-11-09 16:51 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('busplanning', '0002_auto_20241108_1456'), + ] + + operations = [ + migrations.RenameModel( + old_name='BusShiftStops', + new_name='BusShiftStop', + ), + migrations.AlterModelTable( + name='busshiftstop', + table='bus_shift_stop', + ), + ] diff --git a/padam_django/apps/busplanning/models.py b/padam_django/apps/busplanning/models.py index 174f4055..516d060b 100644 --- a/padam_django/apps/busplanning/models.py +++ b/padam_django/apps/busplanning/models.py @@ -26,7 +26,8 @@ class Meta: check=Q(start_time__lte=F('end_time')), name="start_before_end_constraint" ), - # TODO create custom constraints check ing against the DB existing records to avoid Duplicates schedule + # TODO create custom constraints check ing against the DB existing records to avoid Duplicates schedule, + # equivalent of postgres exclusion constraint ] driver = models.ForeignKey(Driver, on_delete=models.SET_NULL, null=True) # can be null but must raise an error @@ -36,10 +37,12 @@ class Meta: start_time = models.TimeField("Departure time", null=False) end_time = models.TimeField("Arrival time", null=False) + def __str__(self): + return f"Shift-{self.pk}: {self.driver} // {self.bus}: ({self.start_time}-{self.end_time})" -class BusShiftStops(models.Model): +class BusShiftStop(models.Model): class Meta: - db_table = 'bus_shift_stops' + db_table = 'bus_shift_stop' unique_together = ( ("shift", "stop"), # A stop cannot be twice in a same shift/path ("shift", "index"), diff --git a/padam_django/apps/busplanning/tests.py b/padam_django/apps/busplanning/tests.py index d393758e..c6c30bf3 100644 --- a/padam_django/apps/busplanning/tests.py +++ b/padam_django/apps/busplanning/tests.py @@ -58,6 +58,8 @@ def test_create(self): except IntegrityError: pass + # TODO test form + # Those tests will fail because they don't use the form that checks for overlapping schedules e1: BusShift = BusShift(driver=driver, bus=bus, start_time=start_time_0, end_time=end_time_2) try: with transaction.atomic(): @@ -65,3 +67,11 @@ def test_create(self): self.fail("No concurrent shifts allowed") except IntegrityError: pass + + e2: BusShift = BusShift(driver=driver, bus=bus, start_time=start_time_0, end_time=end_time_2) + try: + with transaction.atomic(): + e2.save() + self.fail("No concurrent shifts allowed") + except IntegrityError: + pass \ No newline at end of file diff --git a/padam_django/settings.py b/padam_django/settings.py index c45a94e9..02b322c5 100644 --- a/padam_django/settings.py +++ b/padam_django/settings.py @@ -137,3 +137,6 @@ # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# Added in case I wanted ot see the logs in testing +# NOSE_ARGS = ['--nocapture', '--nologcapture',] \ No newline at end of file From 01cc5e8258f6da888ff1e9f17013da0251331d50 Mon Sep 17 00:00:00 2001 From: Plume ROBERTS <–38692785+PlumeRoberts@users.noreply.github.com> Date: Sat, 9 Nov 2024 19:23:19 +0100 Subject: [PATCH 4/4] Added comments --- padam_django/apps/busplanning/forms.py | 17 +++++++++++------ padam_django/apps/busplanning/models.py | 6 ++++-- padam_django/apps/busplanning/tests.py | 3 ++- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/padam_django/apps/busplanning/forms.py b/padam_django/apps/busplanning/forms.py index e9bba8c4..a3c04c9c 100644 --- a/padam_django/apps/busplanning/forms.py +++ b/padam_django/apps/busplanning/forms.py @@ -16,18 +16,23 @@ def clean(self): cleaned_data = super().clean() driver = cleaned_data["driver"] bus = cleaned_data["bus"] - start_time = cleaned_data["start_time"] - end_time = cleaned_data["end_time"] + departure = cleaned_data["start_time"] + arrival = cleaned_data["end_time"] - # get overlapping schedules for driver or buses + # Get overlapping schedules for driver or buses: + # For any driver or matching bus: + # - start_time < departure < end_time: if the departure overlaps + # - start_time < arrival < end_time: if the arrival overlaps + # - departure < star_time < arrival: if another shift begins during this one + # Any result os overlapping overlapping_shifts = BusShift.objects.filter( Q( Q(driver=driver) | Q(bus=bus) ) & Q( - Q(start_time__lte=start_time, end_time__gte=start_time) | - Q(start_time__lte=end_time, end_time__gte=end_time) | - Q(start_time__gte=start_time, end_time__lte=end_time) + Q(start_time__lte=departure, end_time__gte=departure) | + Q(start_time__lte=arrival, end_time__gte=arrival) | + Q(start_time__gte=departure, end_time__lte=arrival) ) ) print(overlapping_shifts) diff --git a/padam_django/apps/busplanning/models.py b/padam_django/apps/busplanning/models.py index 516d060b..972b8d57 100644 --- a/padam_django/apps/busplanning/models.py +++ b/padam_django/apps/busplanning/models.py @@ -21,13 +21,14 @@ def __str__(self): class BusShift(models.Model): class Meta: constraints = [ - # A driver can only drive outside already allocated time CheckConstraint( check=Q(start_time__lte=F('end_time')), - name="start_before_end_constraint" + name="end_before_start_constraint" ), + # A driver can only drive outside already allocated time # TODO create custom constraints check ing against the DB existing records to avoid Duplicates schedule, # equivalent of postgres exclusion constraint + # - Currently done with admin forms ] driver = models.ForeignKey(Driver, on_delete=models.SET_NULL, null=True) # can be null but must raise an error @@ -40,6 +41,7 @@ class Meta: def __str__(self): return f"Shift-{self.pk}: {self.driver} // {self.bus}: ({self.start_time}-{self.end_time})" + class BusShiftStop(models.Model): class Meta: db_table = 'bus_shift_stop' diff --git a/padam_django/apps/busplanning/tests.py b/padam_django/apps/busplanning/tests.py index c6c30bf3..b4a569bb 100644 --- a/padam_django/apps/busplanning/tests.py +++ b/padam_django/apps/busplanning/tests.py @@ -1,7 +1,7 @@ from django.db import IntegrityError, transaction from django.db.models import QuerySet from django.test import TestCase -from .models import BusStop, BusShift, BusShiftStops +from .models import BusStop, BusShift, BusShiftStop from ..fleet.factories import DriverFactory, BusFactory from ..fleet.models import Driver, Bus from ..geography.factories import PlaceFactory @@ -59,6 +59,7 @@ def test_create(self): pass # TODO test form + # Those tests will fail because they don't use the form that checks for overlapping schedules e1: BusShift = BusShift(driver=driver, bus=bus, start_time=start_time_0, end_time=end_time_2) try: