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