Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ __pycache__/
# virtualenv
venv/
ENV/
mise.toml
# pipenv: https://github.com/kennethreitz/pipenv
/Pipfile

Expand Down
8 changes: 0 additions & 8 deletions .idea/.gitignore

This file was deleted.

6 changes: 0 additions & 6 deletions .idea/inspectionProfiles/Project_Default.xml

This file was deleted.

6 changes: 0 additions & 6 deletions .idea/inspectionProfiles/profiles_settings.xml

This file was deleted.

4 changes: 0 additions & 4 deletions .idea/misc.xml

This file was deleted.

8 changes: 0 additions & 8 deletions .idea/modules.xml

This file was deleted.

23 changes: 0 additions & 23 deletions .idea/padam-django-tech-test.iml

This file was deleted.

17 changes: 17 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,20 @@ run: ## Run the test server.

install: ## Install the python requirements.
pip install -r requirements.txt

migrate: ## Apply migrations.
python manage.py migrate

makemigrations: ## Create new migrations based on the models.
python manage.py makemigrations

create_admin_user: ## Create a superuser for the admin interface.
python manage.py createsuperuser --username admin --email admin@test.com

create_data: ## Create initial data.
python manage.py create_data

test: ## Run all tests.
python manage.py test padam_django.tests

setup_and_run: install migrate create_data create_admin_user run
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,18 @@ en temps que d'autres ...
- Privilégier la qualité et les bonnes pratiques.
- Vous pouvez réduire le périmètre du projet si vous manquez de temps. Une ébauche de réponse est déjà une bonne chose.
- Soyez prêt à présenter le sujet, à justifier vos choix et à parler de comment vous auriez fait les parties que vous avez laisser de côté.


---

### Notes Adèle
Things I would have done with more time:
- Make the BusStop form more user-friendly, sorting the places list by distance from the last bus stop coordinates
- in the context of a complete app dealing with bus routes, we would probably already have Postgis installed in a Postgres DB, so it could be a quick win
- Another nice option would be to be able to combine a classic select with a list and an autocomplete field (but I have not found a way to make it work with only django admin)
- Make the unique constraint take into account time to the minute would make it more useful
- I could not make it work in sqlite but I think with Postgres I could use TruncMinute("time") in the constraint
- Make error messages more explicit, referencing the id of the overlapping shift(s)
- Move BusShift and BusStop to a new app, as they might not really belong in "fleet"
- Create a few more tests to be exhaustive on BusShift error cases
- here I prioritized checking for overlap cases because it seemed like the riskiest part of the code (the rest being mostly managed by django)
71 changes: 71 additions & 0 deletions padam_django/apps/fleet/admin.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from django.contrib import admin
from django.db.models import Min, Max
from django.forms import BaseInlineFormSet
from django.core.exceptions import ValidationError

from . import models

Expand All @@ -11,3 +14,71 @@ class BusAdmin(admin.ModelAdmin):
@admin.register(models.Driver)
class DriverAdmin(admin.ModelAdmin):
pass


class BusStopFormSet(BaseInlineFormSet):
def _shift_has_overlap(self, shifts, departure_time, arrival_time):
if self.instance.pk:
shifts = shifts.exclude(pk=self.instance.pk)
return (
shifts.annotate(
departure=Min("stops__time"),
arrival=Max("stops__time"),
)
.filter(
departure__lte=arrival_time,
arrival__gte=departure_time,
)
.exists()
)

def clean(self):
super().clean()

# BusStops are not saved yet, so we have to get times from the forms
bus_stop_times = [
form.cleaned_data.get("time")
for form in self.forms
if form.cleaned_data.get("time") and not form.cleaned_data.get("DELETE")
]

if len(bus_stop_times) < 2:
raise ValidationError("A bus shift must have at least two valid stops.")

departure_time = min(bus_stop_times)
arrival_time = max(bus_stop_times)

if self._shift_has_overlap(
self.instance.bus.shifts, departure_time, arrival_time
):
raise ValidationError("This bus already has a conflicting shift.")
if self._shift_has_overlap(
self.instance.driver.shifts, departure_time, arrival_time
):
raise ValidationError("This driver already has a conflicting shift.")


class BusStopInline(admin.TabularInline):
model = models.BusStop
formset = BusStopFormSet
min_num = 2
extra = 0


@admin.register(models.BusShift)
class BusShiftAdmin(admin.ModelAdmin):
fields = ["driver", "bus"]
list_display = ["__str__", "departure", "arrival", "duration"]
inlines = [BusStopInline]

@admin.display(description="Departure")
def departure(self, obj):
return obj.departure_time

@admin.display(description="Arrival")
def arrival(self, obj):
return obj.arrival_time

@admin.display(description="Duration")
def duration(self, obj):
return obj.duration
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Generated by Django 4.2.16 on 2025-12-30 20:27

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('geography', '0001_initial'),
('fleet', '0002_auto_20211109_1456'),
]

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.RESTRICT, related_name='shifts', to='fleet.bus')),
('driver', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='shifts', to='fleet.driver')),
],
),
migrations.CreateModel(
name='BusStop',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('time', models.DateTimeField(verbose_name='Scheduled time at the designated place for the related bus shift')),
('bus_shift', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stops', to='fleet.busshift')),
('place', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='stops', to='geography.place')),
],
),
migrations.AddConstraint(
model_name='busstop',
constraint=models.UniqueConstraint(fields=('place', 'time', 'bus_shift'), name='unique_bus_stop_combination'),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 4.2.16 on 2026-01-02 10:28

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('fleet', '0003_busshift_busstop_busstop_unique_bus_stop_combination'),
]

operations = [
migrations.AlterModelOptions(
name='busstop',
options={'ordering': ['time']},
),
migrations.AlterField(
model_name='busstop',
name='time',
field=models.DateTimeField(verbose_name='Scheduled time of arrival at the stop'),
),
]
53 changes: 52 additions & 1 deletion padam_django/apps/fleet/models.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from django.db import models
from padam_django.apps.geography.models import Place


class Driver(models.Model):
user = models.OneToOneField('users.User', on_delete=models.CASCADE, related_name='driver')
user = models.OneToOneField(
"users.User", on_delete=models.CASCADE, related_name="driver"
)

def __str__(self):
return f"Driver: {self.user.username} (id: {self.pk})"
Expand All @@ -16,3 +19,51 @@ class Meta:

def __str__(self):
return f"Bus: {self.licence_plate} (id: {self.pk})"


class BusShift(models.Model):
driver = models.ForeignKey(Driver, on_delete=models.RESTRICT, related_name="shifts")
bus = models.ForeignKey(Bus, on_delete=models.RESTRICT, related_name="shifts")

def __str__(self):
return f"BusShift: Driver {self.driver.user.username} with bus {self.bus.licence_plate} (id: {self.pk})"

@property
def departure_time(self):
first_stop = self.stops.first()
return first_stop.time if first_stop else None

@property
def arrival_time(self):
last_stop = self.stops.last()
return last_stop.time if last_stop else None

@property
def duration(self):
arrival = self.arrival_time
departure = self.departure_time
if arrival and departure:
return arrival - departure
return None


class BusStop(models.Model):
place = models.ForeignKey(Place, on_delete=models.RESTRICT, related_name="stops")
bus_shift = models.ForeignKey(
BusShift, on_delete=models.CASCADE, related_name="stops"
)
time = models.DateTimeField("Scheduled time of arrival at the stop")

class Meta:
constraints = [
models.UniqueConstraint(
# If a shift has two stops at the same place and time
# one must be a duplicate
fields=["place", "time", "bus_shift"],
name="unique_bus_stop_combination",
)
]
ordering = ["time"]

def __str__(self):
return f"BusStop: {self.place.name} at {self.time.strftime('%H:%M')} (id: {self.pk})"
Empty file added padam_django/tests/__init__.py
Empty file.
Loading