diff --git a/Makefile b/Makefile index 4062f4c4..b0f0be0c 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/padam_django/apps/fleet/admin.py b/padam_django/apps/fleet/admin.py index 3fba5023..bb387e72 100644 --- a/padam_django/apps/fleet/admin.py +++ b/padam_django/apps/fleet/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from . import models - +from . import forms @admin.register(models.Bus) class BusAdmin(admin.ModelAdmin): @@ -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 \ No newline at end of file diff --git a/padam_django/apps/fleet/factories.py b/padam_django/apps/fleet/factories.py index c78c832e..15223a38 100644 --- a/padam_django/apps/fleet/factories.py +++ b/padam_django/apps/fleet/factories.py @@ -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 @@ -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) \ No newline at end of file diff --git a/padam_django/apps/fleet/forms.py b/padam_django/apps/fleet/forms.py new file mode 100644 index 00000000..67311367 --- /dev/null +++ b/padam_django/apps/fleet/forms.py @@ -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." + ) \ No newline at end of file diff --git a/padam_django/apps/fleet/migrations/0003_busshift.py b/padam_django/apps/fleet/migrations/0003_busshift.py new file mode 100644 index 00000000..62d7ce64 --- /dev/null +++ b/padam_django/apps/fleet/migrations/0003_busshift.py @@ -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", + }, + ), + ] diff --git a/padam_django/apps/fleet/models.py b/padam_django/apps/fleet/models.py index 4cd3f19d..61615d71 100644 --- a/padam_django/apps/fleet/models.py +++ b/padam_django/apps/fleet/models.py @@ -1,3 +1,4 @@ +from django.core.validators import ValidationError from django.db import models @@ -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})" \ No newline at end of file diff --git a/padam_django/apps/fleet/tests.py b/padam_django/apps/fleet/tests.py new file mode 100644 index 00000000..1dbc086d --- /dev/null +++ b/padam_django/apps/fleet/tests.py @@ -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) \ No newline at end of file diff --git a/padam_django/apps/geography/admin.py b/padam_django/apps/geography/admin.py index e0334458..22cacab6 100644 --- a/padam_django/apps/geography/admin.py +++ b/padam_django/apps/geography/admin.py @@ -6,3 +6,7 @@ @admin.register(models.Place) class PlaceAdmin(admin.ModelAdmin): pass + +@admin.register(models.BusStop) +class BusStopAdmin(admin.ModelAdmin): + pass \ No newline at end of file diff --git a/padam_django/apps/geography/factories.py b/padam_django/apps/geography/factories.py index b134a30c..9eda0624 100644 --- a/padam_django/apps/geography/factories.py +++ b/padam_django/apps/geography/factories.py @@ -1,6 +1,7 @@ +from django.utils import timezone import factory from faker import Faker - +from datetime import timedelta from . import models @@ -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 + + \ No newline at end of file diff --git a/padam_django/apps/geography/migrations/0002_busstop_busstop_unique_place_stop_time.py b/padam_django/apps/geography/migrations/0002_busstop_busstop_unique_place_stop_time.py new file mode 100644 index 00000000..b2043bad --- /dev/null +++ b/padam_django/apps/geography/migrations/0002_busstop_busstop_unique_place_stop_time.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.16 on 2025-11-26 09:04 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + dependencies = [ + ("geography", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="BusStop", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ("arrival_at", models.DateTimeField(default=django.utils.timezone.now)), + ( + "place", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="bus_stops", + to="geography.place", + ), + ), + ], + options={ + "ordering": ["arrival_at"], + }, + ), + migrations.AddConstraint( + model_name="busstop", + constraint=models.UniqueConstraint( + fields=("name", "place", "arrival_at"), name="unique_place_stop_time" + ), + ), + ] diff --git a/padam_django/apps/geography/models.py b/padam_django/apps/geography/models.py index e566ee2b..2f9ea04d 100644 --- a/padam_django/apps/geography/models.py +++ b/padam_django/apps/geography/models.py @@ -1,5 +1,5 @@ from django.db import models - +from django.utils import timezone class Place(models.Model): name = models.CharField("Name of the place", max_length=50) @@ -13,3 +13,21 @@ class Meta: def __str__(self): return f"Place: {self.name} (id: {self.pk})" + +class BusStop(models.Model): + name = models.CharField(max_length=100) + place = models.ForeignKey(Place,on_delete=models.CASCADE, related_name="bus_stops") + arrival_at = models.DateTimeField(default=timezone.now) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["name", "place", "arrival_at"], + name="unique_place_stop_time", + ) + ] + ordering = ["arrival_at"] + + + def __str__(self): + return f"BusStop: {self.place.name} at {self.arrival_at}" \ 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