diff --git a/.gitignore b/.gitignore index d7d26693..43d2d9c5 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,11 @@ ENV/ # Editors stuff .idea .vscode + +# Python venv +/lib +lib64 +pyvenv.cfg +/bin +/share +notes_perso.txt \ No newline at end of file diff --git a/Makefile b/Makefile index 4062f4c4..e4c08087 100644 --- a/Makefile +++ b/Makefile @@ -3,3 +3,9 @@ run: ## Run the test server. install: ## Install the python requirements. pip install -r requirements.txt + +migrate: ## Make migrations + python manage.py migrate + +makemigrations: ## Generate database migrations from models state + python manage.py makemigrations \ No newline at end of file diff --git a/padam_django/apps/common/management/commands/create_data.py b/padam_django/apps/common/management/commands/create_data.py index a149a937..ecf87bd0 100644 --- a/padam_django/apps/common/management/commands/create_data.py +++ b/padam_django/apps/common/management/commands/create_data.py @@ -4,11 +4,12 @@ class Command(BaseCommand): - - help = 'Create test data' + help = "Create test data" def handle(self, *args, **options): - management.call_command('create_users', number=5) - management.call_command('create_drivers', number=5) - management.call_command('create_buses', number=10) - management.call_command('create_places', number=30) + management.call_command("create_users", number=5) + management.call_command("create_drivers", number=5) + management.call_command("create_buses", number=10) + management.call_command("create_places", number=30) + management.call_command("create_bus_shifts", number=10) + management.call_command("create_bus_stops", number=10) diff --git a/padam_django/apps/fleet/admin.py b/padam_django/apps/fleet/admin.py index 3fba5023..efff2975 100644 --- a/padam_django/apps/fleet/admin.py +++ b/padam_django/apps/fleet/admin.py @@ -8,6 +8,42 @@ class BusAdmin(admin.ModelAdmin): pass +class BusStopsInline(admin.TabularInline): + model = models.BusStop + ordering = ("datetime",) + + +@admin.register(models.BusShift) +class BusShiftAdmin(admin.ModelAdmin): + readonly_fields = ( + "total_duration", + "start_datetime", + "end_datetime", + "has_enough_stops", + ) + fieldsets = [ + ( + "Main", + { + "fields": [ + "bus", + "driver", + "start_datetime", + "end_datetime", + "total_duration", + ] + }, + ), + ("Flags", {"fields": ["has_enough_stops"]}), + ] + inlines = [BusStopsInline] + + @admin.register(models.Driver) class DriverAdmin(admin.ModelAdmin): pass + + +@admin.register(models.BusStop) +class BusStopAdmin(admin.ModelAdmin): + pass diff --git a/padam_django/apps/fleet/apps.py b/padam_django/apps/fleet/apps.py index 71378675..95129c6e 100644 --- a/padam_django/apps/fleet/apps.py +++ b/padam_django/apps/fleet/apps.py @@ -2,4 +2,7 @@ class FleetConfig(AppConfig): - name = 'padam_django.apps.fleet' + name = "padam_django.apps.fleet" + + def ready(self): + import padam_django.apps.fleet.signals diff --git a/padam_django/apps/fleet/exceptions.py b/padam_django/apps/fleet/exceptions.py new file mode 100644 index 00000000..24d5bec4 --- /dev/null +++ b/padam_django/apps/fleet/exceptions.py @@ -0,0 +1,14 @@ +class BusOtherShiftsOverlapException(Exception): + pass + + +class DriverOtherShiftsOverlapException(Exception): + pass + + +class StopWouldOverlapBusOtherShifts(Exception): + pass + + +class StopWouldOverlapDriverOtherShifts(Exception): + pass diff --git a/padam_django/apps/fleet/factories.py b/padam_django/apps/fleet/factories.py index c78c832e..b6f6d9fc 100644 --- a/padam_django/apps/fleet/factories.py +++ b/padam_django/apps/fleet/factories.py @@ -1,14 +1,15 @@ import factory from faker import Faker +from django.utils import timezone from . import models -fake = Faker(['fr']) +fake = Faker(["fr"]) 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 +20,20 @@ class BusFactory(factory.django.DjangoModelFactory): class Meta: model = models.Bus + + +class BusShiftFactory(factory.django.DjangoModelFactory): + bus = factory.SubFactory("padam_django.apps.fleet.factories.BusFactory") + driver = factory.SubFactory("padam_django.apps.fleet.factories.DriverFactory") + + class Meta: + model = models.BusShift + + +class BusStopFactory(factory.django.DjangoModelFactory): + place = factory.SubFactory("padam_django.apps.geography.factories.PlaceFactory") + datetime = factory.Faker("date_time", tzinfo=timezone.get_current_timezone()) + shift = factory.SubFactory("padam_django.apps.fleet.factories.BusShiftFactory") + + class Meta: + model = models.BusStop diff --git a/padam_django/apps/fleet/management/commands/create_bus_shifts.py b/padam_django/apps/fleet/management/commands/create_bus_shifts.py new file mode 100644 index 00000000..1178d096 --- /dev/null +++ b/padam_django/apps/fleet/management/commands/create_bus_shifts.py @@ -0,0 +1,12 @@ +from padam_django.apps.common.management.base import CreateDataBaseCommand + +from padam_django.apps.fleet.factories import BusShiftFactory + + +class Command(CreateDataBaseCommand): + help = "Create few bus shifts" + + def handle(self, *args, **options): + super().handle(*args, **options) + self.stdout.write(f"Creating {self.number} bus shifts ...") + BusShiftFactory.create_batch(size=self.number) diff --git a/padam_django/apps/fleet/management/commands/create_bus_stops.py b/padam_django/apps/fleet/management/commands/create_bus_stops.py new file mode 100644 index 00000000..c2fe47d0 --- /dev/null +++ b/padam_django/apps/fleet/management/commands/create_bus_stops.py @@ -0,0 +1,12 @@ +from padam_django.apps.common.management.base import CreateDataBaseCommand + +from padam_django.apps.fleet.factories import BusStopFactory + + +class Command(CreateDataBaseCommand): + help = "Create few bus stops" + + def handle(self, *args, **options): + super().handle(*args, **options) + self.stdout.write(f"Creating {self.number} bus stops ...") + BusStopFactory.create_batch(size=self.number) diff --git a/padam_django/apps/fleet/migrations/0003_busstop_squashed_0018_alter_busshift_has_enough_stops.py b/padam_django/apps/fleet/migrations/0003_busstop_squashed_0018_alter_busshift_has_enough_stops.py new file mode 100644 index 00000000..d6f3b55f --- /dev/null +++ b/padam_django/apps/fleet/migrations/0003_busstop_squashed_0018_alter_busshift_has_enough_stops.py @@ -0,0 +1,40 @@ +# Generated by Django 3.2.5 on 2023-06-11 15:42 + +import datetime +from django.db import migrations, models +import django.db.models.deletion +from django.utils.timezone import utc + + +class Migration(migrations.Migration): + + replaces = [('fleet', '0003_busstop'), ('fleet', '0004_rename_stop_time_busstop_datetime'), ('fleet', '0005_busshift'), ('fleet', '0006_auto_20230610_1457'), ('fleet', '0007_alter_busstop_shift'), ('fleet', '0008_auto_20230610_1509'), ('fleet', '0009_alter_busstop_shift'), ('fleet', '0010_alter_busstop_datetime'), ('fleet', '0011_busshift_total_duration'), ('fleet', '0012_alter_busshift_total_duration'), ('fleet', '0013_alter_busstop_shift'), ('fleet', '0014_busshift_has_enough_stops'), ('fleet', '0015_alter_busstop_place'), ('fleet', '0016_auto_20230611_1507'), ('fleet', '0017_alter_busshift_total_duration'), ('fleet', '0018_alter_busshift_has_enough_stops')] + + 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_datetime', models.DateTimeField(default=datetime.datetime(1, 1, 1, 0, 0, tzinfo=utc), verbose_name='Shift start datetime')), + ('end_datetime', models.DateTimeField(default=datetime.datetime(1, 1, 1, 0, 0, tzinfo=utc), verbose_name='Shift end datetime')), + ('bus', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shifts', to='fleet.bus')), + ('driver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shifts', to='fleet.driver')), + ('total_duration', models.DurationField(default=datetime.timedelta(0), verbose_name='Total shift duration')), + ('has_enough_stops', models.BooleanField(default=False, verbose_name='Has enough stops to be valid')), + ], + ), + migrations.CreateModel( + name='BusStop', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('datetime', models.DateTimeField(verbose_name='Bus stop datetime')), + ('place', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stops', to='geography.place')), + ('shift', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stops', to='fleet.busshift')), + ], + ), + ] diff --git a/padam_django/apps/fleet/models/__init__.py b/padam_django/apps/fleet/models/__init__.py new file mode 100644 index 00000000..f09ecc7c --- /dev/null +++ b/padam_django/apps/fleet/models/__init__.py @@ -0,0 +1,4 @@ +from .bus_model import Bus +from .bus_shift_model import BusShift +from .bus_stop_model import BusStop +from .driver_model import Driver diff --git a/padam_django/apps/fleet/models.py b/padam_django/apps/fleet/models/bus_model.py similarity index 55% rename from padam_django/apps/fleet/models.py rename to padam_django/apps/fleet/models/bus_model.py index 4cd3f19d..4019f7b9 100644 --- a/padam_django/apps/fleet/models.py +++ b/padam_django/apps/fleet/models/bus_model.py @@ -1,13 +1,6 @@ from django.db import models -class Driver(models.Model): - user = models.OneToOneField('users.User', on_delete=models.CASCADE, related_name='driver') - - def __str__(self): - return f"Driver: {self.user.username} (id: {self.pk})" - - class Bus(models.Model): licence_plate = models.CharField("Name of the bus", max_length=10) diff --git a/padam_django/apps/fleet/models/bus_shift_model.py b/padam_django/apps/fleet/models/bus_shift_model.py new file mode 100644 index 00000000..ad942999 --- /dev/null +++ b/padam_django/apps/fleet/models/bus_shift_model.py @@ -0,0 +1,115 @@ +from datetime import datetime, MINYEAR, timedelta + +from django.db import models +from django.db.models.query import QuerySet +from django.utils import timezone + +from padam_django.apps.fleet.models.bus_stop_model import BusStop +from padam_django.apps.fleet.exceptions import ( + DriverOtherShiftsOverlapException, + BusOtherShiftsOverlapException, +) + +DEFAULT_DATETIME_FOR_MISSING_STOPS = datetime( + year=MINYEAR, month=1, day=1, tzinfo=timezone.get_current_timezone() +) + + +class BusShift(models.Model): + bus = models.ForeignKey( + "fleet.Bus", on_delete=models.CASCADE, related_name="shifts" + ) + driver = models.ForeignKey( + "fleet.Driver", on_delete=models.CASCADE, related_name="shifts" + ) + start_datetime = models.DateTimeField( + verbose_name="Shift start datetime", default=DEFAULT_DATETIME_FOR_MISSING_STOPS + ) + end_datetime = models.DateTimeField( + verbose_name="Shift end datetime", default=DEFAULT_DATETIME_FOR_MISSING_STOPS + ) + total_duration = models.DurationField( + verbose_name="Total shift duration", default=timedelta(days=0) + ) + has_enough_stops = models.BooleanField( + verbose_name="Has enough stops to be valid", default=False + ) + + def save(self, *args, **kwargs): + ordered_stops = self.get_ascending_linked_stops() + self.update_stops_related_fields(ordered_stops) + + # Must be done after update_stops_related_fields + self.update_total_duration() + + return super().save(*args, **kwargs) + + def bus_has_overlapping_shifts(self) -> bool: + chosen_bus_shifts: QuerySet[BusShift] = self.bus.shifts + return self.shifts_overlap_with_self(chosen_bus_shifts) + + def driver_has_overlapping_shifts(self) -> bool: + driver_shifts: QuerySet[BusShift] = self.driver.shifts + return self.shifts_overlap_with_self(driver_shifts) + + def shifts_overlap_with_self(self, shifts: QuerySet) -> bool: + return self.shifts_start_overlap_with_self( + shifts + ) or self.shifts_end_overlap_with_self(shifts) + + def shifts_start_overlap_with_self(self, shifts: QuerySet) -> bool: + return ( + shifts.filter( + start_datetime__gt=self.start_datetime, + start_datetime__lt=self.end_datetime, + ) + .exclude(pk=self.pk) + .exists() + ) + + def shifts_end_overlap_with_self(self, shifts: QuerySet) -> bool: + return ( + shifts.filter( + end_datetime__gt=self.start_datetime, + end_datetime__lt=self.end_datetime, + ) + .exclude(pk=self.pk) + .exists() + ) + + def update_total_duration(self) -> None: + self.total_duration = self.end_datetime - self.start_datetime + return + + def get_ascending_linked_stops(self) -> QuerySet[BusStop]: + stops: QuerySet[BusStop] = self.stops.all() + return stops.order_by("datetime") + + def update_stops_related_fields(self, ordered_stops: QuerySet[BusStop]) -> None: + self.update_has_enough_stops(ordered_stops) + self.update_start_datetime(ordered_stops) + self.update_end_datetime(ordered_stops) + return + + def update_has_enough_stops(self, stops: QuerySet[BusStop]) -> bool: + self.has_enough_stops = len(stops) >= 2 + return + + def update_start_datetime(self, ordered_stops: QuerySet[BusStop]) -> None: + first_stop: BusStop = ordered_stops.first() + if first_stop is not None: + self.start_datetime = first_stop.datetime + else: + self.start_datetime = DEFAULT_DATETIME_FOR_MISSING_STOPS + return + + def update_end_datetime(self, ordered_stops: QuerySet[BusStop]) -> None: + last_stop: BusStop = ordered_stops.last() + if last_stop is not None: + self.end_datetime = last_stop.datetime + else: + self.end_datetime = DEFAULT_DATETIME_FOR_MISSING_STOPS + return + + def __str__(self): + return f"BusShift: {self.bus} by {self.driver} from {self.start_datetime} to {self.end_datetime} (id: {self.pk})" diff --git a/padam_django/apps/fleet/models/bus_stop_model.py b/padam_django/apps/fleet/models/bus_stop_model.py new file mode 100644 index 00000000..a15d98cd --- /dev/null +++ b/padam_django/apps/fleet/models/bus_stop_model.py @@ -0,0 +1,14 @@ +from django.db import models + + +class BusStop(models.Model): + place = models.ForeignKey( + "geography.Place", on_delete=models.CASCADE, related_name="stops" + ) + datetime = models.DateTimeField(verbose_name="Bus stop datetime") + shift = models.ForeignKey( + "fleet.BusShift", on_delete=models.CASCADE, related_name="stops" + ) + + def __str__(self): + return f"BusStop: {self.place} the {self.datetime.date()} at {self.datetime.time()} (id: {self.pk})" diff --git a/padam_django/apps/fleet/models/driver_model.py b/padam_django/apps/fleet/models/driver_model.py new file mode 100644 index 00000000..337a5465 --- /dev/null +++ b/padam_django/apps/fleet/models/driver_model.py @@ -0,0 +1,10 @@ +from django.db import models + + +class Driver(models.Model): + user = models.OneToOneField( + "users.User", on_delete=models.CASCADE, related_name="driver" + ) + + def __str__(self): + return f"Driver: {self.user.username} (id: {self.pk})" diff --git a/padam_django/apps/fleet/signals/__init__.py b/padam_django/apps/fleet/signals/__init__.py new file mode 100644 index 00000000..7a831011 --- /dev/null +++ b/padam_django/apps/fleet/signals/__init__.py @@ -0,0 +1,2 @@ +from .bus_stop_signals import * +from .bus_shift_signals import * diff --git a/padam_django/apps/fleet/signals/bus_shift_signals.py b/padam_django/apps/fleet/signals/bus_shift_signals.py new file mode 100644 index 00000000..74f30521 --- /dev/null +++ b/padam_django/apps/fleet/signals/bus_shift_signals.py @@ -0,0 +1,24 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from padam_django.apps.fleet.models import BusShift +from padam_django.apps.fleet.exceptions import ( + DriverOtherShiftsOverlapException, + BusOtherShiftsOverlapException, +) + + +@receiver(post_save, sender=BusShift) +def ensure_overlapping_is_fine( + sender, instance: BusShift, using, update_fields, **kwargs +): + if instance.bus_has_overlapping_shifts(): + raise BusOtherShiftsOverlapException( + f"{instance.bus} can't be assigned to shift." + ) + elif instance.driver_has_overlapping_shifts(): + raise DriverOtherShiftsOverlapException( + f"{instance.driver} can't be assigned to shift." + ) + else: + return diff --git a/padam_django/apps/fleet/signals/bus_stop_signals.py b/padam_django/apps/fleet/signals/bus_stop_signals.py new file mode 100644 index 00000000..2803cf6d --- /dev/null +++ b/padam_django/apps/fleet/signals/bus_stop_signals.py @@ -0,0 +1,26 @@ +from django.db.models.signals import post_delete, post_save +from django.dispatch import receiver + +from padam_django.apps.fleet.models import BusStop +from padam_django.apps.fleet.exceptions import ( + DriverOtherShiftsOverlapException, + BusOtherShiftsOverlapException, + StopWouldOverlapBusOtherShifts, + StopWouldOverlapDriverOtherShifts, +) + + +@receiver(post_delete, sender=BusStop) +@receiver(post_save, sender=BusStop) +def update_linked_shift(sender, instance: BusStop, using, **kwargs): + try: + instance.shift.save() + except DriverOtherShiftsOverlapException: + raise StopWouldOverlapDriverOtherShifts( + f"Can't set stop {instance.pk} to {instance.datetime}. Would overlap driver's other shifts." + ) + except BusOtherShiftsOverlapException: + raise StopWouldOverlapBusOtherShifts( + f"Can't set stop {instance.pk} to {instance.datetime}. Would overlap bus's other shifts." + ) + return diff --git a/requirements.txt b/requirements.txt index 89c9f0c8..0f1b9c94 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,36 @@ +asgiref==3.7.2 +backcall==0.2.0 +black==23.3.0 +click==8.1.3 +decorator==5.1.1 Django==3.2.5 - django-extensions==3.1.3 -Werkzeug==2.0.1 -ipython==7.25.0 - factory-boy==3.2.0 Faker==8.10.1 +flake8==6.0.0 +ipython==7.25.0 +jedi==0.18.2 +matplotlib-inline==0.1.6 +mccabe==0.7.0 +mypy-extensions==1.0.0 +packaging==23.1 +parso==0.8.3 +pathspec==0.11.1 +pexpect==4.8.0 +pickleshare==0.7.5 +platformdirs==3.5.3 +prompt-toolkit==3.0.38 +ptyprocess==0.7.0 +pycodestyle==2.10.0 +pyflakes==3.0.1 +Pygments==2.15.1 +python-dateutil==2.8.2 +pytz==2023.3 +six==1.16.0 +sqlparse==0.4.4 +text-unidecode==1.3 +tomli==2.0.1 +traitlets==5.9.0 +typing_extensions==4.6.3 +wcwidth==0.2.6 +Werkzeug==2.0.1