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 = [