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
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
run: ## Run the test server.
python manage.py runserver_plus

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

install: ## Install the python requirements.
pip install -r requirements.txt
31 changes: 24 additions & 7 deletions padam_django/apps/fleet/admin.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
from django.contrib import admin

from . import models
from . import forms, models

admin.site.register(models.Bus)
admin.site.register(models.Driver)

@admin.register(models.Bus)
class BusAdmin(admin.ModelAdmin):
pass

class BusStopInline(admin.TabularInline):
model = models.BusStop
ordering = ["arrival_at"]
formset = forms.BusStopInlineFormSet

@admin.register(models.Driver)
class DriverAdmin(admin.ModelAdmin):
pass
def get_extra(self, request, obj=None, **kwargs):
return 0 if obj else 2


@admin.register(models.BusShift)
class BusShiftAdmin(admin.ModelAdmin):
list_display = (
"id",
"bus",
"driver",
"departure_at",
"first_place_name",
"arrival_at",
"last_place_name",
"shift_duration",
)
inlines = [BusStopInline]
29 changes: 27 additions & 2 deletions padam_django/apps/fleet/factories.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
from datetime import datetime

import factory
from django.utils import timezone
from faker import Faker

from padam_django.apps.geography.factories import PlaceFactory

from . import models

fake = Faker(["fr"])


fake = Faker(['fr'])
def set_datetime(h=0, m=0):
return timezone.make_aware(datetime(2025, 11, 22, h, m))


class DriverFactory(factory.django.DjangoModelFactory):
user = factory.SubFactory('padam_django.apps.users.factories.UserFactory')
user = factory.SubFactory("padam_django.apps.users.factories.UserFactory")

class Meta:
model = models.Driver
Expand All @@ -19,3 +27,20 @@ class BusFactory(factory.django.DjangoModelFactory):

class Meta:
model = models.Bus


class BusShiftFactory(factory.django.DjangoModelFactory):
bus = factory.SubFactory(BusFactory)
driver = factory.SubFactory(DriverFactory)

class Meta:
model = models.BusShift


class BusStopFactory(factory.django.DjangoModelFactory):
shift = factory.SubFactory(BusShiftFactory)
place = factory.SubFactory(PlaceFactory)
arrival_at = factory.LazyFunction(lambda: set_datetime(10))

class Meta:
model = models.BusStop
62 changes: 62 additions & 0 deletions padam_django/apps/fleet/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from readline import insert_text

from django.core.exceptions import ValidationError
from django.forms.models import BaseInlineFormSet
from django.utils.translation import gettext_lazy as _


class BusStopInlineFormSet(BaseInlineFormSet):
def check_bus_shift_period(self, min_arrival_at, max_arrival_at):
"""Check wheter the bus already has a shift overlap period."""
if (
self.instance.bus.shifts.exclude(id=self.instance.pk)
.filter_intersecting_period(min_arrival_at, max_arrival_at)
.exists()
):
raise ValidationError(
_("This bus is already assigned to another shift during this period")
)

def check_driver_shift_period(self, min_arrival_at, max_arrival_at):
"""Check whether the driver already has a journey period that overlaps with their shift."""
if (
self.instance.driver.shifts.exclude(id=self.instance.pk)
.filter_intersecting_period(min_arrival_at, max_arrival_at)
.exists()
):
raise ValidationError(
_("This driver is already assigned to another shift during this period")
)

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

if not self.instance.bus:
raise ValidationError(_("Please select a bus"))

if not self.instance.driver:
raise ValidationError(_("Please select a driver"))

stops = [
form.cleaned_data
for form in self.forms
if form.cleaned_data and not form.cleaned_data.get("DELETE")
]
if len(stops) < 2:
raise ValidationError(_("A shift must contain at least 2 stops."))

nb_places = len(set([cleaned_data["place"].id for cleaned_data in stops]))
if nb_places < 2:
raise ValidationError(_("A shift must contain at least 2 different stops."))

arrivals_at = [cleaned_data["arrival_at"] for cleaned_data in stops]
min_arrival_at = min(arrivals_at)
max_arrival_at = max(arrivals_at)

self.check_bus_shift_period(
min_arrival_at=min_arrival_at, max_arrival_at=max_arrival_at
)

self.check_driver_shift_period(
min_arrival_at=min_arrival_at, max_arrival_at=max_arrival_at
)
82 changes: 82 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,82 @@
# Generated by Django 4.2.16 on 2025-11-23 20: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(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="shifts",
to="fleet.bus",
),
),
(
"driver",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
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",
),
),
("arrival_at", models.DateTimeField()),
(
"place",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="shift_stops",
to="geography.place",
),
),
(
"shift",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="stops",
to="fleet.busshift",
),
),
],
options={
"ordering": ["arrival_at"],
"unique_together": {("shift", "arrival_at")},
},
),
]
105 changes: 104 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 django.utils.translation import gettext_lazy as _


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,103 @@ class Meta:

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


class BusShiftQuerySet(models.QuerySet):
def filter_intersecting_period(self, min_arrival_at, max_arrival_at):
return self.alias(
min_arrival_at=models.Min("stops__arrival_at"),
max_arrival_at=models.Max("stops__arrival_at"),
).filter(min_arrival_at__lte=max_arrival_at, max_arrival_at__gte=min_arrival_at)


class BusShiftManager(models.Manager):
def get_queryset(self):
return BusShiftQuerySet(self.model, using=self._db)

def filter_intersecting_period(self, min_arrival_at, max_arrival_at):
return self.get_queryset().filter_intersecting_period(
min_arrival_at, max_arrival_at
)


class BusShift(models.Model):
bus = models.ForeignKey(
Bus, on_delete=models.SET_NULL, null=True, related_name="shifts"
)

driver = models.ForeignKey(
Driver, on_delete=models.SET_NULL, null=True, related_name="shifts"
)

objects = BusShiftManager()

@property
def first_stop(self):
return self.stops.first()

@property
def last_stop(self):
return self.stops.last()

@property
def departure_at(self):
first_stop = self.first_stop
return first_stop.arrival_at if first_stop else None

@property
def arrival_at(self):
last_stop = self.last_stop
return last_stop.arrival_at if last_stop else None

def get_shift_duration(self):
"""Shift duration in minutes."""
arrival_at = self.arrival_at
departure_at = self.departure_at
if arrival_at is None or departure_at is None:
return None

return round((self.arrival_at - self.departure_at).total_seconds() // 60)

get_shift_duration.short_description = _("Duration (in minutes)")
shift_duration = property(get_shift_duration)

@property
def first_place(self):
first_stop = self.first_stop
return first_stop.place if first_stop else None

@property
def first_place_name(self):
first_place = self.first_place
return first_place.name if first_place else None

@property
def last_place(self):
last_stop = self.last_stop
return last_stop.place if last_stop else None

@property
def last_place_name(self):
last_place = self.last_place
return last_place.name if last_place else None

def __str__(self):
return f"Shift: {self.bus} - {self.driver} (id: {self.pk})"


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="shift_stops"
)

arrival_at = models.DateTimeField()

class Meta:
ordering = ["arrival_at"]
unique_together = ("shift", "arrival_at")

def __str__(self):
return f"Stop: {self.place} - {self.arrival_at} (id: {self.pk})"
Empty file.
Loading