diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..f34f0d0f
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,28 @@
+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
+ python -m pip install -e ".[dev]"
+
+ - name: Run tests
+ run: python manage.py test --noinput
\ No newline at end of file
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
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/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"
diff --git a/README.md b/README.md
index f99d629d..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
@@ -18,13 +26,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 +128,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
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.
diff --git a/docs/implementation.md b/docs/implementation.md
new file mode 100644
index 00000000..846ac39b
--- /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
+
+Development dependencies are defined in `pyproject.toml`.
+
+Install the project and its development tools 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.
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/__init__.py b/padam_django/apps/shifts/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/padam_django/apps/shifts/admin.py b/padam_django/apps/shifts/admin.py
new file mode 100644
index 00000000..b6b8d08d
--- /dev/null
+++ b/padam_django/apps/shifts/admin.py
@@ -0,0 +1,102 @@
+from django.contrib import admin
+from django.forms.models import BaseInlineFormSet
+
+from .models import BusShift, BusStop
+
+
+class BusStopInlineFormSet(BaseInlineFormSet):
+ def clean(self) -> None:
+ 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",)
+ ordering = ("order",)
+
+
+@admin.register(BusShift)
+class BusShiftAdmin(admin.ModelAdmin):
+ list_display = (
+ "id",
+ "bus",
+ "driver",
+ "departure_at_display",
+ "arrival_at_display",
+ "duration_display",
+ )
+ 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,)
+
+ 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",
+ ordering="_arrival_at",
+ )
+ 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_select_related = (
+ "bus_shift__bus",
+ "bus_shift__driver__user",
+ "place",
+ )
+ autocomplete_fields = ("bus_shift", "place")
+ search_fields = (
+ "place__name",
+ "bus_shift__bus__licence_plate",
+ "bus_shift__driver__user__username",
+ )
+ show_full_result_count = False
+ ordering = ("bus_shift", "order")
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/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/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/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..40d96b25
--- /dev/null
+++ b/padam_django/apps/shifts/models.py
@@ -0,0 +1,161 @@
+from datetime import datetime, timedelta
+from typing import Optional
+
+from django.core.exceptions import ValidationError
+from django.db import models
+
+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"
+ )
+ objects = BusShiftManager()
+
+ # Attributes added by BusShiftQuerySet.with_departure_and_arrival().
+ _departure_at: Optional[datetime]
+ _arrival_at: Optional[datetime]
+
+ 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 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 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 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
+ 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.
+ """
+ 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 conflicting_shifts.filter(bus_id=self.bus_id).exists():
+ errors.append(
+ ValidationError("This bus is already assigned to another shift.")
+ )
+
+ if conflicting_shifts.filter(driver_id=self.driver_id).exists():
+ errors.append(
+ ValidationError(
+ "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"
+ )
+ 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})"
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,
+ )
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
diff --git a/padam_django/settings.py b/padam_django/settings.py
index 129e922c..aa3f89f6 100644
--- a/padam_django/settings.py
+++ b/padam_django/settings.py
@@ -44,11 +44,14 @@
'padam_django.apps.common',
'padam_django.apps.fleet',
'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',
@@ -56,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..ff30ed26
--- /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"
+
+
+[project.optional-dependencies]
+dev = [
+ "django-debug-toolbar==6.1.0",
+ "ruff",
+]
+
+[tool.setuptools.packages.find]
+include = ["padam_django*"]
+namespaces = false
+
+[tool.ruff]
+extend-exclude = ["*/migrations/*.py", ]
+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
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