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 @@ -10,6 +10,7 @@ __pycache__/

# virtualenv
venv/
.venv/
ENV/
# pipenv: https://github.com/kennethreitz/pipenv
/Pipfile
Expand Down
138 changes: 138 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Mes Choix Techniques

Ce document reflete l'état de mes reflexions au cours du test.


## Objectifs du test à remplir

- Modéliser BusShift et BusStop
- Pas de chevauchements bus/chauffeur
- Interface admin pour créer/modifier les trajets
- Minimum 2 arrêts par trajet

## Modélisation

### 1. Structure des modèles

BusShift
├── Bus (1)
├── Driver (1)
└── BusStop (2 min - indéfini).
└── Place (1)

**BusShift**
- id (PrimaryKey)
- `bus` (ForeignKey => Bus, PROTECT)
- `driver` (ForeignKey => Driver, PROTECT)
- Properties calculées : `departure_time`, `arrival_time`, `total_duration`

**BusStop**
- id (PrimaryKey)
- `shift` (ForeignKey → BusShift, CASCADE)
- `place` (ForeignKey → Place, CASCADE)
- `time` (DateTimeField)
- `order` (PositiveIntegerField)

**Justification du champ `order`** :
- Garantit l'ordre des arrêts indépendamment du temps
- Rends l'ordre plus explicite que la validation


## Stratégie on_delete

### Bus/Driver / PROTECT

- Un bus avec des trajets historiques ne doit pas pouvoir être supprimé
- Protection des data
- soft delete préférable?

### BusStop / CASCADE

- Un arrêt sans trajet n'a pas de sens
- Simplification de la gestion

## Interface Admin

### Choix : TabularInline

**Alternatives considérées :**
1. Modèles séparés => UX horrible, pas de vue d'ensemble
2. TabularInline => Vue tableau intégrée
3. StackedInline =>Trop verbeux pour plusieurs arrêts et obligation de scroll
4. Drag & drop => Trop coûteux en temps

**Avantages du TabularInline :**
- Vue d'ensemble du trajet sur une page
- Impossible de créer des arrêts orphelins
- Validation du minimum 2 arrêts via FormSet
- UX claire et intuitive

## Contraintes à valider

### 1. Pas de chevauchements bus/driver

Deux trajets se chevauchent si l'un commence avant que l'autre ne se termine.
`start1 < end2 AND start2 < end1`

Idéalement il faudrait valider au niveau du modele ET de l'admin
Par manque de temps on privilégie LA ROBUSTESSE

Une validation seule au niveau de l'admin est plus risquée:

- Ne valide pas les données par script malveillant
- Ne protége pas l'API
- Ne valide pas les data crée dans le shell
- Ou data crées par des tests.

Dans l'idéal il devrait y avoir double validation front + back.

### 2. Minimum 2 arrêts par trajet

**Validation via FormSet** : La validation se fait au niveau du FormSet car le BusShift.save() n'est pas rappelé après l'ajout des stops inline dans l'admin.

## Améliorations Futures

### Admin / UX

1. **Améliorer la vue**
- Visualisation graphique des plannings par bus/chauffeur
- Drag & drop pour réorganiser les shifts
- Vue timeline pour détecter visuellement les conflits

2. **Fonction Duplication de shifts**
- Action admin "Dupliquer ce trajet"

3. **Filtres plus avancés** (dates, trajet, lieu)
- Filtrer par plage de dates
- Filtrer par durée de trajet
- Recherche par lieu


### Backend / Archi

1. **Validation de l'ordre chronologique**
- Vérifier que `time[n] < time[n+1]`
- Cohérence entre `order` et `time`

2. **Tests unitaires supplémentaires (les 4 plus critiques)**

- **Shifts back-to-back** (10h-12h puis 12h-14h) : valider le comportement souhaité
- **Modification d'un stop créant un conflit** : s'assurer que l'édition est bien validée
- **empêcher les doublons** :
- **Suppression d'un stop** : bloquer si le shift n'a que 2 stops

3. **Soft delete**
- Garder l'historique des trajets supprimés pour analytics

4. **API**

- Endpoints (GET et POST) pour BusShift et BusStop

5. **Contraintes**
- Check constraint : `order >= 0`
- Check constraint : durée trajet < 24h

## Ce que j'aurais aimé faire différement:
Renforcer la validation Backend des 2 arrêts pour plus de robustesse. Et ajouter une validation frontend pour les chevauchements.

Mieux committer le code.
16 changes: 16 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,19 @@ run: ## Run the test server.

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

migrate:
python manage.py migrate

makemigrations:
python manage.py makemigrations

import_fake_data:
python manage.py create_data

create_admin:
python manage.py createsuperuser

test:
python manage.py test padam_django.apps.fleet.tests

40 changes: 40 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.core.exceptions import ValidationError
from django.forms import BaseInlineFormSet


from . import models

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


class BusStopFormSet(BaseInlineFormSet):
def clean(self):
super().clean()

valid_stops = 0
for form in self.forms:
if form.cleaned_data and not form.cleaned_data.get('DELETE', False):
if form.cleaned_data.get('order') is not None and form.cleaned_data.get('place') and form.cleaned_data.get('time'):
valid_stops += 1

if valid_stops < 2:
raise ValidationError("Un shift doit avoir au moins deux arrêts (départ et arrivée)")


class BusStopInline(admin.TabularInline):
model = models.BusStop
formset = BusStopFormSet
extra = 2
fields = ['order', 'place', 'time']
ordering = ['order']

def get_extra(self, request, obj=None, **kwargs):
if obj:
return 0
return 2


@admin.register(models.BusShift)
class BusShiftAdmin(admin.ModelAdmin):
list_display = ['id', 'bus', 'driver', 'departure_time', 'arrival_time', 'total_duration']
list_filter = ['bus', 'driver']
inlines = [BusStopInline]

def get_queryset(self, request):
return super().get_queryset(request).select_related('bus', 'driver__user')
41 changes: 41 additions & 0 deletions padam_django/apps/fleet/migrations/0003_busshift_busstop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Generated by Django 4.2.16 on 2026-01-08 09:52

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.PROTECT, related_name='bus_shifts', to='fleet.bus')),
('driver', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='driver_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')),
('time', models.DateTimeField(verbose_name='Stop time')),
('order', models.PositiveIntegerField(verbose_name='Stop order')),
('place', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bus_stops', to='geography.place')),
('shift', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stops', to='fleet.busshift')),
],
options={
'ordering': ['order'],
'unique_together': {('shift', 'place', 'time')},
},
),
]
83 changes: 82 additions & 1 deletion padam_django/apps/fleet/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.db import models

from django.db.models import Min, Max
from django.core.exceptions import ValidationError

class Driver(models.Model):
user = models.OneToOneField('users.User', on_delete=models.CASCADE, related_name='driver')
Expand All @@ -16,3 +17,83 @@ class Meta:

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


class BusShift(models.Model):
bus = models.ForeignKey(Bus, on_delete=models.PROTECT, related_name='bus_shifts')
driver = models.ForeignKey(Driver, on_delete=models.PROTECT, related_name='driver_shifts')

class Meta:
verbose_name = "Bus Shift"
verbose_name_plural = "Bus Shifts"

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

def clean(self):
super().clean()
if self.pk:
stop_count = self.stops.count()
if stop_count < 2:
raise ValidationError("Un shift doit avoir au moins deux arrêts (départ et arrivée)")

def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)

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

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

@property
def total_duration(self):
if self.departure_time and self.arrival_time:
return self.arrival_time - self.departure_time
return None


class BusStop(models.Model):
shift = models.ForeignKey(BusShift, on_delete=models.CASCADE, related_name='stops')
place = models.ForeignKey('geography.Place', on_delete=models.CASCADE, related_name='bus_stops')
time = models.DateTimeField("Stop time")
order = models.PositiveIntegerField("Stop order")

class Meta:
unique_together = ['shift', 'place', 'time']
ordering = ['order']

def clean(self):
super().clean()
if not self.time or not self.shift_id:
return

current_stops = self.shift.stops.exclude(pk=self.pk) if self.pk else self.shift.stops.all()
times = list(current_stops.values_list('time', flat=True)) + [self.time]

departure = min(times)
arrival = max(times)


def check_overlap(filter_field, resource, error_label):
overlapping = (
BusShift.objects.filter(**{filter_field: resource})
.exclude(pk=self.shift.pk)
.annotate(departure=Min('stops__time'), arrival=Max('stops__time'))
.filter(departure__lt=arrival, arrival__gt=departure)
)
if overlapping.exists():
raise ValidationError(f"{error_label} est déjà assigné pendant cette période")


check_overlap('bus', self.shift.bus, f"Le bus {self.shift.bus.licence_plate}")
check_overlap('driver', self.shift.driver, f"Le chauffeur {self.shift.driver.user.username}")

def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
Empty file.
Loading