diff --git a/.gitignore b/.gitignore index d7d26693..32975960 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ ENV/ # Editors stuff .idea .vscode + +myenv \ No newline at end of file diff --git a/padam_django/apps/trips/__init__.py b/padam_django/apps/trips/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/trips/admin.py b/padam_django/apps/trips/admin.py new file mode 100644 index 00000000..8b2f6bf8 --- /dev/null +++ b/padam_django/apps/trips/admin.py @@ -0,0 +1,13 @@ +from django.contrib import admin +from .models import BusStop, BusShift +from .forms import BusShiftForm + + +class BusShiftAdmin(admin.ModelAdmin): + form = BusShiftForm + list_display = ["bus", "driver", "start_time", "end_time", "duration"] + search_fields = ["bus__licence_plate", "driver__user__username"] + + +admin.site.register(BusStop) +admin.site.register(BusShift, BusShiftAdmin) diff --git a/padam_django/apps/trips/apps.py b/padam_django/apps/trips/apps.py new file mode 100644 index 00000000..acf40bca --- /dev/null +++ b/padam_django/apps/trips/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class TripsConfig(AppConfig): + name = "padam_django.apps.trips" diff --git a/padam_django/apps/trips/forms.py b/padam_django/apps/trips/forms.py new file mode 100644 index 00000000..d7276506 --- /dev/null +++ b/padam_django/apps/trips/forms.py @@ -0,0 +1,91 @@ +from django import forms +from django.core.exceptions import ValidationError +from django.db.models import Q +from .models import BusShift + + +class BusShiftForm(forms.ModelForm): + """ + A Django form for creating or updating `BusShift` instances, which represent + individual bus shifts that must contain a unique sequence of at least two bus stops. + + This form performs validation on the set of bus stops provided to ensure: + - No duplicate stops are included within the shift. + - At least two unique bus stops are specified for a valid shift. + + Attributes: + model: The model associated with this form, set to `BusShift`. + fields: Specifies that all fields in `BusShift` should be included in the form. + """ + + class Meta: + model = BusShift + fields = "__all__" + + def clean(self): + """ + Override the clean method to perform custom validation on bus stops. + + This method checks: + - No duplicate bus stops are included in the shift. + - At least two unique bus stops are specified. + + Raises: + ValidationError: If duplicate stops are found or fewer than two unique stops are provided. + + Returns: + cleaned_data: The validated form data. + """ + cleaned_data = super().clean() + stops = self.cleaned_data.get("stops") + + if stops: + unique_stops = set() + for stop in stops: + unique_stops.add(stop) + + if len(unique_stops) < 2: + raise ValidationError("At least two bus stops are required.") + + self._calculate_shift_times(unique_stops) + self._validate_unique_shift() + return cleaned_data + + def _calculate_shift_times(self, unique_stops): + """ + Calculate start_time, end_time, and duration based on unique bus stops. + Assigns None values if no stops are assigned, and duration as zero. + """ + if unique_stops: + sorted_stops = sorted(unique_stops, key=lambda x: x.stop_time) + first_stop = sorted_stops[0] + last_stop = sorted_stops[-1] + + self.instance.start_time = first_stop.stop_time + self.instance.end_time = last_stop.stop_time + else: + self.instance.start_time = self.instance.end_time = None + + def _validate_unique_shift(self): + """ + Ensure there are no overlapping shifts for the same bus or driver. + + Raises: + ValidationError: If any overlap exists with another shift's start or end times. + """ + + if not self.instance.start_time or not self.instance.end_time: + return + + bus = self.cleaned_data.get("bus") + driver = self.cleaned_data.get("driver") + if bus and driver and self.instance.start_time and self.instance.end_time: + overlapping_shifts = BusShift.objects.exclude(pk=self.instance.pk).filter( + Q(bus=bus) | Q(driver=driver), + Q(start_time__lt=self.instance.end_time), + Q(end_time__gt=self.instance.start_time), + ) + if overlapping_shifts.exists(): + raise ValidationError( + "This shift overlaps with an existing shift for the same bus or driver." + ) diff --git a/padam_django/apps/trips/migrations/0001_initial.py b/padam_django/apps/trips/migrations/0001_initial.py new file mode 100644 index 00000000..25a7b140 --- /dev/null +++ b/padam_django/apps/trips/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 3.2.5 on 2024-11-04 11:24 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('fleet', '0002_auto_20211109_1456'), + ('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)), + ('stop_time', models.DateTimeField(verbose_name='Stop Time')), + ('place', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bus_stops', to='geography.place')), + ], + options={ + 'ordering': ['stop_time'], + }, + ), + migrations.CreateModel( + name='BusShift', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('duration', models.DurationField(blank=True, editable=False, null=True, verbose_name='Duration')), + ('start_time', models.DateTimeField(blank=True, editable=False, null=True, verbose_name='Start Time')), + ('end_time', models.DateTimeField(blank=True, editable=False, null=True, verbose_name='End Time')), + ('bus', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shifts', to='fleet.bus')), + ('driver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shifts', to='fleet.driver')), + ('stops', models.ManyToManyField(to='trips.BusStop')), + ], + ), + migrations.AddConstraint( + model_name='busstop', + constraint=models.UniqueConstraint(fields=('name', 'place', 'stop_time'), name='unique_place_stop_time'), + ), + ] diff --git a/padam_django/apps/trips/migrations/__init__.py b/padam_django/apps/trips/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/trips/models.py b/padam_django/apps/trips/models.py new file mode 100644 index 00000000..83049c5d --- /dev/null +++ b/padam_django/apps/trips/models.py @@ -0,0 +1,74 @@ +from django.db import models + + +class BusStop(models.Model): + """ + Represents a bus stop with a specific place, stop time, and unique identifier (name). + + Attributes: + name (str): The name of the bus stop. + place (ForeignKey): The geographical location of the stop. + stop_time (DateTimeField): The scheduled stop time at this place. + + Meta: + constraints: Ensures that a unique combination of name, place, and stop_time exists. + ordering: Orders bus stops by their stop time by default. + """ + + name = models.CharField(max_length=100) + place = models.ForeignKey( + "geography.Place", on_delete=models.CASCADE, related_name="bus_stops" + ) + stop_time = models.DateTimeField("Stop Time") + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["name", "place", "stop_time"], + name="unique_place_stop_time", + ) + ] + ordering = ["stop_time"] + + def __str__(self): + return f"{self.place} at {self.stop_time}" + + +class BusShift(models.Model): + """ + Represents a shift for a specific bus and driver with assigned stops, start and end times, and a duration. + + Attributes: + bus (ForeignKey): The bus assigned to this shift. + driver (ForeignKey): The driver assigned to this shift. + stops (ManyToManyField): The sequence of bus stops for the shift. + start_time (DateTimeField): Calculated start time of the shift. + end_time (DateTimeField): Calculated end time of the shift. + """ + + bus = models.ForeignKey( + "fleet.Bus", on_delete=models.CASCADE, related_name="shifts" + ) + driver = models.ForeignKey( + "fleet.Driver", on_delete=models.CASCADE, related_name="shifts" + ) + stops = models.ManyToManyField("BusStop") + start_time = models.DateTimeField( + "Start Time", null=True, blank=True, editable=False + ) + end_time = models.DateTimeField("End Time", null=True, blank=True, editable=False) + + @property + def duration(self): + """ + Calculate the duration of the shift based on the start and end times. + + Returns: + timedelta: The duration of the shift if both start_time and end_time are defined. + None: If either start_time or end_time is None, duration cannot be calculated. + """ + if self.end_time and self.start_time: + return self.end_time - self.start_time + + def __str__(self): + return f"Bus shift with Bus {self.bus} and driver {self.driver}" diff --git a/padam_django/apps/trips/tests/__init__.py b/padam_django/apps/trips/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/trips/tests/test_trips.py b/padam_django/apps/trips/tests/test_trips.py new file mode 100644 index 00000000..3351442e --- /dev/null +++ b/padam_django/apps/trips/tests/test_trips.py @@ -0,0 +1,167 @@ +from django.test import TestCase +from django.utils import timezone +from django.db import IntegrityError +from django.core.exceptions import ValidationError +from ..models import BusStop, BusShift +from ...geography.models import Place +from ...fleet.models import Bus, Driver +from ..forms import BusShiftForm +from django.contrib.auth import get_user_model +from datetime import timedelta + + +class BusShiftModelTests(TestCase): + + def setUp(self): + self.bus = Bus.objects.create(licence_plate="BUS1") + self.driver = Driver.objects.create(user=self.create_user()) + self.place1 = Place.objects.create(name="Stop 1", longitude=0.0, latitude=0.0) + self.place2 = Place.objects.create(name="Stop 2", longitude=0.1, latitude=0.1) + self.stop_time1 = timezone.now() + self.stop_time2 = self.stop_time1 + timedelta(minutes=10) + self.stop_time3 = self.stop_time1 + + def create_user(self): + """Helper method to create a user for the driver.""" + User = get_user_model() + return User.objects.create_user(username="driver_user", password="password") + + def test_create_bus_stop(self): + """Test that a BusStop can be created successfully.""" + unique_place = Place.objects.create(name="Stop A Unique", longitude=0.2, latitude=0.2) + bus_stop = BusStop.objects.create(place=unique_place, stop_time=timezone.now()) + self.assertIsInstance(bus_stop, BusStop) + self.assertEqual(bus_stop.place, unique_place) + + def test_create_bus_shift_with_valid_stops(self): + """Test that creating a BusShift with two stops is valid.""" + bus_shift = BusShift(bus=self.bus, driver=self.driver) + bus_shift.save() + + bus_stop1 = BusStop.objects.create(place=self.place1, stop_time=self.stop_time1) + bus_stop2 = BusStop.objects.create(place=self.place2, stop_time=self.stop_time2) + bus_shift.stops.add(bus_stop1, bus_stop2) + + try: + bus_shift.clean() + bus_shift.save() + self.assertIsNotNone(bus_shift.pk) + except ValidationError as e: + self.fail(f"BusShift creation failed: {str(e)}") + + def test_bus_shift_with_two_stops(self): + """Test that a BusShift with exactly two stops passes validation.""" + bus_stop1 = BusStop.objects.create(place=self.place1, stop_time=self.stop_time1) + bus_stop2 = BusStop.objects.create(place=self.place2, stop_time=self.stop_time2) + + bus_shift = BusShift(bus=self.bus, driver=self.driver) + bus_shift.save() + bus_shift.stops.add(bus_stop1, bus_stop2) + + try: + bus_shift.clean() + except ValidationError: + self.fail("BusShift.clean() raised ValidationError unexpectedly with two stops.") + + def test_unique_bus_stop_constraint(self): + """Test that creating duplicate BusStops raises an IntegrityError.""" + place = Place.objects.create(name="Unique Place", longitude=1.0, latitude=2.0) + + BusStop.objects.create(name="Stop A", place=place, stop_time=timezone.now()) + + with self.assertRaises(IntegrityError): + BusStop.objects.create(name="Stop A", place=place, stop_time=self.stop_time3) + + +class BusShiftFormTests(TestCase): + + def setUp(self): + self.bus = Bus.objects.create(licence_plate="BUS1") + self.driver = Driver.objects.create(user=self.create_user()) + self.place1 = Place.objects.create(name="Stop 1", longitude=0.0, latitude=0.0) + self.place2 = Place.objects.create(name="Stop 2", longitude=0.1, latitude=0.1) + self.stop_time1 = timezone.now() + self.stop_time2 = self.stop_time1 + timedelta(minutes=10) + self.start_time1 = timezone.now() + timedelta(hours=2) + self.end_time1 = self.start_time1 + timedelta(hours=1) + self.bus_shift1 = BusShift.objects.create( + bus=self.bus, + driver=self.driver, + start_time=self.start_time1, + end_time=self.end_time1 + ) + + def create_user(self): + """Helper method to create a user for the driver.""" + User = get_user_model() + return User.objects.create_user(username="driver_user", password="password") + + def test_valid_form_with_two_unique_stops(self): + """Test that a form with two unique stops is valid.""" + bus_stop1 = BusStop.objects.create(place=self.place1, stop_time=self.stop_time1) + bus_stop2 = BusStop.objects.create(place=self.place2, stop_time=self.stop_time2) + + form_data = { + 'bus': self.bus.id, + 'driver': self.driver.id, + 'stops': [bus_stop1.id, bus_stop2.id], + } + form = BusShiftForm(data=form_data) + self.assertTrue(form.is_valid()) + + def test_form_with_one_stop(self): + """Test that a form with one stop raises a ValidationError.""" + bus_stop1 = BusStop.objects.create(place=self.place1, stop_time=self.stop_time1) + + form_data = { + 'bus': self.bus.id, + 'driver': self.driver.id, + 'stops': [bus_stop1.id], + } + form = BusShiftForm(data=form_data) + self.assertFalse(form.is_valid()) + self.assertIn('__all__', form.errors) + self.assertEqual(form.errors['__all__'], ["At least two bus stops are required."]) + + def test_form_with_no_stops(self): + """Test that a form with no stops raises a ValidationError.""" + form_data = { + 'bus': self.bus.id, + 'driver': self.driver.id, + 'stops': [], + } + form = BusShiftForm(data=form_data) + self.assertFalse(form.is_valid()) + self.assertIn('stops', form.errors) + self.assertEqual(form.errors['stops'], ["This field is required."]) + + def test_calculate_shift_times(self): + """Test that the shift times are calculated correctly.""" + bus_stop1 = BusStop.objects.create(place=self.place1, stop_time=self.stop_time1) + bus_stop2 = BusStop.objects.create(place=self.place2, stop_time=self.stop_time2) + + form_data = { + 'bus': self.bus.id, + 'driver': self.driver.id, + 'stops': [bus_stop1.id, bus_stop2.id], + } + form = BusShiftForm(data=form_data) + if form.is_valid(): + bus_shift = form.save() + self.assertEqual(bus_shift.start_time, self.stop_time1) + self.assertEqual(bus_shift.end_time, self.stop_time2) + self.assertEqual(bus_shift.duration, self.stop_time2 - self.stop_time1) + + def test_no_overlap_with_another_shift(self): + """Test that non-overlapping shifts do not raise a ValidationError.""" + bus_shift3 = BusShift( + bus=self.bus, + driver=self.driver, + start_time=self.start_time1, + end_time=self.end_time1 + ) + + try: + bus_shift3.clean() + except ValidationError: + self.fail("clean() raised ValidationError unexpectedly!") diff --git a/padam_django/settings.py b/padam_django/settings.py index 129e922c..8725047d 100644 --- a/padam_django/settings.py +++ b/padam_django/settings.py @@ -45,6 +45,7 @@ 'padam_django.apps.fleet', 'padam_django.apps.geography', 'padam_django.apps.users', + 'padam_django.apps.trips', ] MIDDLEWARE = [