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/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/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/forms.py b/padam_django/apps/busplanning/forms.py new file mode 100644 index 00000000..a3c04c9c --- /dev/null +++ b/padam_django/apps/busplanning/forms.py @@ -0,0 +1,46 @@ +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"] + departure = cleaned_data["start_time"] + arrival = cleaned_data["end_time"] + + # 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=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) + 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/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/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/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..972b8d57 --- /dev/null +++ b/padam_django/apps/busplanning/models.py @@ -0,0 +1,62 @@ +from django.db import models +from django.db.models import CheckConstraint, Q, F +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, + unique=True + ) + + def __str__(self): + return f"Stop name: {self.name} (id: {self.pk})" + + +class BusShift(models.Model): + class Meta: + constraints = [ + CheckConstraint( + check=Q(start_time__lte=F('end_time')), + 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 + + 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) + + 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' + 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/apps/busplanning/tests.py b/padam_django/apps/busplanning/tests.py new file mode 100644 index 00000000..b4a569bb --- /dev/null +++ b/padam_django/apps/busplanning/tests.py @@ -0,0 +1,78 @@ +from django.db import IntegrityError, transaction +from django.db.models import QuerySet +from django.test import TestCase +from .models import BusStop, BusShift, BusShiftStop +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 + + # 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(): + e1.save() + 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 129e922c..02b322c5 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', @@ -136,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