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

migrate: ## Migrate django models.
python manage.py makemigrations
python manage.py migrate

superuser: ## Create a superuser.
python manage.py createsuperuser

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

test: ## Run the tests.
python manage.py test
6 changes: 5 additions & 1 deletion padam_django/apps/fleet/admin.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from django.contrib import admin

from . import models

from . import forms

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

@admin.register(models.BusShift)
class BusShiftAdmin(admin.ModelAdmin):
form = forms.BusShiftAdminForm
23 changes: 23 additions & 0 deletions padam_django/apps/fleet/factories.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import factory
from faker import Faker
from datetime import timedelta
from django.utils import timezone
from . import models
from ..geography.factories import BusStopFactory

from . import models

Expand All @@ -19,3 +23,22 @@ class BusFactory(factory.django.DjangoModelFactory):

class Meta:
model = models.Bus

class BusShiftFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.BusShift

bus = factory.SubFactory(BusFactory)
driver = factory.SubFactory(DriverFactory)

@factory.post_generation
def bus_stops(self,create,extracted,**kwargs):
if not create:
return
if extracted:
for stop in extracted:
self.bus_stops.add(stop)
else:
bus_stop1 = BusStopFactory(arrival_at=timezone.now())
bus_stop2 = BusStopFactory(arrival_at=timezone.now() + timedelta(minutes=30))
self.bus_stops.add(bus_stop1,bus_stop2)
68 changes: 68 additions & 0 deletions padam_django/apps/fleet/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from django import forms
from django.core.validators import ValidationError
from .models import BusShift

class BusShiftAdminForm(forms.ModelForm):
class Meta:
model = BusShift
fields = "__all__"

def clean(self):
cleaned_data = super().clean()
bus = cleaned_data.get("bus")
driver = cleaned_data.get("driver")
bus_stops = cleaned_data.get("bus_stops")

# Validate minimum number of stops
self._validate_bus_minimum_stops(bus_stops)

# Get departure and arrival time of the bus shift
departure_at, arrival_at=self._get_bus_shift_first_and_last_stop_time(bus_stops)

# Departure time must be before arrival
self._validate_shift_times(departure_at,arrival_at)

# Validate no overlapping shifts
self._validate_no_overlapping_shift(bus, driver, departure_at, arrival_at)

return cleaned_data

def _validate_bus_minimum_stops(self,bus_stops):
if not bus_stops or bus_stops.count() < 2:
raise ValidationError(
"A shift must have at least 2 bus stops."
)

def _get_bus_shift_first_and_last_stop_time(self,bus_stops):
departure_at = bus_stops.first().arrival_at
arrival_at = bus_stops.last().arrival_at

if not departure_at or not arrival_at:
raise ValidationError("Invalid bus shift time")

return departure_at, arrival_at

def _validate_shift_times(self,departure_at,arrival_at):
if departure_at >= arrival_at :
raise ValidationError(
"Departure time must be before arrival time."
)

def _validate_no_overlapping_shift(self, bus, driver, departure_at, arrival_at):
self._check_overlapping_shifts(bus,departure_at,arrival_at,"bus")
self._check_overlapping_shifts(driver,departure_at,arrival_at,"driver")

def _check_overlapping_shifts(self,entity,departure_at,arrival_at,entity_type):
overlapping_shifts = BusShift.objects.filter(
**{entity_type: entity}
).exclude(pk=self.instance.pk)

for shift in overlapping_shifts:
shift_arrival_time = shift.arrival_at
shift_departure_time = shift.departure_at

if shift_arrival_time and shift_departure_time:
if shift_departure_time < arrival_at and shift_arrival_time > departure_at :
raise ValidationError(
f"This {entity_type} already has a shift in this time interval."
)
48 changes: 48 additions & 0 deletions padam_django/apps/fleet/migrations/0003_busshift.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Generated by Django 4.2.16 on 2025-11-26 09:04

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


class Migration(migrations.Migration):
dependencies = [
("geography", "0002_busstop_busstop_unique_place_stop_time"),
("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.CASCADE,
related_name="shifts",
to="fleet.bus",
),
),
("bus_stops", models.ManyToManyField(to="geography.busstop")),
(
"driver",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="shifts",
to="fleet.driver",
),
),
],
options={
"verbose_name_plural": "Buses shifts",
},
),
]
23 changes: 23 additions & 0 deletions padam_django/apps/fleet/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from django.core.validators import ValidationError
from django.db import models


Expand All @@ -16,3 +17,25 @@ 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.CASCADE,related_name="shifts")
driver = models.ForeignKey(Driver,on_delete=models.CASCADE,related_name="shifts")
bus_stops = models.ManyToManyField("geography.BusStop")

class Meta:
verbose_name_plural = "Buses shifts"


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

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

def __str__(self):
return f"BusShift: {self.bus.licence_plate}, Driver: {self.driver.user.username} (id: {self.pk})"
122 changes: 122 additions & 0 deletions padam_django/apps/fleet/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
from datetime import timedelta
from django.utils import timezone
from django.test import TestCase

from ..geography.factories import BusStopFactory
from .factories import DriverFactory,BusFactory,BusShiftFactory
from .forms import BusShiftAdminForm

class BusShiftTestCase(TestCase):
"""
Tets for the BusShift model and its rules.
"""

def setUp(self):
"""
Objects for all tests:
- Two buses
- Two drivers
- Common base time
"""
self.bus1 = BusFactory()
self.bus2 = BusFactory()
self.driver1 = DriverFactory()
self.driver2 = DriverFactory()

self.base_time = timezone.now()

def test_bus_shift_creation(self):
"""
Test that a BusShift can be created successfully,
with at least two bus stops and correct departure/arrival.
"""
bus_shift = BusShiftFactory()

self.assertIsNotNone(bus_shift.pk)
self.assertGreater(bus_shift.arrival_at, bus_shift.departure_at)
self.assertEqual(bus_shift.bus_stops.count(),2)

def test_bus_shift_no_overlap_different_bus(self):
"""
Test non-overlapping shifts with same driver but different buses.
Also test that overlapping shifts raise ValidationError.
"""

# Shift 1
stop1 = BusStopFactory(arrival_at=self.base_time)
stop2 = BusStopFactory(arrival_at=self.base_time + timedelta(hours=2))
shift1 = BusShiftFactory(
bus=self.bus1, driver=self.driver1, bus_stops=[stop1, stop2]
)

# Shift 2 non-overlapping
stop3 = BusStopFactory(arrival_at=self.base_time + timedelta(hours=3))
stop4 = BusStopFactory(arrival_at=self.base_time + timedelta(hours=6))
shift2 = BusShiftFactory(
bus=self.bus2, driver=self.driver1, bus_stops=[stop3, stop4]
)

self.assertIsNotNone(shift1.pk)
self.assertIsNotNone(shift2.pk)

# Shift 3 overlapping with shift 1
stop5 = BusStopFactory(arrival_at=self.base_time + timedelta(hours=1))
stop6 = BusStopFactory(arrival_at=self.base_time + timedelta(hours=3))

form_data = {
'bus': self.bus2,
'driver': self.driver1,
'bus_stops': [stop5, stop6],
}

form = BusShiftAdminForm(data=form_data)
self.assertFalse(form.is_valid())

def test_bus_shift_no_overlap_different_driver(self):
"""
Shifts with the same bus but different drivers
and non-overlapping times are allowed.
"""
# Shift 1
stop1 = BusStopFactory(arrival_at=self.base_time)
stop2 = BusStopFactory(arrival_at=self.base_time + timedelta(hours=2))
shift1 = BusShiftFactory(
bus=self.bus1, driver=self.driver1, bus_stops=[stop1, stop2]
)

# Shift 2 non-overlapping
stop3 = BusStopFactory(arrival_at=self.base_time + timedelta(hours=3))
stop4 = BusStopFactory(arrival_at=self.base_time + timedelta(hours=6))
shift2 = BusShiftFactory(
bus=self.bus1, driver=self.driver2, bus_stops=[stop3, stop4]
)

self.assertIsNotNone(shift1.pk)
self.assertIsNotNone(shift2.pk)

# Shift 3 overlapping with shift 1
stop5 = BusStopFactory(arrival_at=self.base_time + timedelta(hours=1))
stop6 = BusStopFactory(arrival_at=self.base_time + timedelta(hours=3))

form_data = {
'bus': self.bus1,
'driver': self.driver2,
'bus_stops': [stop5, stop6],
}

form = BusShiftAdminForm(data=form_data)
self.assertFalse(form.is_valid())

def test_bus_shift_has_at_least_two_stops(self):
"""
BusShift must have at least two stops
"""
busshift = BusShiftFactory()
self.assertEqual(busshift.bus_stops.count(),2)

def test_bus_shift_departure_before_arrival(self):
"""
Departure must be before arrival
"""
busshift = BusShiftFactory()
self.assertLess(busshift.departure_at, busshift.arrival_at)
4 changes: 4 additions & 0 deletions padam_django/apps/geography/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@
@admin.register(models.Place)
class PlaceAdmin(admin.ModelAdmin):
pass

@admin.register(models.BusStop)
class BusStopAdmin(admin.ModelAdmin):
pass
13 changes: 12 additions & 1 deletion padam_django/apps/geography/factories.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.utils import timezone
import factory
from faker import Faker

from datetime import timedelta
from . import models


Expand All @@ -15,3 +16,13 @@ class PlaceFactory(factory.django.DjangoModelFactory):

class Meta:
model = models.Place

class BusStopFactory(factory.django.DjangoModelFactory):
name = factory.LazyFunction(fake.street_name)
place = factory.SubFactory(PlaceFactory)
arrival_at= factory.LazyFunction(lambda: timezone.now() + timedelta(minutes=fake.random_int(min=1,max=300)))

class Meta:
model = models.BusStop


Loading