diff --git a/Makefile b/Makefile index 4062f4c4..5be5f943 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,21 @@ run: ## Run the test server. python manage.py runserver_plus +test: ## Run unittests. + python manage.py test + +makemigrations: ## Create migration files + python manage.py makemigrations + +migrate: ## Run new migrations + python manage.py migrate + install: ## Install the python requirements. pip install -r requirements.txt + +install-dev: ## Install the python requirements as well as some dev specific ones. + pip install -r requirements-dev.txt + +lint: ## Run code linter and formatter + black . -S + flake8 . diff --git a/docs/DATAMODEL.md b/docs/DATAMODEL.md new file mode 100644 index 00000000..ad60c741 --- /dev/null +++ b/docs/DATAMODEL.md @@ -0,0 +1,41 @@ +# Complete data model of the travel app + +```mermaid +erDiagram + User||--o{ StartBusStop: waits + User||--o| Driver: is + User { + Charfield username + } + Driver { + } + Bus { + CharField licence_plate + } + Place||--o{ BusStop: stops + Place { + CharField name + DecimalField longitude + DecimalField latitude + } + BusStop}|--|{ StartBusStop: inherits + BusStop}|--|{ EndBusStop: inherits + BusStop{ + DateField ts_create + DateField ts_update + DateField ts_requested + DateField ts_estimated + DateField ts_boarded + BooleanField has_boarded + } + StartBusStop||--o| EndBusStop: travels + StartBusStop { + } + EndBusStop { + } + BusShift}o--|| Driver: drives + BusShift}o--|| Bus: books + BusShift|o--|{ EndBusStop: stops + BusShift { + } +``` diff --git a/padam_django/apps/common/fields.py b/padam_django/apps/common/fields.py new file mode 100644 index 00000000..d81af709 --- /dev/null +++ b/padam_django/apps/common/fields.py @@ -0,0 +1,18 @@ +from django.db import models + + +class TsCreateField(models.DateTimeField): + def __init__(self, *args, **kwargs): + kwargs.setdefault("auto_now_add", True) + kwargs.setdefault("auto_now", False) + kwargs.setdefault("verbose_name", "Creation date") + kwargs.setdefault("help_text", "Date at which the object was created.") + super().__init__(*args, **kwargs) + + +class TsUpdateField(models.DateTimeField): + def __init__(self, *args, **kwargs): + kwargs.setdefault("auto_now", True) + kwargs.setdefault("verbose_name", "Last update date") + kwargs.setdefault("help_text", "Date at which the object was updated.") + super().__init__(*args, **kwargs) diff --git a/padam_django/apps/common/management/base.py b/padam_django/apps/common/management/base.py index 6449aa08..0167d34c 100644 --- a/padam_django/apps/common/management/base.py +++ b/padam_django/apps/common/management/base.py @@ -2,7 +2,6 @@ class CreateDataBaseCommand(BaseCommand): - def __init__(self, *args, **kwargs): self.number = None super().__init__(*args, **kwargs) diff --git a/padam_django/apps/common/management/commands/create_data.py b/padam_django/apps/common/management/commands/create_data.py index a149a937..274267fc 100644 --- a/padam_django/apps/common/management/commands/create_data.py +++ b/padam_django/apps/common/management/commands/create_data.py @@ -4,7 +4,6 @@ class Command(BaseCommand): - help = 'Create test data' def handle(self, *args, **options): diff --git a/padam_django/apps/common/models.py b/padam_django/apps/common/models.py new file mode 100644 index 00000000..88d7d0f3 --- /dev/null +++ b/padam_django/apps/common/models.py @@ -0,0 +1,10 @@ +from django.db import models +from .fields import TsCreateField, TsUpdateField + + +class TsCreateUpdateMixin(models.Model): + ts_create = TsCreateField() + ts_update = TsUpdateField() + + class Meta: + abstract = True diff --git a/padam_django/apps/common/validators.py b/padam_django/apps/common/validators.py new file mode 100644 index 00000000..5b4953e6 --- /dev/null +++ b/padam_django/apps/common/validators.py @@ -0,0 +1,12 @@ +import datetime + +from django.core.exceptions import ValidationError + + +def validate_future_date(value: datetime.datetime): + now = datetime.datetime.now(tz=datetime.timezone.utc) + if value < now: + raise ValidationError( + "%(value)s is set before the current date %(now)s", + params={"value": value.isoformat(), "now": now.isoformat()}, + ) diff --git a/padam_django/apps/fleet/management/commands/create_buses.py b/padam_django/apps/fleet/management/commands/create_buses.py index eaadc0a8..ef3f0893 100644 --- a/padam_django/apps/fleet/management/commands/create_buses.py +++ b/padam_django/apps/fleet/management/commands/create_buses.py @@ -4,7 +4,6 @@ class Command(CreateDataBaseCommand): - help = 'Create few buses' def handle(self, *args, **options): diff --git a/padam_django/apps/fleet/management/commands/create_drivers.py b/padam_django/apps/fleet/management/commands/create_drivers.py index cd5f9db6..d42aa723 100644 --- a/padam_django/apps/fleet/management/commands/create_drivers.py +++ b/padam_django/apps/fleet/management/commands/create_drivers.py @@ -4,7 +4,6 @@ class Command(CreateDataBaseCommand): - help = 'Create few drivers' def handle(self, *args, **options): diff --git a/padam_django/apps/fleet/migrations/0001_initial.py b/padam_django/apps/fleet/migrations/0001_initial.py index 42817f35..20593101 100644 --- a/padam_django/apps/fleet/migrations/0001_initial.py +++ b/padam_django/apps/fleet/migrations/0001_initial.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [ @@ -17,8 +16,19 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Bus', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('licence_place', models.CharField(max_length=10, verbose_name='Name of the bus')), + ( + 'id', + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'licence_place', + models.CharField(max_length=10, verbose_name='Name of the bus'), + ), ], options={ 'verbose_name_plural': 'Buses', @@ -27,8 +37,22 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Driver', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ( + 'id', + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'user', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), ], ), ] diff --git a/padam_django/apps/fleet/migrations/0002_auto_20211109_1456.py b/padam_django/apps/fleet/migrations/0002_auto_20211109_1456.py index e9c5185d..e3d32700 100644 --- a/padam_django/apps/fleet/migrations/0002_auto_20211109_1456.py +++ b/padam_django/apps/fleet/migrations/0002_auto_20211109_1456.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('fleet', '0001_initial'), @@ -21,6 +20,10 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='driver', name='user', - field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='driver', to=settings.AUTH_USER_MODEL), + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name='driver', + to=settings.AUTH_USER_MODEL, + ), ), ] diff --git a/padam_django/apps/fleet/models.py b/padam_django/apps/fleet/models.py index 4cd3f19d..b92ecfca 100644 --- a/padam_django/apps/fleet/models.py +++ b/padam_django/apps/fleet/models.py @@ -2,7 +2,9 @@ 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})" diff --git a/padam_django/apps/geography/management/commands/create_places.py b/padam_django/apps/geography/management/commands/create_places.py index beb41514..6ab29282 100644 --- a/padam_django/apps/geography/management/commands/create_places.py +++ b/padam_django/apps/geography/management/commands/create_places.py @@ -4,7 +4,6 @@ class Command(CreateDataBaseCommand): - help = 'Create few places' def handle(self, *args, **options): diff --git a/padam_django/apps/geography/migrations/0001_initial.py b/padam_django/apps/geography/migrations/0001_initial.py index 2548bd15..2b37471a 100644 --- a/padam_django/apps/geography/migrations/0001_initial.py +++ b/padam_django/apps/geography/migrations/0001_initial.py @@ -4,20 +4,39 @@ class Migration(migrations.Migration): - initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( name='Place', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=50, verbose_name='Name of the place')), - ('longitude', models.DecimalField(decimal_places=6, max_digits=9, verbose_name='Longitude')), - ('latitude', models.DecimalField(decimal_places=6, max_digits=9, verbose_name='Latitude')), + ( + 'id', + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'name', + models.CharField(max_length=50, verbose_name='Name of the place'), + ), + ( + 'longitude', + models.DecimalField( + decimal_places=6, max_digits=9, verbose_name='Longitude' + ), + ), + ( + 'latitude', + models.DecimalField( + decimal_places=6, max_digits=9, verbose_name='Latitude' + ), + ), ], options={ 'unique_together': {('longitude', 'latitude')}, diff --git a/padam_django/apps/geography/models.py b/padam_django/apps/geography/models.py index e566ee2b..6a9b05c3 100644 --- a/padam_django/apps/geography/models.py +++ b/padam_django/apps/geography/models.py @@ -9,7 +9,7 @@ class Place(models.Model): class Meta: # Two places cannot be located at the same coordinates. - unique_together = (("longitude", "latitude"), ) + unique_together = (("longitude", "latitude"),) def __str__(self): return f"Place: {self.name} (id: {self.pk})" diff --git a/padam_django/apps/travel/__init__.py b/padam_django/apps/travel/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/travel/admin.py b/padam_django/apps/travel/admin.py new file mode 100644 index 00000000..c7842433 --- /dev/null +++ b/padam_django/apps/travel/admin.py @@ -0,0 +1,111 @@ +from django import forms +from django.contrib import admin + +from . import models + + +class BusStopAdminMixin: + list_display = ("place", "ts_requested", "has_boarded") + readonly_fields = ( + "ts_estimated", + "ts_boarded", + "has_boarded", + ) + + +class BusStopFormMixin(forms.ModelForm): + class Meta: + fields = ( + "place", + "ts_requested", + "ts_estimated", + "ts_boarded", + "has_boarded", + ) + + +class StartBusStopForm(BusStopFormMixin): + class Meta: + model = models.StartBusStop + fields = ("user",) + BusStopFormMixin.Meta.fields + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["user"].queryset = self.fields["user"].queryset.filter( + driver__isnull=True + ) + + +@admin.register(models.StartBusStop) +class StartBusStopAdmin(BusStopAdminMixin, admin.ModelAdmin): + list_display = ( + ( + "pk", + "user", + ) + + BusStopAdminMixin.list_display + + ("end_bus_stops",) + ) + + form = StartBusStopForm + + +class EndBusStopForm(BusStopFormMixin): + class Meta: + model = models.EndBusStop + fields = ("start",) + BusStopFormMixin.Meta.fields + + +@admin.register(models.EndBusStop) +class EndBusStopAdmin(BusStopAdminMixin, admin.ModelAdmin): + list_display = ( + "pk", + "start", + ) + BusStopAdminMixin.list_display + form = EndBusStopForm + + +class BusShiftForm(forms.ModelForm): + stops = forms.ModelMultipleChoiceField( + label="List of stops", + help_text="Select any number of stops for this shift", + queryset=models.EndBusStop.objects.filter(shift__isnull=True), + required=True, + ) + + class Meta: + model = models.BusShift + fields = ("driver", "bus", "stops") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance: + end_bus_stops_qs = self.instance.end_bus_stops.all() + self.fields["stops"].initial = end_bus_stops_qs + self.fields["stops"].queryset = ( + self.fields["stops"].queryset | end_bus_stops_qs + ) + + def clean(self): + cleaned_data = super().clean() + stops = cleaned_data.pop("stops") + + instance = models.BusShift(**cleaned_data) + if self.instance: + instance.pk = self.instance.pk + + instance.clean_or_validate(stops) + cleaned_data["stops"] = stops + return cleaned_data + + def save(self, *args, **kwargs): + instance = super().save(commit=False) + instance.save() + self.cleaned_data["stops"].update(shift=instance) + return instance + + +@admin.register(models.BusShift) +class BusShiftAdmin(admin.ModelAdmin): + list_display = ("pk", "driver", "bus", "shift_start", "shift_end", "shift_duration") + form = BusShiftForm diff --git a/padam_django/apps/travel/apps.py b/padam_django/apps/travel/apps.py new file mode 100644 index 00000000..a8c3f9f0 --- /dev/null +++ b/padam_django/apps/travel/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class TravelConfig(AppConfig): + name = "padam_django.apps.travel" diff --git a/padam_django/apps/travel/factories.py b/padam_django/apps/travel/factories.py new file mode 100644 index 00000000..943af6b7 --- /dev/null +++ b/padam_django/apps/travel/factories.py @@ -0,0 +1,10 @@ +import factory +from faker import Faker + + +fake = Faker(["fr"]) + + +class Factory(factory.django.DjangoModelFactory): + # TODO: Build factory + pass diff --git a/padam_django/apps/travel/migrations/0001_initial.py b/padam_django/apps/travel/migrations/0001_initial.py new file mode 100644 index 00000000..828c4a49 --- /dev/null +++ b/padam_django/apps/travel/migrations/0001_initial.py @@ -0,0 +1,188 @@ +# Generated by Django 3.2.5 on 2024-10-27 19:28 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import padam_django.apps.common.fields +import padam_django.apps.common.validators + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('geography', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='StartBusStop', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'ts_create', + padam_django.apps.common.fields.TsCreateField( + auto_now_add=True, + help_text='Date at which the object was created.', + verbose_name='Creation date', + ), + ), + ( + 'ts_update', + padam_django.apps.common.fields.TsUpdateField( + auto_now=True, + help_text='Date at which the object was updated.', + verbose_name='Last update date', + ), + ), + ( + 'ts_requested', + models.DateTimeField( + help_text='Requested time by the user to get picked up', + validators=[ + padam_django.apps.common.validators.validate_future_date + ], + verbose_name='Requested boarding time', + ), + ), + ( + 'ts_estimated', + models.DateTimeField( + blank=True, + help_text='Estimated Bus arrival time by the BusShift algorithm', + null=True, + verbose_name='Estimated boarding time', + ), + ), + ( + 'ts_boarded', + models.DateTimeField( + blank=True, + help_text='Time when the user has been picked up by the bus', + null=True, + verbose_name='Real boarding time', + ), + ), + ( + 'has_boarded', + models.BooleanField( + default=False, + help_text='Checks if the user has boarded the bus', + verbose_name='Has Boarded', + ), + ), + ( + 'place', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='geography.place', + ), + ), + ( + 'user', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='start_bus_stops', + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + 'abstract': False, + 'unique_together': {('user', 'place', 'ts_requested')}, + }, + ), + migrations.CreateModel( + name='EndBusStop', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'ts_create', + padam_django.apps.common.fields.TsCreateField( + auto_now_add=True, + help_text='Date at which the object was created.', + verbose_name='Creation date', + ), + ), + ( + 'ts_update', + padam_django.apps.common.fields.TsUpdateField( + auto_now=True, + help_text='Date at which the object was updated.', + verbose_name='Last update date', + ), + ), + ( + 'ts_requested', + models.DateTimeField( + help_text='Requested time by the user to get picked up', + validators=[ + padam_django.apps.common.validators.validate_future_date + ], + verbose_name='Requested boarding time', + ), + ), + ( + 'ts_estimated', + models.DateTimeField( + blank=True, + help_text='Estimated Bus arrival time by the BusShift algorithm', + null=True, + verbose_name='Estimated boarding time', + ), + ), + ( + 'ts_boarded', + models.DateTimeField( + blank=True, + help_text='Time when the user has been picked up by the bus', + null=True, + verbose_name='Real boarding time', + ), + ), + ( + 'has_boarded', + models.BooleanField( + default=False, + help_text='Checks if the user has boarded the bus', + verbose_name='Has Boarded', + ), + ), + ( + 'place', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='geography.place', + ), + ), + ( + 'start', + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name='end_bus_stops', + to='travel.startbusstop', + ), + ), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/padam_django/apps/travel/migrations/0002_added_bus_shift_model.py b/padam_django/apps/travel/migrations/0002_added_bus_shift_model.py new file mode 100644 index 00000000..9fd94342 --- /dev/null +++ b/padam_django/apps/travel/migrations/0002_added_bus_shift_model.py @@ -0,0 +1,132 @@ +# Generated by Django 3.2.5 on 2024-10-27 21:22 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import padam_django.apps.common.fields + + +class Migration(migrations.Migration): + dependencies = [ + ('geography', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('fleet', '0002_auto_20211109_1456'), + ('travel', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='endbusstop', + options={'default_related_name': 'end_bus_stops'}, + ), + migrations.AlterModelOptions( + name='startbusstop', + options={'default_related_name': 'start_bus_stops'}, + ), + migrations.AlterField( + model_name='endbusstop', + name='place', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='end_bus_stops', + to='geography.place', + ), + ), + migrations.AlterField( + model_name='endbusstop', + name='start', + field=models.OneToOneField( + help_text='Starting BusStop that will form an itinerary', + on_delete=django.db.models.deletion.CASCADE, + related_name='end_bus_stops', + to='travel.startbusstop', + verbose_name='Starting BusStop', + ), + ), + migrations.AlterField( + model_name='startbusstop', + name='place', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='start_bus_stops', + to='geography.place', + ), + ), + migrations.AlterField( + model_name='startbusstop', + name='user', + field=models.ForeignKey( + help_text='User that booked a bus', + on_delete=django.db.models.deletion.CASCADE, + related_name='start_bus_stops', + to=settings.AUTH_USER_MODEL, + verbose_name='User', + ), + ), + migrations.CreateModel( + name='BusShift', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'ts_create', + padam_django.apps.common.fields.TsCreateField( + auto_now_add=True, + help_text='Date at which the object was created.', + verbose_name='Creation date', + ), + ), + ( + 'ts_update', + padam_django.apps.common.fields.TsUpdateField( + auto_now=True, + help_text='Date at which the object was updated.', + verbose_name='Last update date', + ), + ), + ( + 'bus', + models.ForeignKey( + help_text='Bus for this shift', + on_delete=django.db.models.deletion.CASCADE, + related_name='shifts', + to='fleet.bus', + verbose_name='Bus', + ), + ), + ( + 'driver', + models.ForeignKey( + help_text='Driver for this shift', + on_delete=django.db.models.deletion.CASCADE, + related_name='shifts', + to='fleet.driver', + verbose_name='Bus driver', + ), + ), + ], + options={ + 'default_related_name': 'shifts', + }, + ), + migrations.AddField( + model_name='endbusstop', + name='shift', + field=models.ForeignKey( + blank=True, + help_text='Bus shift that will handle these stops', + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='end_bus_stops', + to='travel.busshift', + verbose_name='Bus shift', + ), + ), + ] diff --git a/padam_django/apps/travel/migrations/__init__.py b/padam_django/apps/travel/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/travel/models.py b/padam_django/apps/travel/models.py new file mode 100644 index 00000000..9d3e2337 --- /dev/null +++ b/padam_django/apps/travel/models.py @@ -0,0 +1,275 @@ +from django.core.exceptions import ValidationError +from django.db import models + +from padam_django.apps.common.models import TsCreateUpdateMixin +from padam_django.apps.common.validators import validate_future_date + + +class BusStop(TsCreateUpdateMixin): + """ + Base class for the BusStop models. + + The user must only fill the following fields: + - place: select where the user will be picked up + - ts_requested: select at what time the user should be picked up + + The other fields are for object usage monitoring: + - ts_create + - ts_update + + For feedback with the BusShift algorithm: + - ts_estimated + - ts_boarded + - has_boarded + """ + + place = models.ForeignKey("geography.Place", on_delete=models.CASCADE) + ts_requested = models.DateTimeField( + verbose_name="Requested boarding time", + help_text="Requested time by the user to get picked up", + blank=False, + null=False, + validators=[validate_future_date], + ) + + # The following fields are not user inputs + ts_estimated = models.DateTimeField( + verbose_name="Estimated boarding time", + help_text="Estimated Bus arrival time by the BusShift algorithm", + blank=True, + null=True, + ) + ts_boarded = models.DateTimeField( + verbose_name="Real boarding time", + help_text="Time when the user has been picked up by the bus", + blank=True, + null=True, + ) + has_boarded = models.BooleanField( + verbose_name="Has Boarded", + help_text="Checks if the user has boarded the bus", + default=False, + ) + # TODO: In a later version, allow cancellation of BusStop for soft deletion + + class Meta: + abstract = True + + def __str__(self): + return f"User: {self.user.username}, Place: {self.place.name}, Time: {self.ts_requested.isoformat()} (id: {self.pk})" + + def _get_start_pk(self): + raise NotImplementedError + + def _clean_overlapping_itinerary(self): + """ + Checks if a created itinerary doesn't overlap with another one. + """ + itinerary_qs = self.user.start_bus_stops.select_related( + "end_bus_stops" + ).exclude(end_bus_stops__isnull=True) + if self.pk: + itinerary_qs = itinerary_qs.exclude(pk=self._get_start_pk()) + + itinerary_list = itinerary_qs.values_list( + "ts_requested", "end_bus_stops__ts_requested" + ) + for i in itinerary_list: + ts_start = i[0] + ts_end = i[1] + if ts_start <= self.ts_requested and self.ts_requested <= ts_end: + raise ValidationError( + { + "ts_requested": f"Can't start an itinerary because one is already planned for this time period: {ts_start.isoformat()} -> {ts_end.isoformat()}", + } + ) + + def clean(self): + super().clean() + self._clean_overlapping_itinerary() + + +class StartBusStop(BusStop): + """ + The user must first create a `StartBusStop` and then assign an `EndBusStop` to it + if he wants it to be taken into account into the BusShift algorithm. + """ + + user = models.ForeignKey( + "users.User", + verbose_name="User", + help_text="User that booked a bus", + on_delete=models.CASCADE, + ) + + class Meta: + unique_together = (("user", "place", "ts_requested"),) + default_related_name = "start_bus_stops" + abstract = False + + @property + def has_end(self) -> bool: + """Define if the BusStop has an end.""" + return hasattr(self, "end") + + def _get_start_pk(self): + return self.pk + + def clean(self): + """ + To simplify the test, we consider that the drivers are not able to set Bus Stop like passengers + """ + super().clean() + if self.user.is_driver: + raise ValidationError( + {"user": f"The user {self.user.username} is a Driver."} + ) + + +class EndBusStop(BusStop): + """ + A start and an end `BusStop` have a One to One relationship. The pair form an itinerary. + """ + + start = models.OneToOneField( + "travel.StartBusStop", + verbose_name="Starting BusStop", + help_text="Starting BusStop that will form an itinerary", + on_delete=models.CASCADE, + ) + shift = models.ForeignKey( + "travel.BusShift", + verbose_name="Bus shift", + help_text="Bus shift that will handle these stops", + blank=True, + null=True, + on_delete=models.SET_NULL, + ) + + class Meta: + default_related_name = "end_bus_stops" + abstract = False + + @property + def user(self): + return self.start.user + + def clean(self): + super().clean() + start = self.start + + start_place = start.place + if self.place == start_place: + raise ValidationError( + { + "place": f"The itinerary is a round trip: {self.place.name} -> {start_place}", + } + ) + + start_requested = start.ts_requested + if self.ts_requested <= start_requested: + raise ValidationError( + { + "ts_requested": f"The end date of the itinerary {self.ts_requested.isoformat()} can't be before the start date of the itinerary {start_requested.isoformat()}", + } + ) + + def _get_start_pk(self): + return self.start.pk + + +class BusShift(TsCreateUpdateMixin, models.Model): + driver = models.ForeignKey( + "fleet.Driver", + verbose_name="Bus driver", + help_text="Driver for this shift", + on_delete=models.CASCADE, + ) + bus = models.ForeignKey( + "fleet.Bus", + verbose_name="Bus", + help_text="Bus for this shift", + on_delete=models.CASCADE, + ) + + class Meta: + default_related_name = "shifts" + + def __str__(self): + return f"Driver: {self.driver.user.username}, Bus: {self.bus.licence_plate} (id: {self.pk})" + + @property + def stops(self): + return self.end_bus_stops.select_related("start") + + def _get_shift_boundaries(self, shift_qs): + return shift_qs.aggregate( + start=models.Min("start__ts_requested"), + end=models.Max("ts_requested"), + ) + + @property + def shift_start(self): + return self._get_shift_boundaries(self.stops)["start"] + + @property + def shift_end(self): + return self._get_shift_boundaries(self.stops)["end"] + + @property + def shift_duration(self): + boundary = self._get_shift_boundaries(self.stops) + if boundary["end"] and boundary["start"]: + return boundary["end"] - boundary["start"] + return None + + def _clean_object_available(self, obj, shift_boundary, field_name): + def raise_error_message(start, end): + raise ValidationError( + { + field_name: f"The {field_name} is already booked for the period {start.isoformat()} -> {end.isoformat()}" + } + ) + + current_shift_start = shift_boundary["start"] + current_shift_end = shift_boundary["end"] + + shifts_qs = obj.shifts.prefetch_related( + "end_bus_stops", "end_bus_stops__start_bus_stops" + ) + if self.pk: + shifts_qs = shifts_qs.exclude(pk=self.pk) + shift_list = shifts_qs.values("pk").annotate( + start=models.Min("end_bus_stops__start__ts_requested"), + end=models.Max("end_bus_stops__ts_requested"), + ) + for s in shift_list: + shift_start = s["start"] + shift_end = s["end"] + if ( + (shift_start <= current_shift_end <= shift_end) + or (shift_start <= current_shift_start <= shift_end) + or ( + current_shift_start <= shift_start + and shift_end <= current_shift_end + ) + ): + raise_error_message(shift_start, shift_end) + + def _clean_bus(self, shift_boundary): + self._clean_object_available( + self.bus, + shift_boundary, + field_name="bus", + ) + + def _clean_driver(self, shift_boundary): + self._clean_object_available(self.driver, shift_boundary, field_name="driver") + + def clean_or_validate(self, stops): + """ + Checking for stops will be done through form validation + """ + shift_boundary = self._get_shift_boundaries(stops) + self._clean_bus(shift_boundary) + self._clean_driver(shift_boundary) diff --git a/padam_django/apps/travel/tests.py b/padam_django/apps/travel/tests.py new file mode 100644 index 00000000..8b12538b --- /dev/null +++ b/padam_django/apps/travel/tests.py @@ -0,0 +1,149 @@ +import datetime + +from django.core import management +from django.core.exceptions import ValidationError +from django.test import TestCase + +from padam_django.apps.fleet.models import Bus, Driver +from padam_django.apps.geography.models import Place +from padam_django.apps.travel.models import BusShift, StartBusStop, EndBusStop +from padam_django.apps.users.models import User + + +class TravelTestCase(TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + management.call_command("create_data") + + def test_bus_stop_validation(self): + user = User.objects.filter(driver__isnull=True).first() + user_driver = User.objects.filter(driver__isnull=False).first() + place = Place.objects.first() + now = datetime.datetime.now(tz=datetime.timezone.utc) + wrong_ts_requested = now - datetime.timedelta(days=1) + + # Testing ts_requested validator + with self.assertRaises(ValidationError): + start_bus_stop = StartBusStop( + user=user, place=place, ts_requested=wrong_ts_requested + ) + start_bus_stop.full_clean() + + ts_requested = now + datetime.timedelta(days=1) + # A driver can't be a passenger + with self.assertRaises(ValidationError): + start_bus_stop = StartBusStop( + user=user_driver, place=place, ts_requested=wrong_ts_requested + ) + start_bus_stop.full_clean() + + start_bus_stop = StartBusStop.objects.create( + user=user, place=place, ts_requested=ts_requested + ) + start_bus_stop.full_clean() + + end_ts_requested = ts_requested + datetime.timedelta(minutes=30) + with self.assertRaises(ValidationError): + end_bus_stop = EndBusStop( + start=start_bus_stop, place=place, ts_requested=end_ts_requested + ) + end_bus_stop.full_clean() + + end_place = Place.objects.exclude(pk=place.pk).first() + with self.assertRaises(ValidationError): + end_bus_stop = EndBusStop( + start=start_bus_stop, place=end_place, ts_requested=ts_requested + ) + end_bus_stop.full_clean() + + end_bus_stop = EndBusStop.objects.create( + start=start_bus_stop, place=end_place, ts_requested=end_ts_requested + ) + end_bus_stop.full_clean() + + # Itinerary can't overlap + between_ts_requested = ts_requested + (end_ts_requested - ts_requested) / 2 + with self.assertRaises(ValidationError): + start_bus_stop = StartBusStop( + user=user, place=place, ts_requested=between_ts_requested + ) + start_bus_stop.full_clean() + + # The overlap checks only for the current user + user_2 = User.objects.filter(driver__isnull=True).exclude(pk=user.pk).first() + start_bus_stop = StartBusStop.objects.create( + user=user_2, place=place, ts_requested=between_ts_requested + ) + start_bus_stop.full_clean() + + ts_requested_2 = ts_requested - datetime.timedelta(minutes=30) + start_bus_stop_2 = StartBusStop.objects.create( + user=user, place=place, ts_requested=ts_requested_2 + ) + start_bus_stop_2.full_clean() + + # Itinerary can't overlap + with self.assertRaises(ValidationError): + end_bus_stop = EndBusStop( + start=start_bus_stop_2, place=place, ts_requested=between_ts_requested + ) + end_bus_stop.full_clean() + + def test_bus_shift_validation(self): + passenger = User.objects.filter(driver__isnull=True).first() + passenger_2 = ( + User.objects.exclude(pk=passenger.pk).filter(driver__isnull=True).first() + ) + place = Place.objects.first() + place_2 = Place.objects.exclude(pk=place.pk).first() + now = datetime.datetime.now(tz=datetime.timezone.utc) + + ts_requested = now + datetime.timedelta(days=1) + start_bus_stop = StartBusStop.objects.create( + user=passenger, place=place, ts_requested=ts_requested + ) + end_ts_requested = ts_requested + datetime.timedelta(minutes=30) + end_bus_stop = EndBusStop.objects.create( + start=start_bus_stop, place=place_2, ts_requested=end_ts_requested + ) + + driver = Driver.objects.first() + bus = Bus.objects.first() + bus_shift = BusShift.objects.create(driver=driver, bus=bus) + end_bus_stop.shift = bus_shift + end_bus_stop.save(update_fields=["shift"]) + self.assertEquals(bus_shift.shift_start, ts_requested) + self.assertEquals(bus_shift.shift_end, end_ts_requested) + self.assertEquals(bus_shift.shift_duration, end_ts_requested - ts_requested) + + ts_requested_2 = ts_requested - datetime.timedelta(minutes=30) + start_bus_stop_2 = StartBusStop.objects.create( + user=passenger_2, place=place, ts_requested=ts_requested_2 + ) + end_ts_requested_2 = end_ts_requested + datetime.timedelta(minutes=30) + end_bus_stop_2 = EndBusStop.objects.create( + start=start_bus_stop_2, place=place_2, ts_requested=end_ts_requested_2 + ) + driver_2 = Driver.objects.exclude(pk=driver.pk).first() + bus_2 = Bus.objects.exclude(pk=bus.pk).first() + + stops = EndBusStop.objects.filter(pk=end_bus_stop_2.pk) + # The bus is already used by the first shift + with self.assertRaises(ValidationError): + bus_shift_2 = BusShift(driver=driver_2, bus=bus) + bus_shift_2.clean_or_validate(stops) + + # The driver is already on the first shift + with self.assertRaises(ValidationError): + bus_shift_2 = BusShift(driver=driver, bus=bus_2) + bus_shift_2.clean_or_validate(stops) + + bus_shift_2 = BusShift(driver=driver_2, bus=bus_2) + bus_shift_2.clean_or_validate(stops) + + end_bus_stop_2.shift = bus_shift + end_bus_stop_2.save(update_fields=["shift"]) + self.assertEquals(bus_shift.shift_start, ts_requested_2) + self.assertEquals(bus_shift.shift_end, end_ts_requested_2) + self.assertEquals(bus_shift.shift_duration, end_ts_requested_2 - ts_requested_2) diff --git a/padam_django/apps/users/admin.py b/padam_django/apps/users/admin.py index 2bc531c6..519d3da3 100644 --- a/padam_django/apps/users/admin.py +++ b/padam_django/apps/users/admin.py @@ -9,5 +9,6 @@ class UserAdmin(admin.ModelAdmin): def is_driver(self, obj): return obj.is_driver + is_driver.boolean = True is_driver.short_description = 'Is driver' diff --git a/padam_django/apps/users/management/commands/create_users.py b/padam_django/apps/users/management/commands/create_users.py index 5b71b863..85453084 100644 --- a/padam_django/apps/users/management/commands/create_users.py +++ b/padam_django/apps/users/management/commands/create_users.py @@ -4,7 +4,6 @@ class Command(CreateDataBaseCommand): - help = 'Create few users' def handle(self, *args, **options): diff --git a/padam_django/apps/users/migrations/0001_initial.py b/padam_django/apps/users/migrations/0001_initial.py index 2dea4751..0b45df03 100644 --- a/padam_django/apps/users/migrations/0001_initial.py +++ b/padam_django/apps/users/migrations/0001_initial.py @@ -7,7 +7,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [ @@ -18,19 +17,107 @@ class Migration(migrations.Migration): migrations.CreateModel( name='User', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ( + 'id', + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), - ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ( + 'last_login', + models.DateTimeField( + blank=True, null=True, verbose_name='last login' + ), + ), + ( + 'is_superuser', + models.BooleanField( + default=False, + help_text='Designates that this user has all permissions without explicitly assigning them.', + verbose_name='superuser status', + ), + ), + ( + 'username', + models.CharField( + error_messages={ + 'unique': 'A user with that username already exists.' + }, + help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name='username', + ), + ), + ( + 'first_name', + models.CharField( + blank=True, max_length=150, verbose_name='first name' + ), + ), + ( + 'last_name', + models.CharField( + blank=True, max_length=150, verbose_name='last name' + ), + ), + ( + 'email', + models.EmailField( + blank=True, max_length=254, verbose_name='email address' + ), + ), + ( + 'is_staff', + models.BooleanField( + default=False, + help_text='Designates whether the user can log into this admin site.', + verbose_name='staff status', + ), + ), + ( + 'is_active', + models.BooleanField( + default=True, + help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', + verbose_name='active', + ), + ), + ( + 'date_joined', + models.DateTimeField( + default=django.utils.timezone.now, verbose_name='date joined' + ), + ), + ( + 'groups', + models.ManyToManyField( + blank=True, + help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', + related_name='user_set', + related_query_name='user', + to='auth.Group', + verbose_name='groups', + ), + ), + ( + 'user_permissions', + models.ManyToManyField( + blank=True, + help_text='Specific permissions for this user.', + related_name='user_set', + related_query_name='user', + to='auth.Permission', + verbose_name='user permissions', + ), + ), ], options={ 'verbose_name': 'user', diff --git a/padam_django/apps/users/models.py b/padam_django/apps/users/models.py index 672f6a15..0a991993 100644 --- a/padam_django/apps/users/models.py +++ b/padam_django/apps/users/models.py @@ -2,7 +2,6 @@ class User(AbstractUser): - @property def is_driver(self) -> bool: """Define if the user is related to a driver.""" diff --git a/padam_django/settings.py b/padam_django/settings.py index 129e922c..d21df7ca 100644 --- a/padam_django/settings.py +++ b/padam_django/settings.py @@ -44,6 +44,7 @@ 'padam_django.apps.common', 'padam_django.apps.fleet', 'padam_django.apps.geography', + 'padam_django.apps.travel', 'padam_django.apps.users', ] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..76082aad --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +black==23.3.0 +flake8==5.0.4 diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..0b801e16 --- /dev/null +++ b/tox.ini @@ -0,0 +1,3 @@ +[flake8] +extend-ignore = E501 +exclude = .git,__pycache__ \ No newline at end of file