diff --git a/Makefile b/Makefile index 4062f4c4..c1d156fc 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,51 @@ +# N: Set the number of test data items to create (default: 5) +# Example: make create_test_data/buses N=10 +N ?= 5 + +.PHONY: help +help: ## Show this help message. + @echo "Usage: make [target]" + @echo "" + @echo "Available targets:" + @grep -E '^[a-zA-Z_/\-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " %-30s %s\n", $$1, $$2}' + @echo "" + @echo "Options:" + @echo " N= Set the number of test data items to create (default: 5)" + @echo "" + @echo "Examples:" + @echo " make create_test_data/buses" + @echo " make create_test_data/buses N=10" + @echo " make create_test_data/drivers N=3" + run: ## Run the test server. python manage.py runserver_plus install: ## Install the python requirements. pip install -r requirements.txt + +migrate: ## Apply migrations. + python manage.py migrate + +test: ## Run tests. + python manage.py test + +create_test_data: ## Create test data. + python manage.py create_data + +create_test_data/buses: ## Create test data for buses. + python manage.py create_buses -n $(N) + +create_test_data/drivers: ## Create test data for drivers. + python manage.py create_drivers -n $(N) + +create_test_data/places: ## Create test data for places. + python manage.py create_places -n $(N) + +create_test_data/users: ## Create test data for users. + python manage.py create_users -n $(N) + +create_test_data/bus_shifts: ## Create test data for bus shifts. + python manage.py create_bus_shifts -n $(N) + +create_super_user: ## Create a superuser. + python manage.py createsuperuser \ No newline at end of file diff --git a/README_project.md b/README_project.md new file mode 100644 index 00000000..62ecd8ea --- /dev/null +++ b/README_project.md @@ -0,0 +1,98 @@ +# Django technical test / Backend (English version first, French version below) + +## Technical stack + +| Name | Version | +| ------ | ------- | +| Python | 3.9 | +| Django | 4.2.16 | + +- This project was created using Python 3.7. You are free to use another version, but this is the one we recommend. +recommended. +- The database is freely selectable. The project is configured to use `sqlite` by default. + +/ ! \ To keep the `ipython==8.29.0` requirement, a minimum Python version of `3.10` is required; it can be removed if necessary, as it is not used in the project. + +### Start the project + +All Makefile commands are accessible with + +```bash +make help +``` + +*From your Python virtualenv*: + +```bash +make install +make migrate +make run +``` + +Scripts are available to help you quickly create data and take control of the project and are available via Makefile targets. +in hand: + +- `create_data` +- `create_buses` +- `create_drivers` +- `create_places` +- `create_users` +- `create_bus_shifts` + +For example: + +```bash +make create_test_data +make create_test_data/drivers N=5 +``` + + + + +# Test technique Django / Backend + +## Stack Technique + +| Nom | Version | +| ------ | ------- | +| Python | 3.9 | +| Django | 4.2.16 | + +- Le projet à été réalisé en utilisant Python 3.7. Vous êtes libre d'utiliser une autre version mais c'est celle que +nous vous conseillons. +- La base de donnée est au choix. Le projet est configuré pour utiliser `sqlite` par défaut. + +/ ! \ Pour conserver le requirement `ipython==8.29.0` il est nécessaire d'utiliser une version minimum `3.10` de Python, il est possible de le retirer si besoin car il n'est pas utilisé dans le projet. + +### Démarrer le projet + +L'ensemble des commandes du Makefile sont accessibles avec + +```bash +make help +``` + +*Depuis votre virtualenv Python*: + +```bash +make install +make migrate +make run +``` + +Des scripts sont à votre disposition pour vous permettre de rapidement créer de la donnée et de prendre le projet en +main et sont disponibles via des targets du Makefile: + +- `create_data` +- `create_buses` +- `create_drivers` +- `create_places` +- `create_users` +- `create_bus_shifts` + +Par exemple: + +```bash +make create_test_data +make create_test_data/drivers N=5 +``` diff --git a/padam_django/apps/busshifts/__init__.py b/padam_django/apps/busshifts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/busshifts/admin.py b/padam_django/apps/busshifts/admin.py new file mode 100644 index 00000000..0911dc97 --- /dev/null +++ b/padam_django/apps/busshifts/admin.py @@ -0,0 +1,106 @@ +from django.contrib import admin +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet +from django.utils import timezone + +from . import models +from .services import ShiftService + +class BusStopInlineFormSet(BaseInlineFormSet): + def _get_valid_forms(self): + return [ + form for form in self.forms + if form.is_valid() and form.cleaned_data and not form.cleaned_data.get('DELETE', False) + ] + + def _get_stops_data(self, valid_forms): + return [ + { + 'place': form.cleaned_data.get('place'), + 'stop_datetime': self._clean_stop_datetime(form.cleaned_data.get('stop_datetime')), + } + for form in valid_forms + ] + + def _clean_stop_datetime(self, stop_datetime): + if isinstance(stop_datetime, str): + value = stop_datetime.replace('Z', '+00:00') + stop_datetime = timezone.datetime.fromisoformat(value) + return stop_datetime + + def clean(self): + super().clean() + + if any(self.errors): + return + + stops_data = self._get_stops_data(self._get_valid_forms()) + + try: + ShiftService.validate_shift_data( + bus=self.instance.bus, + driver=self.instance.driver, + stops_data=stops_data, + exclude_pk=self.instance.pk, + ) + except ValidationError as exc: + raise ValidationError(exc.messages) + + +class BusStopInline(admin.TabularInline): + model = models.BusStop + extra = 1 + formset = BusStopInlineFormSet + autocomplete_fields = ['place'] + + def get_queryset(self, request): + queryset = super().get_queryset(request) + return queryset.select_related('place').order_by('stop_datetime') + + +@admin.register(models.BusShift) +class BusShiftAdmin(admin.ModelAdmin): + inlines = [BusStopInline] + list_display = ('bus', 'driver', 'departure_time_display', 'arrival_time_display', 'duration_display') + readonly_fields = ('departure_time_display', 'arrival_time_display', 'duration_display') + search_fields = ["bus__licence_plate", "driver__user__username", "bus_stops__place__name"] + autocomplete_fields = ['bus', 'driver'] + ordering = ('-departure_time',) + list_filter = ('bus', 'driver', 'departure_time') + exclude = ('departure_time', 'arrival_time') + + def departure_time_display(self, obj): + if not obj.departure_time: + return '-' + return obj.departure_time.strftime('%d/%m/%Y %H:%M') + + departure_time_display.short_description = 'Departure time' + departure_time_display.admin_order_field = 'departure_time' + + def arrival_time_display(self, obj): + if not obj.arrival_time: + return '-' + return obj.arrival_time.strftime('%d/%m/%Y %H:%M') + + arrival_time_display.short_description = 'Arrival time' + arrival_time_display.admin_order_field = 'arrival_time' + + def duration_display(self, obj): + return obj.duration_display + + duration_display.short_description = 'Duration' + + def save_related(self, request, form, formsets, change): + super().save_related(request, form, formsets, change) + ShiftService.recalculate_bus_shift_times(form.instance.pk) + + def get_queryset(self, request): + return super().get_queryset(request).select_related("bus", "driver__user") + + +@admin.register(models.BusStop) +class BusStopAdmin(admin.ModelAdmin): + search_fields = ["place__name"] + + def has_module_permission(self, request): + return False \ No newline at end of file diff --git a/padam_django/apps/busshifts/apps.py b/padam_django/apps/busshifts/apps.py new file mode 100644 index 00000000..9aa1c14f --- /dev/null +++ b/padam_django/apps/busshifts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BusshiftsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'padam_django.apps.busshifts' diff --git a/padam_django/apps/busshifts/factories.py b/padam_django/apps/busshifts/factories.py new file mode 100644 index 00000000..ddb38603 --- /dev/null +++ b/padam_django/apps/busshifts/factories.py @@ -0,0 +1,52 @@ +import factory +from faker import Faker +from datetime import timedelta +from django.utils import timezone + +from . import models +from .services import ShiftService +from padam_django.apps.fleet.factories import BusFactory, DriverFactory +from padam_django.apps.geography.factories import PlaceFactory + +fake = Faker(['fr']) + + +class BusShiftFactory(factory.django.DjangoModelFactory): + + bus = factory.SubFactory(BusFactory) + driver = factory.SubFactory(DriverFactory) + + class Meta: + model = models.BusShift + + @classmethod + def _create(cls, model_class, *args, **kwargs): + bus = kwargs.pop('bus') + driver = kwargs.pop('driver') + stops_data = kwargs.pop('stops_data', None) + + if not stops_data: + num_stops = fake.random_int(min=2, max=5) + base_time = timezone.now().replace(hour=8, minute=0, second=0, microsecond=0) + timedelta(days=fake.random_int(1, 30)) + + stops_data = [] + for i in range(num_stops): + stop_time = base_time + timedelta(hours=i, minutes=fake.random_int(0, 59)) + stops_data.append({ + 'place': PlaceFactory(), + 'stop_datetime': stop_time, + }) + + return ShiftService.create_bus_shift_with_stops( + bus=bus, + driver=driver, + stops_data=stops_data, + ) + +class BusStopFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.BusStop + + place = factory.SubFactory(PlaceFactory) + stop_datetime = factory.LazyFunction(timezone.now) + bus_shift = factory.SubFactory(BusShiftFactory) \ No newline at end of file diff --git a/padam_django/apps/busshifts/management/__init__.py b/padam_django/apps/busshifts/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/busshifts/management/commands/__init__.py b/padam_django/apps/busshifts/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/busshifts/management/commands/create_bus_shifts.py b/padam_django/apps/busshifts/management/commands/create_bus_shifts.py new file mode 100644 index 00000000..65edb8fc --- /dev/null +++ b/padam_django/apps/busshifts/management/commands/create_bus_shifts.py @@ -0,0 +1,13 @@ +from padam_django.apps.common.management.base import CreateDataBaseCommand + +from padam_django.apps.busshifts.factories import BusShiftFactory + + +class Command(CreateDataBaseCommand): + + help = 'Create few bus shifts with stops' + + 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/busshifts/migrations/0001_initial.py b/padam_django/apps/busshifts/migrations/0001_initial.py new file mode 100644 index 00000000..fc55e220 --- /dev/null +++ b/padam_django/apps/busshifts/migrations/0001_initial.py @@ -0,0 +1,57 @@ +# Generated by Django 4.2.16 on 2026-08-17 14:37 + +from django.db import migrations, models +import django.db.models.deletion + + +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')), + ('departure_time', models.DateTimeField(blank=True, null=True, verbose_name='Departure time')), + ('arrival_time', models.DateTimeField(blank=True, null=True, verbose_name='Arrival time')), + ('bus', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='bus_shifts', to='fleet.bus')), + ('driver', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='bus_shifts', to='fleet.driver')), + ], + options={ + 'verbose_name': 'Bus Shift', + 'verbose_name_plural': 'Bus Shifts', + }, + ), + migrations.CreateModel( + name='BusStop', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('stop_datetime', models.DateTimeField(verbose_name='Stop datetime')), + ('bus_shift', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bus_stops', to='busshifts.busshift')), + ('place', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='bus_stops', to='geography.place')), + ], + options={ + 'verbose_name': 'Bus Stop', + 'verbose_name_plural': 'Bus Stops', + 'indexes': [models.Index(fields=['bus_shift', 'stop_datetime'], name='busshifts_b_bus_shi_eb04e2_idx')], + }, + ), + migrations.AddIndex( + model_name='busshift', + index=models.Index(fields=['bus', 'departure_time', 'arrival_time'], name='busshifts_b_bus_id_5bc7ec_idx'), + ), + migrations.AddIndex( + model_name='busshift', + index=models.Index(fields=['driver', 'departure_time', 'arrival_time'], name='busshifts_b_driver__93ec42_idx'), + ), + migrations.AddConstraint( + model_name='busshift', + constraint=models.CheckConstraint(check=models.Q(('arrival_time__gte', models.F('departure_time'))), name='busshift_arrival_after_departure'), + ), + ] diff --git a/padam_django/apps/busshifts/migrations/__init__.py b/padam_django/apps/busshifts/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/busshifts/models.py b/padam_django/apps/busshifts/models.py new file mode 100644 index 00000000..26accf2f --- /dev/null +++ b/padam_django/apps/busshifts/models.py @@ -0,0 +1,105 @@ +from django.core.exceptions import ValidationError +from django.db import models + +from padam_django.apps.fleet.models import Bus, Driver +from padam_django.apps.geography.models import Place +from padam_django.apps.common.helpers import normalize_datetime + +class BusShift(models.Model): + bus = models.ForeignKey(Bus, on_delete=models.PROTECT, related_name='bus_shifts') + driver = models.ForeignKey(Driver, on_delete=models.PROTECT, related_name='bus_shifts') + departure_time = models.DateTimeField("Departure time", null=True, blank=True) + arrival_time = models.DateTimeField("Arrival time", null=True, blank=True) + + class Meta: + verbose_name = "Bus Shift" + verbose_name_plural = "Bus Shifts" + indexes = [ + models.Index(fields=['bus', 'departure_time', 'arrival_time']), + models.Index(fields=['driver', 'departure_time', 'arrival_time']), + ] + constraints = [ + models.CheckConstraint( + check=models.Q(arrival_time__gte=models.F('departure_time')), + name='busshift_arrival_after_departure', + ), + ] + + @property + def duration(self): + if self.departure_time and self.arrival_time: + return self.arrival_time - self.departure_time + return None + + @property + def duration_display(self): + duration = self.duration + if not duration: + return "-" + total_minutes = int(duration.total_seconds() // 60) + hours, minutes = divmod(total_minutes, 60) + return f"{hours:02d} hours and {minutes:02d} minutes" + + def sync_times(self): + from .services import ShiftService + updated_shift = ShiftService.recalculate_bus_shift_times(self.pk) + self.departure_time = updated_shift.departure_time + self.arrival_time = updated_shift.arrival_time + + @staticmethod + def validate_minimum_stops(stop_count): + if stop_count < 2: + raise ValidationError("A bus shift must have at least 2 stops.") + + def clean(self): + super().clean() + from .services import ShiftService + + if not self.bus: + raise ValidationError("A Bus must be assigned to the shift.") + if not self.driver: + raise ValidationError("A Driver must be assigned to the shift.") + + # On update + if self.pk is not None: + stops_data = list(self.bus_stops.values('place', 'stop_datetime')) + if stops_data: + departure_time, arrival_time = ShiftService.validate_shift_data( + bus=self.bus, + driver=self.driver, + stops_data=stops_data, + exclude_pk=self.pk, + ) + self.departure_time = departure_time + self.arrival_time = arrival_time + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) + + def __str__(self): + return f"Bus Shift: {self.bus.licence_plate} driven by {self.driver.user.username} (id: {self.pk})" + + +class BusStop(models.Model): + place = models.ForeignKey(Place, on_delete=models.PROTECT, related_name='bus_stops') + stop_datetime = models.DateTimeField("Stop datetime") + bus_shift = models.ForeignKey(BusShift, on_delete=models.CASCADE, related_name='bus_stops') + + class Meta: + verbose_name = "Bus Stop" + verbose_name_plural = "Bus Stops" + indexes = [ + models.Index(fields=['bus_shift', 'stop_datetime']), + ] + + def clean(self): + super().clean() + self.stop_datetime = normalize_datetime(self.stop_datetime) + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + def __str__(self): + return f"Bus Stop: {self.place.name} (id: {self.pk})" diff --git a/padam_django/apps/busshifts/services.py b/padam_django/apps/busshifts/services.py new file mode 100644 index 00000000..f5e2d4c4 --- /dev/null +++ b/padam_django/apps/busshifts/services.py @@ -0,0 +1,115 @@ +from django.core.exceptions import ValidationError +from django.db import transaction + +from .models import BusShift, BusStop + +from padam_django.apps.common.helpers import normalize_datetime + + +class ShiftService: + @staticmethod + def _validate_shift_overlap(resource_field, resource_value, departure_time, arrival_time, exclude_pk=None, resource_name="resource"): + if not resource_value or not departure_time or not arrival_time: + raise ValidationError("A bus shift must have both departure and arrival times.") + + qs = BusShift.objects.filter( + **{resource_field: resource_value}, + departure_time__lt=arrival_time, + arrival_time__gt=departure_time, + ) + if exclude_pk is not None: + qs = qs.exclude(pk=exclude_pk) + + if qs.exists(): + raise ValidationError( f"This {resource_name} ({resource_value}) is already assigned during this time range.") + + @staticmethod + def _validate_driver_availability(driver, departure_time, arrival_time, exclude_pk=None): + ShiftService._validate_shift_overlap( + resource_field='driver', + resource_value=driver, + departure_time=departure_time, + arrival_time=arrival_time, + exclude_pk=exclude_pk, + resource_name='driver', + ) + + @staticmethod + def _validate_bus_availability(bus, departure_time, arrival_time, exclude_pk=None): + ShiftService._validate_shift_overlap( + resource_field='bus', + resource_value=bus, + departure_time=departure_time, + arrival_time=arrival_time, + exclude_pk=exclude_pk, + resource_name='bus', + ) + + @classmethod + def validate_shift_data(cls, bus, driver, stops_data, exclude_pk=None): + if not bus: + raise ValidationError("A Bus must be assigned to the shift.") + if not driver: + raise ValidationError("A Driver must be assigned to the shift.") + + BusShift.validate_minimum_stops(len(stops_data)) + + stop_datetimes = [stop['stop_datetime'] for stop in stops_data if stop.get('stop_datetime')] + if len(stop_datetimes) < len(stops_data): + raise ValidationError("All stops must include a valid date/time.") + + departure_time = min(stop_datetimes) + arrival_time = max(stop_datetimes) + + ShiftService._validate_driver_availability(driver, departure_time, arrival_time, exclude_pk=exclude_pk) + ShiftService._validate_bus_availability(bus, departure_time, arrival_time, exclude_pk=exclude_pk) + + return departure_time, arrival_time + + @classmethod + @transaction.atomic + def create_bus_shift_with_stops(cls, bus, driver, stops_data): + departure_time, arrival_time = cls.validate_shift_data(bus, driver, stops_data) + + shift = BusShift.objects.create( + bus=bus, + driver=driver, + departure_time=departure_time, + arrival_time=arrival_time, + ) + + bus_stops = [ + BusStop( + bus_shift=shift, + place=stop['place'], + stop_datetime=normalize_datetime(stop['stop_datetime']), + ) + for stop in stops_data + ] + BusStop.objects.bulk_create(bus_stops) + + return shift + + @classmethod + @transaction.atomic + def recalculate_bus_shift_times(cls, bus_shift_id): + shift = BusShift.objects.get(pk=bus_shift_id) + + stops_data = list( + shift.bus_stops.values('place', 'stop_datetime') + ) + + departure_time, arrival_time = cls.validate_shift_data( + bus=shift.bus, + driver=shift.driver, + stops_data=stops_data, + exclude_pk=shift.pk, + ) + + BusShift.objects.filter(pk=bus_shift_id).update( + departure_time=departure_time, + arrival_time=arrival_time, + ) + + shift.refresh_from_db(fields=['departure_time', 'arrival_time']) + return shift \ No newline at end of file diff --git a/padam_django/apps/busshifts/tests.py b/padam_django/apps/busshifts/tests.py new file mode 100644 index 00000000..040e05ca --- /dev/null +++ b/padam_django/apps/busshifts/tests.py @@ -0,0 +1,328 @@ +from datetime import datetime + +from django.contrib import admin +from django.core.exceptions import ValidationError +from django.test import RequestFactory, TestCase +from django.utils import timezone + +from padam_django.apps.busshifts.admin import BusStopInline +from padam_django.apps.busshifts.models import BusShift +from padam_django.apps.busshifts.services import ShiftService + +from padam_django.apps.busshifts.factories import BusShiftFactory +from padam_django.apps.fleet.factories import BusFactory, DriverFactory +from padam_django.apps.geography.factories import PlaceFactory +from padam_django.apps.users.factories import UserFactory + + +class ShiftServiceValidationTests(TestCase): + + def test_validate_shift_data_requires_at_least_two_stops(self): + bus = BusFactory() + driver = DriverFactory() + stops_data = [ + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 1, 8, 30, tzinfo=timezone.utc)}, + ] + + with self.assertRaisesMessage(ValidationError, "A bus shift must have at least 2 stops."): + ShiftService.validate_shift_data(bus, driver, stops_data) + + def test_create_bus_shift_with_two_stops_is_valid(self): + bus = BusFactory() + driver = DriverFactory() + stops_data = [ + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 1, 8, 30, tzinfo=timezone.utc)}, + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 1, 10, 15, tzinfo=timezone.utc)}, + ] + + bus_shift = ShiftService.create_bus_shift_with_stops(bus, driver, stops_data) + + self.assertEqual( + bus_shift.departure_time, + datetime(2024, 1, 1, 8, 30, tzinfo=timezone.utc), + ) + self.assertEqual( + bus_shift.arrival_time, + datetime(2024, 1, 1, 10, 15, tzinfo=timezone.utc), + ) + self.assertEqual(bus_shift.duration_display, "01 hours and 45 minutes") + self.assertEqual(bus_shift.bus_stops.count(), 2) + + def test_create_bus_shift_with_more_than_two_stops_is_valid(self): + bus = BusFactory() + driver = DriverFactory() + stops_data = [ + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 1, 8, 30, tzinfo=timezone.utc)}, + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 1, 10, 15, tzinfo=timezone.utc)}, + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)}, + ] + + bus_shift = ShiftService.create_bus_shift_with_stops(bus, driver, stops_data) + + self.assertEqual( + bus_shift.departure_time, + datetime(2024, 1, 1, 8, 30, tzinfo=timezone.utc), + ) + self.assertEqual( + bus_shift.arrival_time, + datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc), + ) + self.assertEqual(bus_shift.duration_display, "03 hours and 30 minutes") + self.assertEqual(bus_shift.bus_stops.count(), 3) + + def test_driver_cannot_overlap_another_shift(self): + driver = DriverFactory() + bus1, bus2 = BusFactory(), BusFactory() + + BusShiftFactory( + driver=driver, + bus=bus1, + stops_data=[ + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 2, 13, 0, tzinfo=timezone.utc)}, + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 2, 15, 0, tzinfo=timezone.utc)}, + ], + ) + + overlapping_stops = [ + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 2, 14, 30, tzinfo=timezone.utc)}, + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 2, 15, 30, tzinfo=timezone.utc)}, + ] + with self.assertRaisesMessage(ValidationError, f"This driver ({driver}) is already assigned during this time range."): + ShiftService.validate_shift_data(bus2, driver, overlapping_stops) + + valid_stops_before = [ + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 2, 11, 0, tzinfo=timezone.utc)}, + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 2, 12, 0, tzinfo=timezone.utc)}, + ] + dep, arr = ShiftService.validate_shift_data(bus2, driver, valid_stops_before) + self.assertEqual(dep, datetime(2024, 1, 2, 11, 0, tzinfo=timezone.utc)) + self.assertEqual(arr, datetime(2024, 1, 2, 12, 0, tzinfo=timezone.utc)) + + valid_stops_after = [ + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 2, 16, 0, tzinfo=timezone.utc)}, + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 2, 18, 30, tzinfo=timezone.utc)}, + ] + dep, arr = ShiftService.validate_shift_data(bus2, driver, valid_stops_after) + self.assertEqual(dep, datetime(2024, 1, 2, 16, 0, tzinfo=timezone.utc)) + self.assertEqual(arr, datetime(2024, 1, 2, 18, 30, tzinfo=timezone.utc)) + + def test_bus_cannot_overlap_another_shift(self): + bus = BusFactory() + driver1, driver2 = DriverFactory(), DriverFactory() + + BusShiftFactory( + bus=bus, + driver=driver1, + stops_data=[ + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 3, 13, 0, tzinfo=timezone.utc)}, + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 3, 15, 0, tzinfo=timezone.utc)}, + ], + ) + + overlapping_stops = [ + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 3, 14, 30, tzinfo=timezone.utc)}, + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 3, 15, 30, tzinfo=timezone.utc)}, + ] + with self.assertRaisesMessage(ValidationError, f"This bus ({bus}) is already assigned during this time range."): + ShiftService.validate_shift_data(bus, driver2, overlapping_stops) + + valid_stops_before = [ + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 3, 11, 0, tzinfo=timezone.utc)}, + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 3, 12, 0, tzinfo=timezone.utc)}, + ] + dep, arr = ShiftService.validate_shift_data(bus, driver2, valid_stops_before) + self.assertEqual(dep, datetime(2024, 1, 3, 11, 0, tzinfo=timezone.utc)) + self.assertEqual(arr, datetime(2024, 1, 3, 12, 0, tzinfo=timezone.utc)) + + valid_stops_after = [ + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 3, 16, 0, tzinfo=timezone.utc)}, + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 3, 18, 30, tzinfo=timezone.utc)}, + ] + dep, arr = ShiftService.validate_shift_data(bus, driver2, valid_stops_after) + self.assertEqual(dep, datetime(2024, 1, 3, 16, 0, tzinfo=timezone.utc)) + self.assertEqual(arr, datetime(2024, 1, 3, 18, 30, tzinfo=timezone.utc)) + + def test_validate_shift_data_requires_bus_and_driver(self): + bus = BusFactory() + driver = DriverFactory() + stops_data = [ + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 1, 8, 30, tzinfo=timezone.utc)}, + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 1, 10, 0, tzinfo=timezone.utc)}, + ] + + with self.assertRaisesMessage(ValidationError, "A Bus must be assigned to the shift."): + ShiftService.validate_shift_data(None, driver, stops_data) + + with self.assertRaisesMessage(ValidationError, "A Driver must be assigned to the shift."): + ShiftService.validate_shift_data(bus, None, stops_data) + + def test_validate_shift_data_rejects_missing_stop_datetime(self): + bus = BusFactory() + driver = DriverFactory() + stops_data = [ + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 1, 8, 30, tzinfo=timezone.utc)}, + {'place': PlaceFactory(), 'stop_datetime': None}, + ] + + with self.assertRaisesMessage(ValidationError, "All stops must include a valid date/time."): + ShiftService.validate_shift_data(bus, driver, stops_data) + + def test_shift_edge_to_edge_times_do_not_overlap(self): + driver = DriverFactory() + bus1, bus2 = BusFactory(), BusFactory() + + BusShiftFactory( + driver=driver, + bus=bus1, + stops_data=[ + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 2, 8, 0, tzinfo=timezone.utc)}, + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 2, 10, 0, tzinfo=timezone.utc)}, + ], + ) + + exact_edge_stops = [ + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 2, 10, 0, tzinfo=timezone.utc)}, + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 2, 12, 0, tzinfo=timezone.utc)}, + ] + dep, arr = ShiftService.validate_shift_data(bus2, driver, exact_edge_stops) + self.assertEqual(dep, datetime(2024, 1, 2, 10, 0, tzinfo=timezone.utc)) + self.assertEqual(arr, datetime(2024, 1, 2, 12, 0, tzinfo=timezone.utc)) + + def test_validate_shift_data_with_exclude_pk_allows_self_update(self): + driver = DriverFactory() + bus = BusFactory() + + shift = BusShiftFactory( + driver=driver, + bus=bus, + stops_data=[ + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 2, 13, 0, tzinfo=timezone.utc)}, + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 2, 15, 0, tzinfo=timezone.utc)}, + ], + ) + + updated_stops = [ + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 2, 13, 30, tzinfo=timezone.utc)}, + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 2, 15, 30, tzinfo=timezone.utc)}, + ] + dep, arr = ShiftService.validate_shift_data(bus, driver, updated_stops, exclude_pk=shift.pk) + self.assertEqual(dep, datetime(2024, 1, 2, 13, 30, tzinfo=timezone.utc)) + self.assertEqual(arr, datetime(2024, 1, 2, 15, 30, tzinfo=timezone.utc)) + + def test_recalculate_bus_shift_times_updates_shift_bounds(self): + driver = DriverFactory() + bus = BusFactory() + + shift = BusShiftFactory( + driver=driver, + bus=bus, + stops_data=[ + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 1, 8, 0, tzinfo=timezone.utc)}, + {'place': PlaceFactory(), 'stop_datetime': datetime(2024, 1, 1, 10, 0, tzinfo=timezone.utc)}, + ] + ) + + from padam_django.apps.busshifts.factories import BusStopFactory + BusStopFactory( + bus_shift=shift, + stop_datetime=datetime(2024, 1, 1, 12, 30, tzinfo=timezone.utc), + ) + + updated_shift = ShiftService.recalculate_bus_shift_times(shift.pk) + + self.assertEqual(updated_shift.arrival_time, datetime(2024, 1, 1, 12, 30, tzinfo=timezone.utc)) + self.assertEqual(updated_shift.departure_time, datetime(2024, 1, 1, 8, 0, tzinfo=timezone.utc)) + +class BusShiftAdminFormsetTests(TestCase): + + def test_admin_inline_formset_blocks_driver_overlap(self): + driver = DriverFactory() + bus = BusFactory() + bus2 = BusFactory() + user = UserFactory(is_staff=True, is_superuser=True) + place1, place2 = PlaceFactory(), PlaceFactory() + + BusShiftFactory( + driver=driver, + bus=bus, + stops_data=[ + {'place': place1, 'stop_datetime': datetime(2024, 1, 2, 9, 0, tzinfo=timezone.utc)}, + {'place': place2, 'stop_datetime': datetime(2024, 1, 2, 11, 0, tzinfo=timezone.utc)}, + ], + ) + + request = RequestFactory().get('/') + request.user = user + + new_shift = BusShift(driver=driver, bus=bus2) + inline = BusStopInline(BusShift, admin.site) + formset_class = inline.get_formset(request=request) + + formset = formset_class( + data={ + 'busstop_set-TOTAL_FORMS': '2', + 'busstop_set-INITIAL_FORMS': '0', + 'busstop_set-MIN_NUM_FORMS': '0', + 'busstop_set-MAX_NUM_FORMS': '1000', + 'busstop_set-0-place': str(place1.pk), + 'busstop_set-0-stop_datetime_0': '2024-01-02', + 'busstop_set-0-stop_datetime_1': '10:00:00', + 'busstop_set-1-place': str(place2.pk), + 'busstop_set-1-stop_datetime_0': '2024-01-02', + 'busstop_set-1-stop_datetime_1': '12:00:00', + }, + instance=new_shift, + prefix='busstop_set', + ) + + self.assertFalse(formset.is_valid()) + self.assertIn( + 'This driver', + str(formset.non_form_errors()), + ) + + def test_admin_inline_formset_blocks_bus_overlap(self): + bus = BusFactory() + driver = DriverFactory() + driver2 = DriverFactory() + user = UserFactory(is_staff=True, is_superuser=True) + place1, place2 = PlaceFactory(), PlaceFactory() + + BusShiftFactory( + bus=bus, + driver=driver, + stops_data=[ + {'place': place1, 'stop_datetime': datetime(2024, 1, 4, 9, 0, tzinfo=timezone.utc)}, + {'place': place2, 'stop_datetime': datetime(2024, 1, 4, 11, 0, tzinfo=timezone.utc)}, + ], + ) + + request = RequestFactory().get('/') + request.user = user + + new_shift = BusShift(bus=bus, driver=driver2) + inline = BusStopInline(BusShift, admin.site) + formset_class = inline.get_formset(request=request) + + formset = formset_class( + data={ + 'busstop_set-TOTAL_FORMS': '2', + 'busstop_set-INITIAL_FORMS': '0', + 'busstop_set-MIN_NUM_FORMS': '0', + 'busstop_set-MAX_NUM_FORMS': '1000', + 'busstop_set-0-place': str(place1.pk), + 'busstop_set-0-stop_datetime_0': '2024-01-04', + 'busstop_set-0-stop_datetime_1': '10:00:00', + 'busstop_set-1-place': str(place2.pk), + 'busstop_set-1-stop_datetime_0': '2024-01-04', + 'busstop_set-1-stop_datetime_1': '12:00:00', + }, + instance=new_shift, + prefix='busstop_set', + ) + + self.assertFalse(formset.is_valid()) + self.assertIn( + 'This bus', + str(formset.non_form_errors()), + ) diff --git a/padam_django/apps/common/helpers.py b/padam_django/apps/common/helpers.py new file mode 100644 index 00000000..dbd19e7f --- /dev/null +++ b/padam_django/apps/common/helpers.py @@ -0,0 +1,11 @@ +from django.utils import timezone + +def normalize_datetime(dt): + """Tronque une datetime aux minutes près et s'assure qu'elle est aware (UTC).""" + if dt is None: + return None + + if timezone.is_naive(dt): + dt = timezone.make_aware(dt, timezone.utc) + + return dt.replace(second=0, microsecond=0) \ 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..829eb355 100644 --- a/padam_django/apps/common/management/commands/create_data.py +++ b/padam_django/apps/common/management/commands/create_data.py @@ -12,3 +12,4 @@ def handle(self, *args, **options): 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) diff --git a/padam_django/apps/fleet/admin.py b/padam_django/apps/fleet/admin.py index 3fba5023..0751f5bc 100644 --- a/padam_django/apps/fleet/admin.py +++ b/padam_django/apps/fleet/admin.py @@ -5,9 +5,11 @@ @admin.register(models.Bus) class BusAdmin(admin.ModelAdmin): - pass + search_fields = ["licence_plate"] + @admin.register(models.Driver) class DriverAdmin(admin.ModelAdmin): - pass + search_fields = ["user__username"] + diff --git a/padam_django/apps/geography/admin.py b/padam_django/apps/geography/admin.py index e0334458..ac37316f 100644 --- a/padam_django/apps/geography/admin.py +++ b/padam_django/apps/geography/admin.py @@ -5,4 +5,4 @@ @admin.register(models.Place) class PlaceAdmin(admin.ModelAdmin): - pass + search_fields = ["name"] diff --git a/padam_django/settings.py b/padam_django/settings.py index 129e922c..55a31681 100644 --- a/padam_django/settings.py +++ b/padam_django/settings.py @@ -45,6 +45,7 @@ 'padam_django.apps.fleet', 'padam_django.apps.geography', 'padam_django.apps.users', + 'padam_django.apps.busshifts', ] MIDDLEWARE = [ @@ -85,6 +86,7 @@ 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', + 'ATOMIC_REQUESTS': True, } }