From 726919d87eb9fca7bfd5fe098855ab7cdc53bbac Mon Sep 17 00:00:00 2001 From: Florian Latapie <70631622+FlorianLatapie@users.noreply.github.com> Date: Mon, 11 Nov 2024 19:58:04 +0100 Subject: [PATCH] Add BusShift and BusStop implementation --- .idea/misc.xml | 5 +- .idea/padam-django-tech-test.iml | 2 +- Makefile | 10 +- README.md | 23 ++--- .../common/management/commands/create_data.py | 4 +- padam_django/apps/geography/admin.py | 5 + padam_django/apps/geography/factories.py | 12 ++- .../management/commands/create_bus_stops.py | 12 +++ .../apps/geography/migrations/0002_busstop.py | 25 +++++ padam_django/apps/geography/models.py | 16 +++ padam_django/apps/schedules/__init__.py | 0 padam_django/apps/schedules/admin.py | 9 ++ padam_django/apps/schedules/apps.py | 5 + .../apps/schedules/migrations/0001_initial.py | 31 ++++++ .../apps/schedules/migrations/__init__.py | 0 padam_django/apps/schedules/models.py | 92 +++++++++++++++++ padam_django/apps/schedules/tests.py | 99 +++++++++++++++++++ padam_django/apps/schedules/views.py | 3 + padam_django/settings.py | 1 + 19 files changed, 335 insertions(+), 19 deletions(-) create mode 100644 padam_django/apps/geography/management/commands/create_bus_stops.py create mode 100644 padam_django/apps/geography/migrations/0002_busstop.py create mode 100644 padam_django/apps/schedules/__init__.py create mode 100644 padam_django/apps/schedules/admin.py create mode 100644 padam_django/apps/schedules/apps.py create mode 100644 padam_django/apps/schedules/migrations/0001_initial.py create mode 100644 padam_django/apps/schedules/migrations/__init__.py create mode 100644 padam_django/apps/schedules/models.py create mode 100644 padam_django/apps/schedules/tests.py create mode 100644 padam_django/apps/schedules/views.py diff --git a/.idea/misc.xml b/.idea/misc.xml index 574ec96e..22f9dd00 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,7 @@ - + + + \ No newline at end of file diff --git a/.idea/padam-django-tech-test.iml b/.idea/padam-django-tech-test.iml index c7ffe09b..e917945d 100644 --- a/.idea/padam-django-tech-test.iml +++ b/.idea/padam-django-tech-test.iml @@ -14,7 +14,7 @@ - + diff --git a/Makefile b/Makefile index 4062f4c4..bd494fde 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,9 @@ -run: ## Run the test server. - python manage.py runserver_plus - install: ## Install the python requirements. pip install -r requirements.txt + +migrate: ## Migrate django models + python manage.py makemigrations + python manage.py migrate + +run: ## Run the test server. + python manage.py runserver_plus \ No newline at end of file diff --git a/README.md b/README.md index 8e1a8c29..27bc232c 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,11 @@ Pour réaliser le test, pensez à fork ce repository. Idéalement, ouvrir une PR ## Stack Technique | Nom | Version | -| ------ | ------- | +|--------|---------| | Python | 3.7 | | Django | 3.2.5 | - - Le projet à été réalisé en utilisant Python 3.7. Vous êtes libre d'utiliser une autre version mais c'est celle que + - Le projet a été réalisé en utilisant Python 3.7. Vous êtes libre d'utiliser une autre version, mais c'est celle que nous vous conseillons. - La base de donnée est au choix. Le projet est configuré pour utiliser `sqlite` par défaut. @@ -33,15 +33,16 @@ make run ``` Des scripts sont à votre disposition pour vous permettre de rapidement créer de la donnée et de prendre le projet en -main: +main : - `create_data` - `create_buses` - `create_drivers` - `create_places` - `create_users` +- `create_bus_stops` -Par exemple: +Par exemple : ``` python manage.py create_drivers -n 5 @@ -51,7 +52,7 @@ python manage.py create_drivers -n 5 ### Description -Un trajet en bus (`BusShift`) est composé des éléments suivants: +Un trajet en bus (`BusShift`) est composé des éléments suivants : - Un bus: (`Bus`). - Un chauffeur: (`Driver`). @@ -60,7 +61,7 @@ Un trajet en bus (`BusShift`) est composé des éléments suivants: - L'heure d'arrivée est déterminée par l'heure de passage au dernier arrêt. - Il est possible de déduire le temps total nécessaire pour effectuer le trajet depuis l'heure de départ et l'heure d'arrivée. -La structure de projet qui vous est proposée comprends déjà les models suivants: +La structure de projet qui vous est proposée comprend déjà les models suivants : - `Bus` - `Driver` - `Place` @@ -71,7 +72,7 @@ La structure de projet qui vous est proposée comprends déjà les models suivan #### Implémenter les modèles `BusShift` and `BusStop` à la base de code existante L'implémentation de ces deux modèles est libre et laissée à votre appréciation. Les contraintes métiers suivantes -doivent être respectées: +doivent être respectées : - Un même bus ne peut être assigné, en même temps, à plusieurs trajets dont les heures de début et fin se chevaucheraient. @@ -82,12 +83,12 @@ doivent être respectées: Il doit être possible, pour un utilisateur, de créer ou de modifier des trajets de bus (`BusShift`) en utilisant l'admin de django. -**Note**: Il existe plusieurs solutions pour concevoir cette fonctionnalité. Certaines seront peut être plus couteuse -en temps que d'autres ... +**Note** : Il existe plusieurs solutions pour concevoir cette fonctionnalité. Certaines seront peut-être plus couteuse +en temps que d'autres… ### Conseils - - Ne passez pas plus de 4 heures sur un sujet (le but est d'évaluer vos compétences, pas de réduire votre temps libre à néant ;-)) + - Ne passez pas plus de 4 heures sur un sujet (le but est d'évaluer vos compétences, pas de réduire votre temps libre à néant ;-).) - Privilégier la qualité et les bonnes pratiques. - Vous pouvez réduire le périmètre du projet si vous manquez de temps. Une ébauche de réponse est déjà une bonne chose. - - Soyez prêt à présenter le sujet, à justifier vos choix et à parler de comment vous auriez fait les parties que vous avez laisser de côté. + - Soyez prêt à présenter le sujet, à justifier vos choix et à parler de comment vous auriez fait les parties que vous avez laissées de côté. diff --git a/padam_django/apps/common/management/commands/create_data.py b/padam_django/apps/common/management/commands/create_data.py index a149a937..ed89b77f 100644 --- a/padam_django/apps/common/management/commands/create_data.py +++ b/padam_django/apps/common/management/commands/create_data.py @@ -1,6 +1,5 @@ -from django.core.management.base import BaseCommand - from django.core import management +from django.core.management.base import BaseCommand class Command(BaseCommand): @@ -12,3 +11,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_bus_stops', number=100) diff --git a/padam_django/apps/geography/admin.py b/padam_django/apps/geography/admin.py index e0334458..0f115a3f 100644 --- a/padam_django/apps/geography/admin.py +++ b/padam_django/apps/geography/admin.py @@ -6,3 +6,8 @@ @admin.register(models.Place) class PlaceAdmin(admin.ModelAdmin): pass + + +@admin.register(models.BusStop) +class BusStopAdmin(admin.ModelAdmin): + pass diff --git a/padam_django/apps/geography/factories.py b/padam_django/apps/geography/factories.py index b134a30c..6fd0c747 100644 --- a/padam_django/apps/geography/factories.py +++ b/padam_django/apps/geography/factories.py @@ -1,9 +1,10 @@ +from datetime import time + import factory from faker import Faker from . import models - fake = Faker(['fr']) @@ -15,3 +16,12 @@ class PlaceFactory(factory.django.DjangoModelFactory): class Meta: model = models.Place + + +class BusStopFactory(factory.django.DjangoModelFactory): + place = factory.SubFactory(PlaceFactory) + expected_arrival = factory.LazyFunction( + lambda: time(hour=fake.random_int(min=0, max=23), minute=fake.random_int(min=0, max=59))) + + class Meta: + model = models.BusStop diff --git a/padam_django/apps/geography/management/commands/create_bus_stops.py b/padam_django/apps/geography/management/commands/create_bus_stops.py new file mode 100644 index 00000000..3451fc17 --- /dev/null +++ b/padam_django/apps/geography/management/commands/create_bus_stops.py @@ -0,0 +1,12 @@ +from padam_django.apps.common.management.base import CreateDataBaseCommand + +from padam_django.apps.geography.factories import BusStopFactory + + +class Command(CreateDataBaseCommand): + help = 'Create few bus stops' + + def handle(self, *args, **options): + super().handle(*args, **options) + self.stdout.write(f'Creating {self.number} bus stops ...') + BusStopFactory.create_batch(size=self.number) diff --git a/padam_django/apps/geography/migrations/0002_busstop.py b/padam_django/apps/geography/migrations/0002_busstop.py new file mode 100644 index 00000000..d85af848 --- /dev/null +++ b/padam_django/apps/geography/migrations/0002_busstop.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.5 on 2024-11-10 14:24 + +from django.db import migrations, models +import django.db.models.deletion + + +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')), + ('expected_arrival', models.TimeField(verbose_name='Expected arrival time')), + ('place', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='geography.place')), + ], + options={ + 'unique_together': {('place', 'expected_arrival')}, + }, + ), + ] diff --git a/padam_django/apps/geography/models.py b/padam_django/apps/geography/models.py index e566ee2b..8a8e5db4 100644 --- a/padam_django/apps/geography/models.py +++ b/padam_django/apps/geography/models.py @@ -13,3 +13,19 @@ class Meta: def __str__(self): return f"Place: {self.name} (id: {self.pk})" + + +class BusStop(models.Model): + """ + A bus stop is a Place where a bus is expected to stop at a given time. + """ + place = models.ForeignKey(Place, on_delete=models.CASCADE) + expected_arrival = models.TimeField("Expected arrival time") + + class Meta: + # A bus stop cannot be duplicated. + # its either same time different place or same place different time + unique_together = (("place", "expected_arrival"),) + + def __str__(self): + return f"BusStop: {self.place.name} at {self.expected_arrival}" diff --git a/padam_django/apps/schedules/__init__.py b/padam_django/apps/schedules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/schedules/admin.py b/padam_django/apps/schedules/admin.py new file mode 100644 index 00000000..106166de --- /dev/null +++ b/padam_django/apps/schedules/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin + +from . import models + +# Register your models here. +@admin.register(models.BusShift) +class PlaceAdmin(admin.ModelAdmin): + form = models.BusShiftForm + list_display = ['bus', 'driver', 'start_time', 'end_time', 'duration'] \ No newline at end of file diff --git a/padam_django/apps/schedules/apps.py b/padam_django/apps/schedules/apps.py new file mode 100644 index 00000000..64fe6626 --- /dev/null +++ b/padam_django/apps/schedules/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class SchedulesConfig(AppConfig): + name = 'padam_django.apps.schedules' diff --git a/padam_django/apps/schedules/migrations/0001_initial.py b/padam_django/apps/schedules/migrations/0001_initial.py new file mode 100644 index 00000000..20b5691e --- /dev/null +++ b/padam_django/apps/schedules/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.5 on 2024-11-10 14: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', '0002_busstop'), + ] + + operations = [ + migrations.CreateModel( + name='BusShift', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('start_time', models.TimeField(verbose_name='Start time')), + ('end_time', models.TimeField(verbose_name='End time')), + ('bus', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fleet.bus')), + ('bus_stops', models.ManyToManyField(to='geography.BusStop')), + ('driver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fleet.driver')), + ], + options={ + 'unique_together': {('bus', 'driver', 'start_time')}, + }, + ), + ] diff --git a/padam_django/apps/schedules/migrations/__init__.py b/padam_django/apps/schedules/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/schedules/models.py b/padam_django/apps/schedules/models.py new file mode 100644 index 00000000..dc4a0782 --- /dev/null +++ b/padam_django/apps/schedules/models.py @@ -0,0 +1,92 @@ +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import Model +from django.forms import ModelForm +from django import forms +from datetime import datetime + +# Create your models here. +class BusShift(models.Model): + """ + A bus shift is a period of time during which a bus is driven by a driver and stops at least two bus stops. + Creating a bus shift is done by creating a BusShiftForm instance. + """ + bus = models.ForeignKey('fleet.Bus', on_delete=models.CASCADE) + driver = models.ForeignKey('fleet.Driver', on_delete=models.CASCADE) + bus_stops = models.ManyToManyField('geography.BusStop') + start_time = models.TimeField("Start time", blank=True, null=True) + end_time = models.TimeField("End time", blank=True, null=True) + + @property + def duration(self): + start_time = datetime.combine(datetime.today(), self.start_time) + end_time = datetime.combine(datetime.today(), self.end_time) + return end_time - start_time + + class Meta: + unique_together = (("bus", "driver", "start_time"), ) + + def __str__(self): + return (f"BusShift: {self.bus.licence_plate} driven by {self.driver.user.username} at {self.start_time}, duration: {self.duration}, with {self.bus_stops.count()} stops") + +class BusShiftForm(ModelForm): + """ + A form for creating and updating a BusShift instance. + This has the responsibility of validating the form data and computing the start_time and end_time fields. + """ + class Meta: + model = BusShift + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Hide start_time and end_time fields from the form but still compute them in the clean method. + self.fields['start_time'].widget = forms.HiddenInput() + self.fields['end_time'].widget = forms.HiddenInput() + + def clean(self): + cleaned_data = super().clean() + driver = cleaned_data.get('driver') + bus = cleaned_data.get('bus') + bus_stops = cleaned_data.get('bus_stops') + + self.check_bus_stops(bus_stops, cleaned_data) + + self.check_overlapping_shifts(bus, cleaned_data, driver) + + return cleaned_data + + def check_overlapping_shifts(self, bus, cleaned_data, driver): + """ + Check if the driver or the bus is already scheduled for another shift at this time. + :param bus: + :param cleaned_data: + :param driver: + :return: + """ + overlapping_shifts = BusShift.objects.filter( + models.Q(driver=driver) | models.Q(bus=bus), + models.Q(start_time__lt=cleaned_data['end_time'], end_time__gt=cleaned_data['start_time']) + ).exclude(pk=self.instance.pk) + if overlapping_shifts.exists(): + raise ValidationError("The driver or the bus is already scheduled for another shift at this time.") + + def check_bus_stops(self, bus_stops, cleaned_data): + """ + Check if the bus shift has at least two bus stops and sort them by expected arrival time. + This method alters the cleaned_data dictionary by sorting the bus stops and setting the start_time and end_time fields. + :param bus_stops: + :param cleaned_data: + :return: + """ + bus_stops_count = bus_stops.count() + + if bus_stops_count < 2: + raise ValidationError("A bus shift must have at least two bus stops.") + # Sort and remove duplicates + cleaned_data['bus_stops'] = sorted(list(set(bus_stops)), key=lambda x: x.expected_arrival) + last_item = bus_stops_count - 1 + cleaned_data['start_time'] = cleaned_data['bus_stops'][0].expected_arrival + cleaned_data['end_time'] = cleaned_data['bus_stops'][last_item].expected_arrival + diff --git a/padam_django/apps/schedules/tests.py b/padam_django/apps/schedules/tests.py new file mode 100644 index 00000000..f9fda48a --- /dev/null +++ b/padam_django/apps/schedules/tests.py @@ -0,0 +1,99 @@ +from django.test import TestCase + +from padam_django.apps.fleet.factories import BusFactory, DriverFactory +from padam_django.apps.geography.factories import PlaceFactory, BusStopFactory +from padam_django.apps.schedules.models import BusShift, BusShiftForm +from datetime import datetime + +class BusShiftTestCase(TestCase): + def setUp(self): + self.bus = BusFactory() + self.driver = DriverFactory() + + tmp_bus_stops = BusStopFactory.create_batch(size=5) + self.bus_stops = sorted(tmp_bus_stops, key=lambda x: x.expected_arrival) + + def test_bus_shift_creation(self): + bus_shift = BusShift.objects.create( + bus=self.bus, + driver=self.driver, + start_time=self.bus_stops[0].expected_arrival, + end_time=self.bus_stops[2].expected_arrival + ) + bus_shift.bus_stops.set(self.bus_stops) + + self.assertEqual(bus_shift.bus, self.bus) + self.assertEqual(bus_shift.driver, self.driver) + self.assertEqual(bus_shift.start_time, self.bus_stops[0].expected_arrival) + self.assertEqual(bus_shift.end_time, self.bus_stops[2].expected_arrival) + self.assertEqual(bus_shift.bus_stops.count(), 5) + + def test_bus_shift_form_with_less_than_two_stops(self): + bus_shift = BusShiftForm({ + 'bus': self.bus.pk, + 'driver': self.driver.pk, + 'bus_stops': [self.bus_stops[0].pk], + }) + + self.assertFalse(bus_shift.is_valid()) + self.assertIn('__all__', bus_shift.errors) + + def test_bus_shift_form_with_two_stops(self): + bus_shift = BusShiftForm({ + 'bus': self.bus.pk, + 'driver': self.driver.pk, + 'bus_stops': [self.bus_stops[0].pk, self.bus_stops[1].pk], + }) + + self.assertTrue(bus_shift.is_valid()) + bus_shift.save() + self.assertEqual(BusShift.objects.count(), 1) + bus_shift = BusShift.objects.first() + self.assertEqual(bus_shift.bus, self.bus) + + def test_bus_shift_from_with_overlapping(self): + bus_shift1 = BusShiftForm({ + 'bus': self.bus.pk, + 'driver': self.driver.pk, + 'bus_stops': [self.bus_stops[0].pk, self.bus_stops[1].pk], + }) + self.assertTrue(bus_shift1.is_valid()) + bus_shift1.save() + + bus_shift2 = BusShiftForm({ + 'bus': self.bus.pk, + 'driver': self.driver.pk, + 'bus_stops': [self.bus_stops[0].pk, self.bus_stops[2].pk], + }) + + self.assertFalse(bus_shift2.is_valid()) + self.assertIn('__all__', bus_shift2.errors) + + def test_bus_shift_from_without_overlapping(self): + bus_shift1 = BusShiftForm({ + 'bus': self.bus.pk, + 'driver': self.driver.pk, + 'bus_stops': [self.bus_stops[0].pk, self.bus_stops[1].pk], + }) + bus_shift1.save() + + bus_shift2 = BusShiftForm({ + 'bus': self.bus.pk, + 'driver': self.driver.pk, + 'bus_stops': [self.bus_stops[2].pk, self.bus_stops[4].pk], + }) + + self.assertTrue(bus_shift2.is_valid()) + + def test_duration_property(self): + bus_shift = BusShiftForm({ + 'bus': self.bus.pk, + 'driver': self.driver.pk, + 'bus_stops': [self.bus_stops[0].pk, self.bus_stops[1].pk], + }) + + bus_shift.save() + bus_shift = BusShift.objects.first() + start_time = datetime.combine(datetime.today(), self.bus_stops[0].expected_arrival) + end_time = datetime.combine(datetime.today(), self.bus_stops[1].expected_arrival) + self.assertEqual(bus_shift.duration, end_time - start_time) \ No newline at end of file diff --git a/padam_django/apps/schedules/views.py b/padam_django/apps/schedules/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/padam_django/apps/schedules/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/padam_django/settings.py b/padam_django/settings.py index 129e922c..a0760202 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.schedules', ] MIDDLEWARE = [