From 4f9c23bbd008d434ad5999cdd563b17414771acd Mon Sep 17 00:00:00 2001 From: Do SANON Date: Wed, 22 Jul 2026 20:40:56 +0200 Subject: [PATCH 01/16] chore: align development setup with Python 3.9 - add missing command 'migrate' target documented in the README - downgrade IPython from 8.29.0 to 8.18.1 for Python 3.9 compatibility - add .python-version to standardize the local Python version --- .python-version | 1 + Makefile | 3 +++ requirements.txt | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 .python-version diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..bd28b9c5 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.9 diff --git a/Makefile b/Makefile index 4062f4c4..085498d5 100644 --- a/Makefile +++ b/Makefile @@ -3,3 +3,6 @@ run: ## Run the test server. install: ## Install the python requirements. pip install -r requirements.txt + +migrate: ## Apply database migrations. + python manage.py migrate diff --git a/requirements.txt b/requirements.txt index 863fd63d..c8aecd97 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ Django==4.2.16 django-extensions==3.2.1 Werkzeug==3.1.3 -ipython==8.29.0 +ipython==8.18.1 factory-boy==3.2.0 Faker==8.10.1 From b24e3492a0150ab643e72d300c69139ad9b0ff25 Mon Sep 17 00:00:00 2001 From: Do SANON Date: Wed, 22 Jul 2026 20:51:18 +0200 Subject: [PATCH 02/16] docs: align documented Python version with Django 4.2.16 requirement --- README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f99d629d..3983ad80 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,12 @@ To carry out the test, remember to fork this repository. Ideally, open a PR at t | 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. +- This project was created using Python 3.7. You are free to use another version, but this is the one we recommend (Django 4.2.16 requires Python ≥ 3.8). - The database is freely selectable. The project is configured to use `sqlite` by default. ### Start the project -*From your Python 3.7 virtualenv*: +*From your Python 3.9 virtualenv*: ``` make install @@ -121,12 +120,12 @@ Pour réaliser le test, pensez à fork ce repository. Idéalement, ouvrir une PR | 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. + nous vous conseillons (Django 4.2.16 nécessite Python ≥ 3.8). - La base de donnée est au choix. Le projet est configuré pour utiliser `sqlite` par défaut. ### Démarrer le projet -*Depuis votre virtualenv Python 3.7*: +*Depuis votre virtualenv Python 3.9*: ``` make install From 90e52b6c23ee55f1328dfe552ebbe1992217a576 Mon Sep 17 00:00:00 2001 From: Do SANON Date: Wed, 22 Jul 2026 21:01:29 +0200 Subject: [PATCH 03/16] chore: remove empty Pipfile - requirements.txt is the source of truth for dependencies. - Pipfile was empty --- Pipfile | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 Pipfile diff --git a/Pipfile b/Pipfile deleted file mode 100644 index b723d019..00000000 --- a/Pipfile +++ /dev/null @@ -1,11 +0,0 @@ -[[source]] -name = "pypi" -url = "https://pypi.org/simple" -verify_ssl = true - -[dev-packages] - -[packages] - -[requires] -python_version = "3.7" From 14497c0f7781ae7b1aa41a133e6f42474fc1d05e Mon Sep 17 00:00:00 2001 From: Do SANON Date: Wed, 22 Jul 2026 21:03:43 +0200 Subject: [PATCH 04/16] chore: ignore local development files - remove the tracked .idea directory to prevent IDE-specific diffs - add .venv/ to .gitignore --- .gitignore | 1 + .idea/.gitignore | 8 ------- .idea/inspectionProfiles/Project_Default.xml | 6 ----- .../inspectionProfiles/profiles_settings.xml | 6 ----- .idea/misc.xml | 4 ---- .idea/modules.xml | 8 ------- .idea/padam-django-tech-test.iml | 23 ------------------- 7 files changed, 1 insertion(+), 55 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/inspectionProfiles/Project_Default.xml delete mode 100644 .idea/inspectionProfiles/profiles_settings.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/padam-django-tech-test.iml diff --git a/.gitignore b/.gitignore index d7d26693..e4222f06 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ __pycache__/ # virtualenv venv/ ENV/ +.venv/ # pipenv: https://github.com/kennethreitz/pipenv /Pipfile diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 73f69e09..00000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml -# Editor-based HTTP Client requests -/httpRequests/ diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 03d9549e..00000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2da..00000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 574ec96e..00000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 2253d389..00000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/padam-django-tech-test.iml b/.idea/padam-django-tech-test.iml deleted file mode 100644 index c7ffe09b..00000000 --- a/.idea/padam-django-tech-test.iml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file From 4823f06e025c39c389539dc7450d33d106fcaf16 Mon Sep 17 00:00:00 2001 From: Do SANON Date: Wed, 22 Jul 2026 21:53:26 +0200 Subject: [PATCH 05/16] docs: document BusShift and BusStop design --- docs/design.md | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 docs/design.md diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 00000000..485c97c1 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,69 @@ +## UML class diagram + +```mermaid +classDiagram + class User { + +id: Integer + +username: String + +email: String + /is_driver: Boolean + } + + class Driver { + +id: Integer + +user: User + } + + class Bus { + +id: Integer + +licence_plate: String + } + + class BusShift { + +id: Integer + +driver: Driver + +bus: Bus + /departure_at: DateTime + /arrival_at: DateTime + /duration: Duration + } + + class BusStop { + +id: Integer + +bus_shift: BusShift + +place: Place + +scheduled_at: DateTime + +order: Integer + } + + class Place { + +id: Integer + +name: String + +longitude: Decimal + +latitude: Decimal + } + + Driver "0..1" --> "1" User : user + BusShift "0..*" --> "1" Driver : driver + BusShift "0..*" --> "1" Bus : bus + BusShift "1" *-- "2..*" BusStop : stops + BusStop "0..*" --> "1" Place : place +``` + +### Derived properties + +These properties are calculated and are not stored in the database: + +- `User.is_driver` indicates whether a `Driver` profile exists for the user. +- `BusShift.departure_at` is calculated from the first scheduled stop. +- `BusShift.arrival_at` is calculated from the last scheduled stop. +- `BusShift.duration` is calculated from the departure and arrival datetimes. + +### Business rules + +- A bus shift must have exactly one driver and one bus. +- A bus shift must have at least two stops. +- Each stop must belong to exactly one bus shift. +- Stop order must be unique within a bus shift. +- Stop times must follow the stop order. +- The last stop time must be later than the first stop time. From f3c4879897ef33fb967e1c7173dfdb291130f4b2 Mon Sep 17 00:00:00 2001 From: Do SANON Date: Wed, 22 Jul 2026 22:06:14 +0200 Subject: [PATCH 06/16] chore: initialize shifts application --- padam_django/apps/shifts/__init__.py | 0 padam_django/apps/shifts/apps.py | 5 +++++ padam_django/settings.py | 1 + 3 files changed, 6 insertions(+) create mode 100644 padam_django/apps/shifts/__init__.py create mode 100644 padam_django/apps/shifts/apps.py diff --git a/padam_django/apps/shifts/__init__.py b/padam_django/apps/shifts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/shifts/apps.py b/padam_django/apps/shifts/apps.py new file mode 100644 index 00000000..3bee92b4 --- /dev/null +++ b/padam_django/apps/shifts/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ShiftsConfig(AppConfig): + name = 'padam_django.apps.shifts' \ No newline at end of file diff --git a/padam_django/settings.py b/padam_django/settings.py index 129e922c..443f882d 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.shifts', 'padam_django.apps.users', ] From 91d4a89e9ebdb474b970a528c54d4cc683095f2a Mon Sep 17 00:00:00 2001 From: Do SANON Date: Wed, 22 Jul 2026 23:20:47 +0200 Subject: [PATCH 07/16] feat: add bus shift and stop models --- .../apps/shifts/migrations/0001_initial.py | 48 ++++++++++++++++ .../apps/shifts/migrations/__init__.py | 0 padam_django/apps/shifts/models.py | 57 +++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 padam_django/apps/shifts/migrations/0001_initial.py create mode 100644 padam_django/apps/shifts/migrations/__init__.py create mode 100644 padam_django/apps/shifts/models.py diff --git a/padam_django/apps/shifts/migrations/0001_initial.py b/padam_django/apps/shifts/migrations/0001_initial.py new file mode 100644 index 00000000..5d4941b3 --- /dev/null +++ b/padam_django/apps/shifts/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# Generated by Django 4.2.16 on 2026-07-22 20:47 + +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')), + ('bus', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='shifts', to='fleet.bus')), + ('driver', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='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')), + ('scheduled_at', models.DateTimeField(verbose_name='Scheduled at')), + ('order', models.PositiveSmallIntegerField(verbose_name='Order')), + ('bus_shift', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stops', to='shifts.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', + 'ordering': ['order'], + }, + ), + migrations.AddConstraint( + model_name='busstop', + constraint=models.UniqueConstraint(fields=('bus_shift', 'order'), name='shifts_bus_stop_unique_order_per_shift'), + ), + ] diff --git a/padam_django/apps/shifts/migrations/__init__.py b/padam_django/apps/shifts/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/shifts/models.py b/padam_django/apps/shifts/models.py new file mode 100644 index 00000000..b31b0ced --- /dev/null +++ b/padam_django/apps/shifts/models.py @@ -0,0 +1,57 @@ +from datetime import datetime, timedelta +from typing import Optional + +from django.db import models + + +class BusShift(models.Model): + bus = models.ForeignKey('fleet.Bus', on_delete=models.PROTECT, related_name='shifts') + driver = models.ForeignKey('fleet.Driver', on_delete=models.PROTECT, related_name='shifts') + + class Meta: + verbose_name = "Bus shift" + verbose_name_plural = "Bus shifts" + + def __str__(self) -> str: + return f"BusShift: {self.bus} / {self.driver} (id: {self.pk})" + + @property + def departure_at(self) -> Optional[datetime]: + """Return the datetime of the first stop.""" + first_stop = self.stops.order_by("order").first() + return first_stop.scheduled_at if first_stop else None + + @property + def arrival_at(self) -> Optional[datetime]: + """Return the datetime of the last stop.""" + last_stop = self.stops.order_by("order").last() + return last_stop.scheduled_at if last_stop else None + + @property + def duration(self) -> Optional[timedelta]: + """Return the duration between departure and arrival.""" + departure, arrival = self.departure_at, self.arrival_at + if departure is None or arrival is None: + return None + return arrival - departure + + +class BusStop(models.Model): + bus_shift = models.ForeignKey(BusShift, on_delete=models.CASCADE, related_name='stops') + place = models.ForeignKey('geography.Place', on_delete=models.PROTECT, related_name='bus_stops') + scheduled_at = models.DateTimeField("Scheduled at") + order = models.PositiveSmallIntegerField("Order") + + class Meta: + verbose_name = "Bus stop" + verbose_name_plural = "Bus stops" + ordering = ['order'] + constraints = [ + models.UniqueConstraint( + fields=['bus_shift', 'order'], + name='shifts_bus_stop_unique_order_per_shift' + ), + ] + + def __str__(self) -> str: + return f"BusStop: {self.place} @ {self.scheduled_at} (order {self.order})" From 4dbef7f0918d85efdef4e74d3c7119e713d31e38 Mon Sep 17 00:00:00 2001 From: Do SANON Date: Wed, 22 Jul 2026 23:22:17 +0200 Subject: [PATCH 08/16] feat: add bus shift admin --- padam_django/apps/fleet/admin.py | 8 +++- padam_django/apps/geography/admin.py | 2 +- padam_django/apps/shifts/admin.py | 69 ++++++++++++++++++++++++++++ padam_django/apps/users/admin.py | 1 + 4 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 padam_django/apps/shifts/admin.py diff --git a/padam_django/apps/fleet/admin.py b/padam_django/apps/fleet/admin.py index 3fba5023..8a7142c3 100644 --- a/padam_django/apps/fleet/admin.py +++ b/padam_django/apps/fleet/admin.py @@ -5,9 +5,13 @@ @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", + "user__email", + ) + list_select_related = ("user",) diff --git a/padam_django/apps/geography/admin.py b/padam_django/apps/geography/admin.py index e0334458..0b4e3ca5 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/apps/shifts/admin.py b/padam_django/apps/shifts/admin.py new file mode 100644 index 00000000..348ac631 --- /dev/null +++ b/padam_django/apps/shifts/admin.py @@ -0,0 +1,69 @@ +from django.contrib import admin + +from .models import BusShift, BusStop + + +class BusStopInline(admin.TabularInline): + model = BusStop + extra = 2 + fields = ("order", "place", "scheduled_at") + autocomplete_fields = ("place",) + ordering = ("order",) + + +@admin.register(BusShift) +class BusShiftAdmin(admin.ModelAdmin): + list_display = ( + "id", + "bus", + "driver", + "departure_at_display", + "arrival_at_display", + "duration_display", + ) + list_filter = ("bus", "driver") + list_select_related = ("bus", "driver", "driver__user") + autocomplete_fields = ("bus", "driver") + search_fields = ( + "bus__licence_plate", + "driver__user__username", + "driver__user__email", + ) + inlines = (BusStopInline,) + + @admin.display(description="Departure") + def departure_at_display(self, obj: BusShift): + return obj.departure_at + + @admin.display(description="Arrival") + def arrival_at_display(self, obj: BusShift): + return obj.arrival_at + + @admin.display(description="Duration") + def duration_display(self, obj: BusShift): + return obj.duration + + +@admin.register(BusStop) +class BusStopAdmin(admin.ModelAdmin): + list_display = ( + "id", + "bus_shift", + "order", + "place", + "scheduled_at", + ) + list_filter = ("place") + list_select_related = ( + "bus_shift", + "bus_shift__bus", + "bus_shift__driver", + "place", + ) + autocomplete_fields = ("bus_shift", "place") + search_fields = ( + "place__name", + "bus_shift__bus__licence_plate", + "bus_shift__driver__user__username", + ) + ordering = ("bus_shift", "order") \ No newline at end of file diff --git a/padam_django/apps/users/admin.py b/padam_django/apps/users/admin.py index 2bc531c6..1bcce0b9 100644 --- a/padam_django/apps/users/admin.py +++ b/padam_django/apps/users/admin.py @@ -6,6 +6,7 @@ @admin.register(models.User) class UserAdmin(admin.ModelAdmin): list_display = ('username', 'email', 'first_name', 'last_name', 'is_driver') + list_select_related = ("driver",) def is_driver(self, obj): return obj.is_driver From ed19350a18158ea5816c4db9272105698d8b280a Mon Sep 17 00:00:00 2001 From: Do SANON Date: Thu, 23 Jul 2026 06:56:56 +0200 Subject: [PATCH 09/16] test: add factories and cover bus shift business rules --- padam_django/apps/shifts/factories.py | 22 ++ padam_django/apps/shifts/tests/__init__.py | 0 padam_django/apps/shifts/tests/test_admin.py | 128 +++++++++ padam_django/apps/shifts/tests/test_models.py | 270 ++++++++++++++++++ 4 files changed, 420 insertions(+) create mode 100644 padam_django/apps/shifts/factories.py create mode 100644 padam_django/apps/shifts/tests/__init__.py create mode 100644 padam_django/apps/shifts/tests/test_admin.py create mode 100644 padam_django/apps/shifts/tests/test_models.py diff --git a/padam_django/apps/shifts/factories.py b/padam_django/apps/shifts/factories.py new file mode 100644 index 00000000..2ca1d31c --- /dev/null +++ b/padam_django/apps/shifts/factories.py @@ -0,0 +1,22 @@ +import factory +from django.utils import timezone + +from . import models + + +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): + bus_shift = factory.SubFactory(BusShiftFactory) + place = factory.SubFactory("padam_django.apps.geography.factories.PlaceFactory") + scheduled_at = factory.LazyFunction(timezone.now) + order = factory.Sequence(lambda n: n + 1) + + class Meta: + model = models.BusStop diff --git a/padam_django/apps/shifts/tests/__init__.py b/padam_django/apps/shifts/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/shifts/tests/test_admin.py b/padam_django/apps/shifts/tests/test_admin.py new file mode 100644 index 00000000..9c1f6f25 --- /dev/null +++ b/padam_django/apps/shifts/tests/test_admin.py @@ -0,0 +1,128 @@ +from datetime import datetime, timedelta + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from padam_django.apps.fleet.factories import BusFactory, DriverFactory +from padam_django.apps.geography.factories import PlaceFactory + +from ..factories import BusShiftFactory, BusStopFactory +from ..models import BusShift, BusStop + +User = get_user_model() + + +class BusShiftAdminTests(TestCase): + def setUp(self): + self.user = User.objects.create_superuser( + username="admin", + email="admin@padam-mobility.fr", + password="password", + ) + self.client.force_login(self.user) + + self.bus = BusFactory() + self.driver = DriverFactory() + self.place_a = PlaceFactory() + self.place_b = PlaceFactory() + + self.add_url = reverse("admin:shifts_busshift_add") + self.base_time = timezone.make_aware( + datetime(2026, 2, 1, 8, 0), + ) + + def _management_form(self, *, total, initial=0): + return { + "stops-TOTAL_FORMS": str(total), + "stops-INITIAL_FORMS": str(initial), + "stops-MIN_NUM_FORMS": "0", + "stops-MAX_NUM_FORMS": "1000", + } + + def test_admin_can_create_shift_with_two_stops(self): + data = { + "bus": self.bus.pk, + "driver": self.driver.pk, + **self._management_form(total=2), + "stops-0-order": "1", + "stops-0-place": self.place_a.pk, + "stops-0-scheduled_at_0": "2026-02-01", + "stops-0-scheduled_at_1": "08:00:00", + "stops-1-order": "2", + "stops-1-place": self.place_b.pk, + "stops-1-scheduled_at_0": "2026-02-01", + "stops-1-scheduled_at_1": "09:00:00", + } + + response = self.client.post(self.add_url, data) + + self.assertEqual(response.status_code, 302) + self.assertEqual(BusShift.objects.count(), 1) + self.assertEqual(BusStop.objects.count(), 2) + + def test_admin_can_update_shift(self): + shift = BusShiftFactory( + bus=self.bus, + driver=self.driver, + ) + first_stop = BusStopFactory( + bus_shift=shift, + place=self.place_a, + order=1, + scheduled_at=self.base_time, + ) + second_stop = BusStopFactory( + bus_shift=shift, + place=self.place_b, + order=2, + scheduled_at=self.base_time + timedelta(hours=1), + ) + change_url = reverse( + "admin:shifts_busshift_change", + args=(shift.pk,), + ) + + data = { + "bus": self.bus.pk, + "driver": self.driver.pk, + **self._management_form(total=2, initial=2), + "stops-0-id": first_stop.pk, + "stops-0-order": "1", + "stops-0-place": self.place_a.pk, + "stops-0-scheduled_at_0": "2026-02-01", + "stops-0-scheduled_at_1": "08:00:00", + "stops-1-id": second_stop.pk, + "stops-1-order": "2", + "stops-1-place": self.place_b.pk, + "stops-1-scheduled_at_0": "2026-02-01", + "stops-1-scheduled_at_1": "10:00:00", + } + + response = self.client.post(change_url, data) + + self.assertEqual(response.status_code, 302) + + second_stop.refresh_from_db() + self.assertEqual( + second_stop.scheduled_at, + self.base_time + timedelta(hours=2), + ) + + def test_admin_rejects_shift_with_one_stop(self): + data = { + "bus": self.bus.pk, + "driver": self.driver.pk, + **self._management_form(total=1), + "stops-0-order": "1", + "stops-0-place": self.place_a.pk, + "stops-0-scheduled_at_0": "2026-02-01", + "stops-0-scheduled_at_1": "08:00:00", + } + + response = self.client.post(self.add_url, data) + + self.assertEqual(response.status_code, 200) + self.assertEqual(BusShift.objects.count(), 0) + self.assertEqual(BusStop.objects.count(), 0) diff --git a/padam_django/apps/shifts/tests/test_models.py b/padam_django/apps/shifts/tests/test_models.py new file mode 100644 index 00000000..9ab17027 --- /dev/null +++ b/padam_django/apps/shifts/tests/test_models.py @@ -0,0 +1,270 @@ +from datetime import timedelta + +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction +from django.test import TestCase +from django.utils import timezone + +from padam_django.apps.fleet.factories import BusFactory, DriverFactory +from padam_django.apps.geography.factories import PlaceFactory + +from ..factories import BusShiftFactory, BusStopFactory +from ..models import BusStop + + +class BusShiftTests(TestCase): + def setUp(self): + self.now = timezone.now() + + def test_shift_requires_at_least_two_stops(self): + shift = BusShiftFactory() + stops = [ + BusStopFactory.build( + bus_shift=shift, + order=1, + scheduled_at=self.now, + ), + ] + + with self.assertRaises(ValidationError): + shift.validate_bus_shift(stops) + + def test_valid_schedule_is_accepted(self): + shift = BusShiftFactory() + stops = [ + BusStopFactory.build( + bus_shift=shift, + order=1, + scheduled_at=self.now, + ), + BusStopFactory.build( + bus_shift=shift, + order=2, + scheduled_at=self.now + timedelta(minutes=30), + ), + ] + + shift.validate_bus_shift(stops) + + def test_stop_times_must_strictly_increase_with_order(self): + invalid_second_times = (self.now, self.now - timedelta(minutes=30)) + + for second_time in invalid_second_times: + with self.subTest(second_time=second_time): + shift = BusShiftFactory() + stops = [ + BusStopFactory.build( + bus_shift=shift, order=1, scheduled_at=self.now + ), + BusStopFactory.build( + bus_shift=shift, order=2, scheduled_at=second_time + ), + ] + + with self.assertRaises(ValidationError): + shift.validate_bus_shift(stops) + + def test_schedule_information_is_derived_from_stops(self): + shift = BusShiftFactory() + + BusStopFactory(bus_shift=shift, order=1, scheduled_at=self.now) + BusStopFactory( + bus_shift=shift, order=2, scheduled_at=self.now + timedelta(minutes=45) + ) + BusStopFactory( + bus_shift=shift, order=3, scheduled_at=self.now + timedelta(minutes=90) + ) + + self.assertEqual(shift.departure_at, self.now) + self.assertEqual(shift.arrival_at, self.now + timedelta(minutes=90)) + self.assertEqual(shift.duration, timedelta(minutes=90)) + + def test_bus_cannot_have_overlapping_shifts(self): + bus = BusFactory() + + existing = BusShiftFactory(bus=bus, driver=DriverFactory()) + BusStopFactory(bus_shift=existing, order=1, scheduled_at=self.now) + BusStopFactory( + bus_shift=existing, + order=2, + scheduled_at=self.now + timedelta(hours=2), + ) + + candidate = BusShiftFactory( + bus=bus, + driver=DriverFactory(), + ) + candidate_stops = [ + BusStopFactory.build( + bus_shift=candidate, + order=1, + scheduled_at=self.now + timedelta(hours=1), + ), + BusStopFactory.build( + bus_shift=candidate, + order=2, + scheduled_at=self.now + timedelta(hours=3), + ), + ] + + with self.assertRaises(ValidationError): + candidate.validate_bus_shift(candidate_stops) + + def test_driver_cannot_have_overlapping_shifts(self): + driver = DriverFactory() + + existing = BusShiftFactory( + bus=BusFactory(), + driver=driver, + ) + BusStopFactory( + bus_shift=existing, + order=1, + scheduled_at=self.now, + ) + BusStopFactory( + bus_shift=existing, + order=2, + scheduled_at=self.now + timedelta(hours=2), + ) + + candidate = BusShiftFactory( + bus=BusFactory(), + driver=driver, + ) + candidate_stops = [ + BusStopFactory.build( + bus_shift=candidate, + order=1, + scheduled_at=self.now + timedelta(hours=1), + ), + BusStopFactory.build( + bus_shift=candidate, + order=2, + scheduled_at=self.now + timedelta(hours=3), + ), + ] + + with self.assertRaises(ValidationError): + candidate.validate_bus_shift(candidate_stops) + + def test_back_to_back_shifts_are_allowed(self): + bus = BusFactory() + driver = DriverFactory() + + existing = BusShiftFactory( + bus=bus, + driver=driver, + ) + BusStopFactory( + bus_shift=existing, + order=1, + scheduled_at=self.now, + ) + BusStopFactory( + bus_shift=existing, + order=2, + scheduled_at=self.now + timedelta(hours=1), + ) + + candidate = BusShiftFactory( + bus=bus, + driver=driver, + ) + candidate_stops = [ + BusStopFactory.build( + bus_shift=candidate, + order=1, + scheduled_at=self.now + timedelta(hours=1), + ), + BusStopFactory.build( + bus_shift=candidate, + order=2, + scheduled_at=self.now + timedelta(hours=2), + ), + ] + + candidate.validate_bus_shift(candidate_stops) + + def test_shift_does_not_overlap_itself(self): + shift = BusShiftFactory() + + stops = [ + BusStopFactory( + bus_shift=shift, + order=1, + scheduled_at=self.now, + ), + BusStopFactory( + bus_shift=shift, + order=2, + scheduled_at=self.now + timedelta(hours=1), + ), + ] + + shift.validate_bus_shift(stops) + + +class BusStopTests(TestCase): + """Business rules and database constraints owned by bus stops.""" + + def setUp(self): + self.now = timezone.now().replace(microsecond=0) + + def test_stop_must_belong_to_a_shift(self): + stop = BusStop( + bus_shift=None, + place=PlaceFactory(), + scheduled_at=self.now, + order=1, + ) + + with self.assertRaises(ValidationError): + stop.full_clean() + + def test_duplicate_order_is_rejected_by_schedule_validation(self): + shift = BusShiftFactory() + stops = [ + BusStopFactory.build( + bus_shift=shift, + order=1, + scheduled_at=self.now, + ), + BusStopFactory.build( + bus_shift=shift, + order=1, + scheduled_at=self.now + timedelta(minutes=30), + ), + ] + + with self.assertRaises(ValidationError): + shift.validate_bus_shift(stops) + + def test_stop_order_is_unique_within_a_shift(self): + shift = BusShiftFactory() + + BusStopFactory( + bus_shift=shift, + order=1, + ) + + with self.assertRaises(IntegrityError), transaction.atomic(): + BusStopFactory( + bus_shift=shift, + order=1, + ) + + def test_same_order_is_allowed_in_different_shifts(self): + BusStopFactory( + bus_shift=BusShiftFactory(), + order=1, + ) + BusStopFactory( + bus_shift=BusShiftFactory(), + order=1, + ) + + self.assertEqual( + BusStop.objects.filter(order=1).count(), + 2, + ) From adad94347db16e4a05e908cb5959cfeb2fa20920 Mon Sep 17 00:00:00 2001 From: Do SANON Date: Thu, 23 Jul 2026 06:59:36 +0200 Subject: [PATCH 10/16] feat: enforce bus shift business rules --- padam_django/apps/shifts/admin.py | 30 ++++++++++- padam_django/apps/shifts/models.py | 85 ++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/padam_django/apps/shifts/admin.py b/padam_django/apps/shifts/admin.py index 348ac631..32441308 100644 --- a/padam_django/apps/shifts/admin.py +++ b/padam_django/apps/shifts/admin.py @@ -1,10 +1,38 @@ from django.contrib import admin +from django.forms.models import BaseInlineFormSet from .models import BusShift, BusStop +class BusStopInlineFormSet(BaseInlineFormSet): + """Validate the complete bus shift before saving its stops.""" + + def clean(self) -> None: + """Validate the submitted bus stops.""" + super().clean() + + if any(self.errors): + return + + stops = [] + + for form in self.forms: + if not form.cleaned_data: + continue + + if form.cleaned_data.get("DELETE"): + continue + + stop = form.instance + stop.bus_shift = self.instance + stops.append(stop) + + self.instance.validate_bus_shift(stops) + + class BusStopInline(admin.TabularInline): model = BusStop + formset = BusStopInlineFormSet extra = 2 fields = ("order", "place", "scheduled_at") autocomplete_fields = ("place",) @@ -53,7 +81,7 @@ class BusStopAdmin(admin.ModelAdmin): "place", "scheduled_at", ) - list_filter = ("place") + list_filter = ("place",) list_select_related = ( "bus_shift", "bus_shift__bus", diff --git a/padam_django/apps/shifts/models.py b/padam_django/apps/shifts/models.py index b31b0ced..a84db890 100644 --- a/padam_django/apps/shifts/models.py +++ b/padam_django/apps/shifts/models.py @@ -1,7 +1,9 @@ from datetime import datetime, timedelta from typing import Optional +from django.core.exceptions import ValidationError from django.db import models +from django.db.models import Count, Min, Max, Q class BusShift(models.Model): @@ -35,6 +37,89 @@ def duration(self) -> Optional[timedelta]: return None return arrival - departure + def validate_bus_shift(self, stops: list["BusStop"]) -> None: + """Validate the shift schedule and resource availability. + + Args: + stops: Stops associated with the shift. + + Raises: + ValidationError: If the schedule is invalid or overlaps another shift. + """ + ordered_stops = self._validate_stops(stops) + + self._validate_resource_availability( + departure=ordered_stops[0].scheduled_at, + arrival=ordered_stops[-1].scheduled_at, + ) + + @staticmethod + def _validate_stops(stops: list["BusStop"]) -> list["BusStop"]: + """Validate and sort the shift stops. + + Args: + stops: Stops associated with the shift. + + Returns: + Stops sorted by order. + + Raises: + ValidationError: If the stop count, orders, or times are invalid. + """ + if len(stops) < 2: + raise ValidationError("A bus shift must have at least two stops.") + + orders = [stop.order for stop in stops] + + if len(orders) != len(set(orders)): + raise ValidationError("Stop order must be unique within a bus shift.") + + ordered_stops = sorted(stops, key=lambda stop: stop.order) + + for previous, current in zip(ordered_stops, ordered_stops[1:]): + if current.scheduled_at <= previous.scheduled_at: + raise ValidationError("Stop times must follow the stop order.") + + return ordered_stops + + def _validate_resource_availability(self, *, departure: datetime, arrival: datetime) -> None: + """Validate bus and driver availability for the shift period. + + Args: + departure: Shift departure time. + arrival: Shift arrival time. + + Raises: + ValidationError: If the bus or driver has an overlapping shift. + """ + overlapping_shifts = ( + BusShift.objects + .filter(Q(bus_id=self.bus_id)| Q(driver_id=self.driver_id)) + .exclude(pk=self.pk) + .annotate( + stop_count=Count("stops"), + candidate_departure=Min("stops__scheduled_at"), + candidate_arrival=Max("stops__scheduled_at"), + ) + .filter( + stop_count__gte=2, + candidate_departure__lt=arrival, + candidate_arrival__gt=departure, + ) + ) + + errors = {} + + if overlapping_shifts.filter(bus_id=self.bus_id).exists(): + errors["bus"] = "This bus is already assigned to another shift" + + if overlapping_shifts.filter(driver_id=self.driver_id).exists(): + errors["driver"] = "This driver is already assigned to another shift" + + if errors: + raise ValidationError(errors) + + class BusStop(models.Model): bus_shift = models.ForeignKey(BusShift, on_delete=models.CASCADE, related_name='stops') From dfb57cb346efa86603c69dcf54e34c0064e74f10 Mon Sep 17 00:00:00 2001 From: Do SANON Date: Thu, 23 Jul 2026 11:10:39 +0200 Subject: [PATCH 11/16] perf: eliminate bus shift changelist N+1 queries --- padam_django/apps/shifts/admin.py | 27 +++++--- padam_django/apps/shifts/managers.py | 100 +++++++++++++++++++++++++++ padam_django/apps/shifts/models.py | 73 +++++++++++-------- 3 files changed, 159 insertions(+), 41 deletions(-) create mode 100644 padam_django/apps/shifts/managers.py diff --git a/padam_django/apps/shifts/admin.py b/padam_django/apps/shifts/admin.py index 32441308..b6b8d08d 100644 --- a/padam_django/apps/shifts/admin.py +++ b/padam_django/apps/shifts/admin.py @@ -5,10 +5,7 @@ class BusStopInlineFormSet(BaseInlineFormSet): - """Validate the complete bus shift before saving its stops.""" - def clean(self) -> None: - """Validate the submitted bus stops.""" super().clean() if any(self.errors): @@ -49,21 +46,30 @@ class BusShiftAdmin(admin.ModelAdmin): "arrival_at_display", "duration_display", ) - list_filter = ("bus", "driver") - list_select_related = ("bus", "driver", "driver__user") + list_select_related = ("bus", "driver__user") autocomplete_fields = ("bus", "driver") search_fields = ( "bus__licence_plate", "driver__user__username", "driver__user__email", ) + show_full_result_count = False inlines = (BusStopInline,) - @admin.display(description="Departure") + def get_queryset(self, request): + return super().get_queryset(request).with_departure_and_arrival() + + @admin.display( + description="Departure", + ordering="_departure_at", + ) def departure_at_display(self, obj: BusShift): return obj.departure_at - @admin.display(description="Arrival") + @admin.display( + description="Arrival", + ordering="_arrival_at", + ) def arrival_at_display(self, obj: BusShift): return obj.arrival_at @@ -81,11 +87,9 @@ class BusStopAdmin(admin.ModelAdmin): "place", "scheduled_at", ) - list_filter = ("place",) list_select_related = ( - "bus_shift", "bus_shift__bus", - "bus_shift__driver", + "bus_shift__driver__user", "place", ) autocomplete_fields = ("bus_shift", "place") @@ -94,4 +98,5 @@ class BusStopAdmin(admin.ModelAdmin): "bus_shift__bus__licence_plate", "bus_shift__driver__user__username", ) - ordering = ("bus_shift", "order") \ No newline at end of file + show_full_result_count = False + ordering = ("bus_shift", "order") diff --git a/padam_django/apps/shifts/managers.py b/padam_django/apps/shifts/managers.py new file mode 100644 index 00000000..f68781de --- /dev/null +++ b/padam_django/apps/shifts/managers.py @@ -0,0 +1,100 @@ +from datetime import datetime +from typing import Optional + +from django.db import models +from django.db.models import Count, DateTimeField, OuterRef, Q, Subquery + + +class BusShiftQuerySet(models.QuerySet): + """Provide reusable bus shift queries.""" + + def for_bus_or_driver(self, *, bus_id: int, driver_id: int): + """Filter shifts assigned to the given bus or driver. + + Args: + bus_id: Bus primary key. + driver_id: Driver primary key. + + Returns: + Bus shifts assigned to either resource. + """ + return self.filter(Q(bus_id=bus_id) | Q(driver_id=driver_id)) + + def with_departure_and_arrival(self) -> "BusShiftQuerySet": + """Annotate shifts with their first and last stop times. + + Returns: + Bus shifts annotated with their departure and arrival times. + + Notes: + Slicing [:1] keeps the QuerySet lazy and adds LIMIT 1, [0] executes it. + """ + from .models import BusStop + + stops = BusStop.objects.filter(bus_shift_id=OuterRef("pk")) + first_stop = stops.order_by("order") + last_stop = stops.order_by("-order") + + return self.annotate( + _departure_at=Subquery( + first_stop.values("scheduled_at")[:1], + output_field=DateTimeField(), + ), + _arrival_at=Subquery( + last_stop.values("scheduled_at")[:1], + output_field=DateTimeField(), + ), + ) + + def with_stop_count(self) -> "BusShiftQuerySet": + """Annotate shifts with their number of stops. + + Returns: + Bus shifts annotated with their stop count. + """ + return self.annotate(stop_count=Count("stops")) + + +class BusShiftManager(models.Manager.from_queryset(BusShiftQuerySet)): + """Provide business-level queries for bus shifts.""" + + def conflicting_with( + self, + *, + bus_id: int, + driver_id: int, + departure: datetime, + arrival: datetime, + exclude_pk: Optional[int] = None, + ): + """Return shifts conflicting with resources and a time period. + + Args: + bus_id: Bus primary key. + driver_id: Driver primary key. + departure: Beginning of the candidate shift. + arrival: End of the candidate shift. + exclude_pk: Shift pk to exclude when editing. + + Returns: + Bus shifts overlapping the given period for the bus or driver. + """ + queryset = ( + self.get_queryset() + .for_bus_or_driver( + bus_id=bus_id, + driver_id=driver_id, + ) + .with_departure_and_arrival() + .with_stop_count() + .filter( + stop_count__gte=2, + _departure_at__lt=arrival, + _arrival_at__gt=departure, + ) + ) + + if exclude_pk is not None: + queryset = queryset.exclude(pk=exclude_pk) + + return queryset diff --git a/padam_django/apps/shifts/models.py b/padam_django/apps/shifts/models.py index a84db890..f74eefb9 100644 --- a/padam_django/apps/shifts/models.py +++ b/padam_django/apps/shifts/models.py @@ -3,12 +3,22 @@ from django.core.exceptions import ValidationError from django.db import models -from django.db.models import Count, Min, Max, Q + +from padam_django.apps.shifts.managers import BusShiftManager class BusShift(models.Model): - bus = models.ForeignKey('fleet.Bus', on_delete=models.PROTECT, related_name='shifts') - driver = models.ForeignKey('fleet.Driver', on_delete=models.PROTECT, related_name='shifts') + bus = models.ForeignKey( + "fleet.Bus", on_delete=models.PROTECT, related_name="shifts" + ) + driver = models.ForeignKey( + "fleet.Driver", on_delete=models.PROTECT, related_name="shifts" + ) + objects = BusShiftManager() + + # Attributes added by BusShiftQuerySet.with_departure_and_arrival(). + _departure_at: Optional[datetime] + _arrival_at: Optional[datetime] class Meta: verbose_name = "Bus shift" @@ -19,19 +29,25 @@ def __str__(self) -> str: @property def departure_at(self) -> Optional[datetime]: - """Return the datetime of the first stop.""" + """Return the first stop time, or None when the shift has no stops.""" + if hasattr(self, "_departure_at"): + return self._departure_at + first_stop = self.stops.order_by("order").first() return first_stop.scheduled_at if first_stop else None @property def arrival_at(self) -> Optional[datetime]: - """Return the datetime of the last stop.""" - last_stop = self.stops.order_by("order").last() + """Return the last stop time, or None when the shift has no stops.""" + if hasattr(self, "_arrival_at"): + return self._arrival_at + + last_stop = self.stops.order_by("-order").first() return last_stop.scheduled_at if last_stop else None @property def duration(self) -> Optional[timedelta]: - """Return the duration between departure and arrival.""" + """Return The shift duration, or None when either boundary is missing.""" departure, arrival = self.departure_at, self.arrival_at if departure is None or arrival is None: return None @@ -82,7 +98,9 @@ def _validate_stops(stops: list["BusStop"]) -> list["BusStop"]: return ordered_stops - def _validate_resource_availability(self, *, departure: datetime, arrival: datetime) -> None: + def _validate_resource_availability( + self, *, departure: datetime, arrival: datetime + ) -> None: """Validate bus and driver availability for the shift period. Args: @@ -92,49 +110,44 @@ def _validate_resource_availability(self, *, departure: datetime, arrival: datet Raises: ValidationError: If the bus or driver has an overlapping shift. """ - overlapping_shifts = ( - BusShift.objects - .filter(Q(bus_id=self.bus_id)| Q(driver_id=self.driver_id)) - .exclude(pk=self.pk) - .annotate( - stop_count=Count("stops"), - candidate_departure=Min("stops__scheduled_at"), - candidate_arrival=Max("stops__scheduled_at"), - ) - .filter( - stop_count__gte=2, - candidate_departure__lt=arrival, - candidate_arrival__gt=departure, - ) + conflicting_shifts = BusShift.objects.conflicting_with( + bus_id=self.bus_id, + driver_id=self.driver_id, + departure=departure, + arrival=arrival, + exclude_pk=self.pk, ) errors = {} - if overlapping_shifts.filter(bus_id=self.bus_id).exists(): + if conflicting_shifts.filter(bus_id=self.bus_id).exists(): errors["bus"] = "This bus is already assigned to another shift" - if overlapping_shifts.filter(driver_id=self.driver_id).exists(): + if conflicting_shifts.filter(driver_id=self.driver_id).exists(): errors["driver"] = "This driver is already assigned to another shift" if errors: raise ValidationError(errors) - class BusStop(models.Model): - bus_shift = models.ForeignKey(BusShift, on_delete=models.CASCADE, related_name='stops') - place = models.ForeignKey('geography.Place', on_delete=models.PROTECT, related_name='bus_stops') + bus_shift = models.ForeignKey( + BusShift, on_delete=models.CASCADE, related_name="stops" + ) + place = models.ForeignKey( + "geography.Place", on_delete=models.PROTECT, related_name="bus_stops" + ) scheduled_at = models.DateTimeField("Scheduled at") order = models.PositiveSmallIntegerField("Order") class Meta: verbose_name = "Bus stop" verbose_name_plural = "Bus stops" - ordering = ['order'] + ordering = ["order"] constraints = [ models.UniqueConstraint( - fields=['bus_shift', 'order'], - name='shifts_bus_stop_unique_order_per_shift' + fields=["bus_shift", "order"], + name="shifts_bus_stop_unique_order_per_shift", ), ] From b8484e418647c7d511b38ad97b0bea270d1dbd06 Mon Sep 17 00:00:00 2001 From: Do SANON Date: Thu, 23 Jul 2026 12:21:31 +0200 Subject: [PATCH 12/16] chore: configure Ruff and Django Debug Toolbar --- padam_django/settings.py | 3 +++ padam_django/urls.py | 10 ++++++- pyproject.toml | 57 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 pyproject.toml diff --git a/padam_django/settings.py b/padam_django/settings.py index 443f882d..aa3f89f6 100644 --- a/padam_django/settings.py +++ b/padam_django/settings.py @@ -46,10 +46,12 @@ 'padam_django.apps.geography', 'padam_django.apps.shifts', 'padam_django.apps.users', + "debug_toolbar" ] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', + "debug_toolbar.middleware.DebugToolbarMiddleware", 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -57,6 +59,7 @@ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] +INTERNAL_IPS = ["127.0.0.1"] ROOT_URLCONF = 'padam_django.urls' diff --git a/padam_django/urls.py b/padam_django/urls.py index 7ecf590e..e484614b 100644 --- a/padam_django/urls.py +++ b/padam_django/urls.py @@ -13,9 +13,17 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ +from django.conf import settings from django.contrib import admin -from django.urls import path +from django.urls import path, include urlpatterns = [ path('admin/', admin.site.urls), ] + + +if settings.DEBUG: + import debug_toolbar + urlpatterns = [ + path("__debug__/", include(debug_toolbar.urls)), + ] + urlpatterns \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..5c17b97a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,57 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "django-test" +version = "0.1.0" +description = "Django technical test for bus shift management" +readme = "README.md" +requires-python = ">=3.9" +extend-exclude = ["*/migrations/*.py", ] + +[project.optional-dependencies] +dev = [ + "django-debug-toolbar==6.1.0", + "ruff", + "mypy", +] + +[tool.setuptools.packages.find] +include = ["padam_django*"] +namespaces = false + +[tool.ruff] +line-length = 88 +indent-width = 4 +target-version = "py39" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle + "D", # pydocstyle + "F", # Pyflakes + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "B", # flake8-bugbear + "SIM", # flake8-simplify +] +ignore = [ + "D100", # Do not require docstrings in Python modules. + "D104", # Do not require docstrings in packages. +] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = [ + "D103", # Do not require docstrings for test functions. +] + +[tool.ruff.lint.isort] +known-first-party = ["padam_django"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.format] +quote-style = "double" \ No newline at end of file From 2ec9ebcbfcfe2a19abcfdf5c61cc6c9f09eaa7ff Mon Sep 17 00:00:00 2001 From: Do SANON Date: Thu, 23 Jul 2026 12:25:51 +0200 Subject: [PATCH 13/16] ci: add GitHub Actions test workflow --- .github/workflows/ci.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..f1e53b8e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + pull_request: + +jobs: + tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.9" + cache: "pip" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Run tests + run: python manage.py test --noinput \ No newline at end of file From 1fc93a1ffaeecac154b403c48f198a7cfe669f80 Mon Sep 17 00:00:00 2001 From: Do SANON Date: Thu, 23 Jul 2026 12:27:43 +0200 Subject: [PATCH 14/16] docs: document implementation and technical decisions --- README.md | 8 ++++ docs/implementation.md | 88 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 docs/implementation.md diff --git a/README.md b/README.md index 3983ad80..3100ac13 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,14 @@ design a simple interface for managing bus routes, using Django admin. To carry out the test, remember to fork this repository. Ideally, open a PR at the end. +## Submission Documentation + +To review the solution: + +1. See [`docs/implementation.md`](docs/implementation.md) for the implemented features, technical choices, local development tools, and possible improvements. +2. See [`docs/design.md`](docs/design.md) for the UML diagram, derived properties, and business rules. + + ## Evaluation criteria - Code documentation and clarity diff --git a/docs/implementation.md b/docs/implementation.md new file mode 100644 index 00000000..0fc6ae45 --- /dev/null +++ b/docs/implementation.md @@ -0,0 +1,88 @@ +# Implementation Summary + +## Implemented Features + +- Added the `BusShift` and `BusStop` models. +- Required each shift to contain at least two stops. +- Enforced unique stop ordering within each shift. +- Prevented overlapping shifts for the same bus or driver. +- Calculated departure time, arrival time, and duration from the related stops. +- Added search, autocomplete fields, filters, and ordering to the Django Admin. +- Added custom `QuerySet` and manager methods for reusable business queries. +- Identified N+1 queries with Django Debug Toolbar and fixed them with optimized querysets. +- Added automated tests for the main business rules. +- Added Ruff for linting and formatting. +- Added a GitHub Actions CI workflow to run the test suite. + +## Technical Choices + +Departure time, arrival time, and duration are calculated from the related stops instead of being stored directly on `BusShift`. + +This avoids duplicated data and keeps the database consistent and normalized. + +If performance measurements later show that these calculations are too expensive, selective denormalization could be considered. + +## Local Development Tools + +Optional development dependencies are defined in `pyproject.toml`. + +Install them from the project root: + +```bash +python -m pip install --upgrade pip +python -m pip install -e ".[dev]" +``` + +The development dependencies include: + +```toml +[project.optional-dependencies] +dev = [ + "django-debug-toolbar==6.1.0", + "ruff", +] +``` + +### Ruff + +Run linting checks: + +```bash +python -m ruff check . +``` + +Check formatting: + +```bash +python -m ruff format --check . +``` + +Format the code: + +```bash +python -m ruff format . +``` + +### Django Debug Toolbar + +Django Debug Toolbar is used locally to inspect SQL queries and detect repeated queries or N+1 problems. + +It is a diagnostic tool only. The actual fixes use Django ORM features such as: + +It must not be enabled in production. + +## Possible Improvements + +The following improvements were left outside the scope of this exercise: + +- Introduce a service layer to separate business workflows from models +- PostgreSQL integration and concurrency. +- Separate settings for development, testing, and production, with sensitive values stored in a .env file. +- Centralize tool configuration in pyproject.toml. +- More validation for external entry points such as APIs or imports. +- More test coverage with `pytest` and `pytest-django`. +- Static type checking with Mypy +- Additional CI checks for migrations, formatting, and linting. +- Upgrade to the latest supported Django version and supported version to benefit from new features, security updates, and bug fixes. +- A REST API for external clients. +- Add internationalization and localization support. From 71be433c11e693b3b43ee15aa53eb522b8612a86 Mon Sep 17 00:00:00 2001 From: Do SANON Date: Thu, 23 Jul 2026 13:17:56 +0200 Subject: [PATCH 15/16] docs: update development dependency installation --- .github/workflows/ci.yml | 1 + docs/implementation.md | 4 ++-- pyproject.toml | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1e53b8e..f34f0d0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,7 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install -r requirements.txt + python -m pip install -e ".[dev]" - name: Run tests run: python manage.py test --noinput \ No newline at end of file diff --git a/docs/implementation.md b/docs/implementation.md index 0fc6ae45..846ac39b 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -24,9 +24,9 @@ If performance measurements later show that these calculations are too expensive ## Local Development Tools -Optional development dependencies are defined in `pyproject.toml`. +Development dependencies are defined in `pyproject.toml`. -Install them from the project root: +Install the project and its development tools from the project root: ```bash python -m pip install --upgrade pip diff --git a/pyproject.toml b/pyproject.toml index 5c17b97a..ff30ed26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,13 +8,12 @@ version = "0.1.0" description = "Django technical test for bus shift management" readme = "README.md" requires-python = ">=3.9" -extend-exclude = ["*/migrations/*.py", ] + [project.optional-dependencies] dev = [ "django-debug-toolbar==6.1.0", "ruff", - "mypy", ] [tool.setuptools.packages.find] @@ -22,6 +21,7 @@ include = ["padam_django*"] namespaces = false [tool.ruff] +extend-exclude = ["*/migrations/*.py", ] line-length = 88 indent-width = 4 target-version = "py39" From d4b6305fe976c5b5a59f344b6fe242245cb4eac4 Mon Sep 17 00:00:00 2001 From: Do SANON Date: Mon, 27 Jul 2026 08:58:19 +0200 Subject: [PATCH 16/16] fix(shifts): handle validation errors in the inline formset --- padam_django/apps/shifts/models.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/padam_django/apps/shifts/models.py b/padam_django/apps/shifts/models.py index f74eefb9..40d96b25 100644 --- a/padam_django/apps/shifts/models.py +++ b/padam_django/apps/shifts/models.py @@ -118,13 +118,19 @@ def _validate_resource_availability( exclude_pk=self.pk, ) - errors = {} + errors = [] if conflicting_shifts.filter(bus_id=self.bus_id).exists(): - errors["bus"] = "This bus is already assigned to another shift" + errors.append( + ValidationError("This bus is already assigned to another shift.") + ) if conflicting_shifts.filter(driver_id=self.driver_id).exists(): - errors["driver"] = "This driver is already assigned to another shift" + errors.append( + ValidationError( + "This driver is already assigned to another shift.", + ) + ) if errors: raise ValidationError(errors)