From 69078873ef251d08e3dae91ac6850ee4f28c9a5c Mon Sep 17 00:00:00 2001 From: mbns Date: Mon, 3 Apr 2023 14:13:27 +0200 Subject: [PATCH 1/3] Add migrate command --- Makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Makefile b/Makefile index 4062f4c4..9d7a32d8 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,8 @@ run: ## Run the test server. python manage.py runserver_plus +migrate: ## Migrate django db models + python manage.py migrate + install: ## Install the python requirements. pip install -r requirements.txt From afb3301d61277f79cd3148e17756d716b876ecb3 Mon Sep 17 00:00:00 2001 From: mbns Date: Mon, 3 Apr 2023 14:21:40 +0200 Subject: [PATCH 2/3] Add Bushift and BusTop models with their factory --- padam_django/apps/route/__init__.py | 0 padam_django/apps/route/admin.py | 37 +++++ padam_django/apps/route/apps.py | 6 + padam_django/apps/route/factories.py | 41 ++++++ padam_django/apps/route/forms.py | 105 ++++++++++++++ .../management/commands/create_busshifts.py | 29 ++++ .../apps/route/migrations/0001_initial.py | 34 +++++ .../apps/route/migrations/__init__.py | 0 padam_django/apps/route/models.py | 50 +++++++ padam_django/apps/route/tests.py | 132 ++++++++++++++++++ padam_django/settings.py | 1 + 11 files changed, 435 insertions(+) create mode 100644 padam_django/apps/route/__init__.py create mode 100644 padam_django/apps/route/admin.py create mode 100644 padam_django/apps/route/apps.py create mode 100644 padam_django/apps/route/factories.py create mode 100644 padam_django/apps/route/forms.py create mode 100644 padam_django/apps/route/management/commands/create_busshifts.py create mode 100644 padam_django/apps/route/migrations/0001_initial.py create mode 100644 padam_django/apps/route/migrations/__init__.py create mode 100644 padam_django/apps/route/models.py create mode 100644 padam_django/apps/route/tests.py diff --git a/padam_django/apps/route/__init__.py b/padam_django/apps/route/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/route/admin.py b/padam_django/apps/route/admin.py new file mode 100644 index 00000000..5270be13 --- /dev/null +++ b/padam_django/apps/route/admin.py @@ -0,0 +1,37 @@ +from django.contrib import admin + +from . import forms +from .models import BusShift, BusStop + + +@admin.register(BusStop) +class BusStopAdmin(admin.ModelAdmin): + """ + Django Admin configuration for the `BusStop` model. + + The admin page displays a list of all the available bus stops, including their place name + and transit time. + """ + list_display = ('place', 'transit_time') + + +@admin.register(BusShift) +class BusShiftAdmin(admin.ModelAdmin): + """ + Django Admin configuration for the `BusShift` model. + + The admin page allows users to create, view, update, and delete instances of the `BusShift` model. + The form used to create or update instances of the `BusShift` model is defined by the `BusShiftForm` + class in the `forms` module. + + The list of `BusShift` instances includes the bus and driver assigned to each shift, as well as the + departure time, arrival time, and duration of the shift. The `BusShift` form includes fields to + specify the bus and driver, as well as a many-to-many relationship to the `BusStop` model to specify + the stops on the route. + + The `departure_time`, `arrival_time`, and `shift_duration` fields are automatically calculated based + on the stops included in the shift. + """ + form = forms.BusShiftForm + list_display = ('bus', 'driver', 'departure_time', 'arrival_time', 'shift_duration') + fields = ('bus', 'driver', 'stops') diff --git a/padam_django/apps/route/apps.py b/padam_django/apps/route/apps.py new file mode 100644 index 00000000..45081c19 --- /dev/null +++ b/padam_django/apps/route/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class RouteConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'padam_django.apps.route' diff --git a/padam_django/apps/route/factories.py b/padam_django/apps/route/factories.py new file mode 100644 index 00000000..34b8c6e1 --- /dev/null +++ b/padam_django/apps/route/factories.py @@ -0,0 +1,41 @@ +import random +from datetime import timedelta + +import factory +from django.utils import timezone + +from ..fleet.factories import BusFactory, DriverFactory +from ..geography.factories import PlaceFactory +from .models import BusShift, BusStop + + +class BusStopFactory(factory.django.DjangoModelFactory): + """ + A factory for creating BusStop instances. + + Attributes: + place (factory): A subfactory that creates Place instances. + transit_time (factory): A Faker that generates a random date_time value. + """ + place = factory.SubFactory(PlaceFactory) + transit_time = factory.LazyFunction( + lambda: timezone.now() + timedelta(days=random.randint(-30, 30), hours=random.randint(0, 23)) + ) + + class Meta: + model = BusStop + + +class BusShiftFactory(factory.django.DjangoModelFactory): + """ + A factory for creating BusShift instances. + + Attributes: + bus (factory): A subfactory that creates Bus instances. + driver (factory): A subfactory that creates Driver instances. + """ + class Meta: + model = BusShift + + bus = factory.SubFactory(BusFactory) + driver = factory.SubFactory(DriverFactory) diff --git a/padam_django/apps/route/forms.py b/padam_django/apps/route/forms.py new file mode 100644 index 00000000..beb60045 --- /dev/null +++ b/padam_django/apps/route/forms.py @@ -0,0 +1,105 @@ +from datetime import datetime + +from django import forms +from django.core.exceptions import ValidationError +from django.db.models.query import QuerySet + +from ..fleet.models import Bus, Driver +from .models import BusShift + + +class BusShiftForm(forms.ModelForm): + + class Meta: + """Meta class to define the model and fields used in the form.""" + model = BusShift + fields = ('bus', 'driver', 'stops') + + def clean(self) -> None: + """ + Clean method to validate the form data. + + Validates the following fields: + - bus: Check if the bus is available during the selected time frame. + - driver: Check if the driver is available during the selected time frame. + - stops: Check if there are at least two bus stops. + + Raises a ValidationError if any of the validations fail. + """ + cleaned_data = super().clean() + bus = cleaned_data.get('bus') + stops = cleaned_data.get('stops') + driver = cleaned_data.get('driver') + if stops: + departure_time = stops.order_by('transit_time').first().transit_time + arrival_time = stops.order_by('transit_time').last().transit_time + + self._check_two_stops_at_least(stops) + self._check_bus_availability(departure_time, arrival_time, bus) + self._check_driver_availability(departure_time, arrival_time, driver) + + def _check_bus_availability(self, departure_time: datetime, arrival_time: datetime, bus: Bus) -> None: + """ + Check if the given bus is available during the given time range. + + Args: + departure_time (datetime): The departure time of the bus shift. + arrival_time (datetime): The arrival time of the bus shift. + bus (Bus): The bus object to check availability for. + + Raises: + ValidationError: If the given bus is already in use during the given time range. + """ + shifts = BusShift.objects.filter(bus__pk=bus.pk) + if not self._is_available(shifts, departure_time, arrival_time): + raise ValidationError('This bus is already booked for a shift') + + def _check_driver_availability(self, departure_time: datetime, arrival_time: datetime, driver: Driver) -> None: + """ + Check if the selected driver is available during the specified shift time. + + Args: + departure_time (datetime): The departure time of the bus shift. + arrival_time (datetime): The arrival time of the bus shift. + driver (Driver): The Driver object representing the selected driver for the bus shift. + + Raises: + ValidationError: If the driver is already assigned to another shift during the specified time period. + """ + shifts = BusShift.objects.filter(driver__pk=driver.pk) + if not self._is_available(shifts, departure_time, arrival_time): + raise ValidationError('This driver is already on a shift') + + @staticmethod + def _is_available(shifts: QuerySet, departure_time: datetime, arrival_time: datetime) -> bool: + """ + Check if there is no overlap between the given shifts and the time range defined by the + departure and arrival times. + + Args: + shifts (QuerySet): A queryset of BusShift instances to compare against. + departure_time (datetime): The departure time of the new shift to be created. + arrival_time (datetime): The arrival time of the new shift to be created. + + Returns: + bool: True if there is no overlap between the shifts and the given time range, False otherwise. + """ + return not any( + (shift.departure_time <= departure_time <= shift.arrival_time) or + (shift.departure_time <= arrival_time <= shift.arrival_time) or + (shift.departure_time >= departure_time and shift.arrival_time <= arrival_time) + for shift in shifts + ) + + @staticmethod + def _check_two_stops_at_least(stops: QuerySet) -> None: + """Validate that at least two bus stops are provided. + + Args: + stops (QuerySet): The set of bus stops included in the shift. + + Raises: + ValidationError: Raised if less than two bus stops are included in the shift. + """ + if stops.count() < 2: + raise ValidationError('At least two stops are required') diff --git a/padam_django/apps/route/management/commands/create_busshifts.py b/padam_django/apps/route/management/commands/create_busshifts.py new file mode 100644 index 00000000..a2b47895 --- /dev/null +++ b/padam_django/apps/route/management/commands/create_busshifts.py @@ -0,0 +1,29 @@ +from padam_django.apps.common.management.base import CreateDataBaseCommand +from padam_django.apps.route.factories import BusShiftFactory, BusStopFactory + + +class Command(CreateDataBaseCommand): + """ + Create bus shifts with BusStops using the CreateDataBaseCommand. + + This command creates a specified number of bus shifts with two BusStops each. + The number of bus shifts to be created can be specified using the '--number' option. + """ + + help = 'Create bus shifts with BusStops' + + def handle(self, *args, **options): + """ + Handle the execution of the command. + + This method creates the specified number of bus shifts with two BusStops each, + using the CreateDataBaseCommand's handle method. It also prints the progress + and completion messages to the console. + """ + super().handle(*args, **options) + for _ in range(self.number): + stops = BusStopFactory.create_batch(2) + shift = BusShiftFactory() + shift.stops.set(stops) + shift.save() + self.stdout.write(f'Created BusShift {shift.pk} with {len(stops)} stops') diff --git a/padam_django/apps/route/migrations/0001_initial.py b/padam_django/apps/route/migrations/0001_initial.py new file mode 100644 index 00000000..27d4406f --- /dev/null +++ b/padam_django/apps/route/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.5 on 2023-04-03 12:18 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('geography', '0001_initial'), + ('fleet', '0002_auto_20211109_1456'), + ] + + operations = [ + migrations.CreateModel( + name='BusStop', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('transit_time', models.DateTimeField()), + ('place', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='geography.place')), + ], + ), + 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, to='fleet.bus')), + ('driver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fleet.driver')), + ('stops', models.ManyToManyField(blank=True, related_name='bus_shifts', to='route.BusStop')), + ], + ), + ] diff --git a/padam_django/apps/route/migrations/__init__.py b/padam_django/apps/route/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/route/models.py b/padam_django/apps/route/models.py new file mode 100644 index 00000000..8a483c4f --- /dev/null +++ b/padam_django/apps/route/models.py @@ -0,0 +1,50 @@ +from datetime import datetime +from typing import Optional + +from django.db import models + + +class BusStop(models.Model): + """A model representing a bus stop.""" + place = models.ForeignKey('geography.Place', on_delete=models.CASCADE) + transit_time = models.DateTimeField(auto_now=False, auto_now_add=False) + + def __str__(self) -> str: + """Returns a string representation of a BusStop instance.""" + transit_time_str = self.transit_time.strftime('%m/%d/%Y %H:%M') + return f"Bus Stop: {self.place.name} - {transit_time_str} (id: {self.pk})" + + +class BusShift(models.Model): + """A model representing a bus shift.""" + bus = models.ForeignKey('fleet.Bus', on_delete=models.CASCADE, null=False, blank=False) + driver = models.ForeignKey('fleet.Driver', on_delete=models.CASCADE, null=False, blank=False,) + stops = models.ManyToManyField('BusStop', related_name='bus_shifts', blank=True) + + @property + def departure_time(self) -> Optional[datetime]: + """Returns the departure time of the bus shift.""" + if self.stops.exists(): + return self.stops.all().order_by('transit_time').first().transit_time + else: + return None + + @property + def arrival_time(self) -> Optional[datetime]: + """Returns the arrival time of the bus shift.""" + if self.stops.exists(): + return self.stops.all().order_by('transit_time').last().transit_time + else: + return None + + @property + def shift_duration(self) -> Optional[datetime]: + """Returns the duration of the bus shift.""" + if self.stops.exists(): + return self.arrival_time - self.departure_time + else: + return None + + def __str__(self) -> str: + """Returns a string representation of a BusShift instance.""" + return f'Bus Shift: Bus:{self.bus.licence_plate}, Driver: {self.driver.user.username} (id: {self.pk})' diff --git a/padam_django/apps/route/tests.py b/padam_django/apps/route/tests.py new file mode 100644 index 00000000..d371c50b --- /dev/null +++ b/padam_django/apps/route/tests.py @@ -0,0 +1,132 @@ +from datetime import timedelta + +from django.core.exceptions import ValidationError +from django.test import TestCase + +from ..fleet.factories import BusFactory, DriverFactory +from ..route.forms import BusShiftForm +from .factories import BusShiftFactory, BusStopFactory + + +class BusShiftModelTestCase(TestCase): + + def setUp(self) -> None: + self.bus = BusFactory() + self.driver = DriverFactory() + + def test_check_bus_availability_raises_validation_error(self): + """ + Test if the '_check_bus_availability' method raises a ValidationError + when trying to create a new BusShift instance with the same bus and time range. + """ + stops = BusStopFactory.create_batch(2) + existing_shift = BusShiftFactory(bus=self.bus, driver=self.driver) + existing_shift.stops.set(stops) + existing_shift.save() + + # try to create a new BusShift instance with the same bus and time range + form = BusShiftForm(data={ + 'bus': existing_shift.bus.pk, + 'driver': existing_shift.driver.pk, + }) + with self.assertRaises(ValidationError): + form._check_bus_availability(existing_shift.departure_time, existing_shift.arrival_time, existing_shift.bus) + + def test_overlapping_shifts_raise_validation_error(self): + """ + Test if overlapping bus shifts raise a ValidationError when validating the form. + """ + # Create a bus shift with two bus stops + stops1 = BusStopFactory.create_batch(2) + existing_shift1 = BusShiftFactory(bus=self.bus, driver=self.driver) + existing_shift1.stops.set(stops1) + existing_shift1.save() + + # Create a bus shift with two bus stops, overlapping the first shift + departure_time_overlap = existing_shift1.departure_time + timedelta(minutes=30) + arrival_time_overlap = existing_shift1.arrival_time + timedelta(minutes=30) + stops2 = [ + BusStopFactory(transit_time=departure_time_overlap), + BusStopFactory(transit_time=arrival_time_overlap) + ] + form = BusShiftForm(data={ + 'bus': self.bus.pk, + 'driver': self.driver.pk, + 'stops': [stop.pk for stop in stops2], + }) + + self.assertFalse(form.is_valid()) + self.assertIn('__all__', form.errors) + self.assertIn('This bus is already booked for a shift', form.errors['__all__']) + + def test_overlapping_drivers_raise_validation_error(self): + """ + Test if overlapping driver shifts raise a ValidationError when validating the form. + """ + # Create a bus shift with two bus stops + stops1 = BusStopFactory.create_batch(2) + existing_shift1 = BusShiftFactory(bus=self.bus, driver=self.driver) + existing_shift1.stops.set(stops1) + existing_shift1.save() + + bus1 = BusFactory() + # Create a bus shift with two bus stops, overlapping the first shift + departure_time_overlap = existing_shift1.departure_time + timedelta(minutes=30) + arrival_time_overlap = existing_shift1.arrival_time + timedelta(minutes=30) + stops2 = [ + BusStopFactory(transit_time=departure_time_overlap), + BusStopFactory(transit_time=arrival_time_overlap) + ] + form = BusShiftForm(data={ + 'bus': bus1.pk, + 'driver': self.driver.pk, + 'stops': [stop.pk for stop in stops2], + }) + + self.assertFalse(form.is_valid()) + self.assertIn('__all__', form.errors) + self.assertIn('This driver is already on a shift', form.errors['__all__']) + + def test_one_stop_raise_validation_error(self): + """ + Test if having only one bus stop in a BusShift raises a ValidationError when validating the form. + """ + # Create a bus shift with only one bus stop + stop = BusStopFactory.create_batch(1) + existing_shift1 = BusShiftFactory(bus=self.bus, driver=self.driver) + existing_shift1.stops.set(stop) + existing_shift1.save() + + form = BusShiftForm(data={ + 'bus': self.bus.pk, + 'driver': self.driver.pk, + 'stops': [stop[0].pk], + }) + + self.assertFalse(form.is_valid()) + self.assertIn('__all__', form.errors) + self.assertIn('At least two stops are required', form.errors['__all__']) + + def test_no_error_when_shifts_do_not_overlap(self): + """ + Test if no validation error is raised when bus shifts do not overlap. + """ + # Create a bus shift with only two stops + stops = BusStopFactory.create_batch(2) + existing_shift = BusShiftFactory(bus=self.bus, driver=self.driver) + existing_shift.stops.set(stops) + existing_shift.save() + departure_time_new = existing_shift.arrival_time + timedelta(minutes=10) + arrival_time_new = existing_shift.arrival_time + timedelta(hours=2) + new_stops = [ + BusStopFactory(transit_time=departure_time_new), + BusStopFactory(transit_time=arrival_time_new) + ] + form = BusShiftForm( + data={ + 'bus': self.bus.pk, + 'driver': self.driver.pk, + 'stops': [stop.pk for stop in new_stops] + }, + ) + self.assertTrue(form.is_valid()) diff --git a/padam_django/settings.py b/padam_django/settings.py index 129e922c..92b3b159 100644 --- a/padam_django/settings.py +++ b/padam_django/settings.py @@ -44,6 +44,7 @@ 'padam_django.apps.common', 'padam_django.apps.fleet', 'padam_django.apps.geography', + 'padam_django.apps.route', 'padam_django.apps.users', ] From dba6adbd8287439e4feb4d1b1d52d2474f205f6b Mon Sep 17 00:00:00 2001 From: mbns Date: Mon, 3 Apr 2023 14:27:52 +0200 Subject: [PATCH 3/3] Add create_busshifts command that creates 5 busShifts with their stops --- padam_django/apps/common/management/commands/create_data.py | 1 + 1 file changed, 1 insertion(+) diff --git a/padam_django/apps/common/management/commands/create_data.py b/padam_django/apps/common/management/commands/create_data.py index a149a937..821fee08 100644 --- a/padam_django/apps/common/management/commands/create_data.py +++ b/padam_django/apps/common/management/commands/create_data.py @@ -12,3 +12,4 @@ def handle(self, *args, **options): management.call_command('create_drivers', number=5) management.call_command('create_buses', number=10) management.call_command('create_places', number=30) + management.call_command('create_busshifts', number=5)