diff --git a/.idea/misc.xml b/.idea/misc.xml index 574ec96e..d5172666 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..6220e23d 100644 --- a/.idea/padam-django-tech-test.iml +++ b/.idea/padam-django-tech-test.iml @@ -14,7 +14,7 @@ - + diff --git a/padam_django/apps/common/management/commands/create_data.py b/padam_django/apps/common/management/commands/create_data.py index a149a937..6e8f72c3 100644 --- a/padam_django/apps/common/management/commands/create_data.py +++ b/padam_django/apps/common/management/commands/create_data.py @@ -12,3 +12,5 @@ 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=30) + management.call_command('create_bus_shifts', number=5) \ No newline at end of file diff --git a/padam_django/apps/pathing/__init__.py b/padam_django/apps/pathing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/pathing/admin.py b/padam_django/apps/pathing/admin.py new file mode 100644 index 00000000..3079ad74 --- /dev/null +++ b/padam_django/apps/pathing/admin.py @@ -0,0 +1,26 @@ + +from django.contrib import admin +from django.core.exceptions import ValidationError +from padam_django.apps.pathing.forms.bus_stop_form import BusStopForm +from padam_django.apps.pathing.models.bus_shift import BusShift +from padam_django.apps.pathing.models.bus_stop import BusStop +from padam_django.apps.pathing.forms.bus_shift_form import BusShiftForm +from django.utils.timezone import now + + +@admin.register(BusShift) +class BusShiftAdmin(admin.ModelAdmin): + form = BusShiftForm + list_display = ['bus', 'driver'] + + def save_model(self, request, obj, form, change): + bus_stops = form.cleaned_data.get('bus_stops') + obj.save() + for bus_stop in bus_stops: + bus_stop.bus_shift = obj + bus_stop.save() + + +@admin.register(BusStop) +class BusStopAdmin(admin.ModelAdmin): + form = BusStopForm diff --git a/padam_django/apps/pathing/apps.py b/padam_django/apps/pathing/apps.py new file mode 100644 index 00000000..b219f8ca --- /dev/null +++ b/padam_django/apps/pathing/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class PathingConfig(AppConfig): + name = 'padam_django.apps.pathing' diff --git a/padam_django/apps/pathing/factories/__init__.py b/padam_django/apps/pathing/factories/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/pathing/factories/bus_shift_factory.py b/padam_django/apps/pathing/factories/bus_shift_factory.py new file mode 100644 index 00000000..461d12b3 --- /dev/null +++ b/padam_django/apps/pathing/factories/bus_shift_factory.py @@ -0,0 +1,18 @@ +import factory +from factory import django, List, SubFactory, LazyAttribute +from padam_django.apps.fleet.factories import BusFactory, DriverFactory +from padam_django.apps.pathing.factories.bus_stop_factory import BusStopFactory +from padam_django.apps.pathing.models import BusShift + + +class BusShiftFactory(django.DjangoModelFactory): + bus = factory.SubFactory(BusFactory) + driver = factory.SubFactory(DriverFactory) + bus_stops = factory.RelatedFactoryList( + BusStopFactory, + factory_related_name='bus_shift', + size=2 + ) + + class Meta: + model = BusShift \ No newline at end of file diff --git a/padam_django/apps/pathing/factories/bus_stop_factory.py b/padam_django/apps/pathing/factories/bus_stop_factory.py new file mode 100644 index 00000000..8c900ef6 --- /dev/null +++ b/padam_django/apps/pathing/factories/bus_stop_factory.py @@ -0,0 +1,17 @@ +import factory +from padam_django.apps.geography.factories import PlaceFactory +from datetime import datetime, timedelta +from random import randint + +from padam_django.apps.pathing.models import BusStop +from django.utils.timezone import make_aware + +def futur_datetime(): + return make_aware(datetime.now() + timedelta(days=randint(1, 30))) + +class BusStopFactory(factory.django.DjangoModelFactory): + visit_date_time = factory.LazyFunction(futur_datetime) + place = factory.SubFactory(PlaceFactory) + + class Meta: + model = BusStop \ No newline at end of file diff --git a/padam_django/apps/pathing/forms/__init__.py b/padam_django/apps/pathing/forms/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/pathing/forms/bus_shift_form.py b/padam_django/apps/pathing/forms/bus_shift_form.py new file mode 100644 index 00000000..deb18959 --- /dev/null +++ b/padam_django/apps/pathing/forms/bus_shift_form.py @@ -0,0 +1,46 @@ +from django import forms +from padam_django.apps.pathing.models.bus_shift import BusShift +from padam_django.apps.pathing.models.bus_stop import BusStop +from django.core.exceptions import ValidationError + +class BusShiftForm(forms.ModelForm): + bus_stops = forms.ModelMultipleChoiceField( + queryset=BusStop.objects.filter(bus_shift__isnull=True), + widget=forms.CheckboxSelectMultiple, + required=True, + label="Select Bus Stops" + ) + + class Meta: + model = BusShift + fields = ['bus', 'driver'] + + def clean_bus_stops(self): + bus_stops = self.cleaned_data.get('bus_stops') + if len(bus_stops) < 2: + raise ValidationError("You must select at least 2 bus stops.") + + bus = self.cleaned_data.get('bus') + driver = self.cleaned_data.get('driver') + bus_stops = self.cleaned_data.get('bus_stops') + + ordered_stops = sorted(bus_stops, key=lambda stop: stop.visit_date_time) + departure_time = ordered_stops[0].visit_date_time + arrival_time = ordered_stops[-1].visit_date_time + + overlapping_bus_shifts = BusShift.objects.filter( + bus=bus, + bus_stops__visit_date_time__lt=arrival_time, + bus_stops__visit_date_time__gt=departure_time, + ).exclude(pk=self.instance.pk) + + overlapping_driver_shifts = BusShift.objects.filter( + driver=driver, + bus_stops__visit_date_time__lt=arrival_time, + bus_stops__visit_date_time__gt=departure_time, + ) + + if overlapping_bus_shifts.exists() or overlapping_driver_shifts.exists(): + raise ValidationError("This bus or driver is already assigned to a shift during this time.") + + return bus_stops \ No newline at end of file diff --git a/padam_django/apps/pathing/forms/bus_stop_form.py b/padam_django/apps/pathing/forms/bus_stop_form.py new file mode 100644 index 00000000..33ef7f72 --- /dev/null +++ b/padam_django/apps/pathing/forms/bus_stop_form.py @@ -0,0 +1,11 @@ +from django import forms +from padam_django.apps.geography.models import Place +from padam_django.apps.pathing.models.bus_stop import BusStop +from django.core.exceptions import ValidationError + +class BusStopForm(forms.ModelForm): + class Meta: + model = BusStop + fields = ['visit_date_time', 'place'] + + diff --git a/padam_django/apps/pathing/management/__init__.py b/padam_django/apps/pathing/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/pathing/management/commands/__init__.py b/padam_django/apps/pathing/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/pathing/management/commands/create_bus_shifts.py b/padam_django/apps/pathing/management/commands/create_bus_shifts.py new file mode 100644 index 00000000..04cf275f --- /dev/null +++ b/padam_django/apps/pathing/management/commands/create_bus_shifts.py @@ -0,0 +1,12 @@ +from padam_django.apps.common.management.base import CreateDataBaseCommand + +from padam_django.apps.pathing.factories.bus_shift_factory import BusShiftFactory + +class Command(CreateDataBaseCommand): + + help = 'Create few bus shifts' + + def handle(self, *args, **options): + super().handle(*args, **options) + self.stdout.write(f'Creating {self.number} bus shifts ...') + BusShiftFactory.create_batch(size=self.number) diff --git a/padam_django/apps/pathing/management/commands/create_bus_stops.py b/padam_django/apps/pathing/management/commands/create_bus_stops.py new file mode 100644 index 00000000..bd973d74 --- /dev/null +++ b/padam_django/apps/pathing/management/commands/create_bus_stops.py @@ -0,0 +1,12 @@ +from padam_django.apps.common.management.base import CreateDataBaseCommand + +from padam_django.apps.pathing.factories.bus_stop_factory 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/pathing/migrations/0001_initial.py b/padam_django/apps/pathing/migrations/0001_initial.py new file mode 100644 index 00000000..b4258600 --- /dev/null +++ b/padam_django/apps/pathing/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.16 on 2024-11-22 20:12 + +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='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='bus_shifts', to='fleet.bus')), + ('driver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bus_shifts', to='fleet.driver')), + ], + options={ + 'verbose_name': 'Bus Shift', + 'verbose_name_plural': 'Bus Shifts', + }, + ), + migrations.CreateModel( + name='BusStop', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('visit_date_time', models.DateTimeField()), + ('bus_shift', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='bus_stops', to='pathing.busshift')), + ('place', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bus_stops', to='geography.place')), + ], + options={ + 'verbose_name': 'Bus Stop', + 'verbose_name_plural': 'Bus Stops', + }, + ), + ] diff --git a/padam_django/apps/pathing/migrations/__init__.py b/padam_django/apps/pathing/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/pathing/models/__init__.py b/padam_django/apps/pathing/models/__init__.py new file mode 100644 index 00000000..0532a09d --- /dev/null +++ b/padam_django/apps/pathing/models/__init__.py @@ -0,0 +1,2 @@ +from .bus_stop import BusStop +from .bus_shift import BusShift \ No newline at end of file diff --git a/padam_django/apps/pathing/models/bus_shift.py b/padam_django/apps/pathing/models/bus_shift.py new file mode 100644 index 00000000..65755cc3 --- /dev/null +++ b/padam_django/apps/pathing/models/bus_shift.py @@ -0,0 +1,26 @@ +from django.db import models +from padam_django.apps.fleet.models import Bus, Driver + + +class BusShift(models.Model): + bus = models.ForeignKey(Bus, on_delete=models.CASCADE, related_name="bus_shifts") + driver = models.ForeignKey(Driver, on_delete=models.CASCADE, related_name="bus_shifts") + + class Meta: + verbose_name = "Bus Shift" + verbose_name_plural = "Bus Shifts" + + def get_ordered_bus_stops(self): + return sorted(self.bus_stops.all(), key=lambda stop: stop.visit_date_time) + + def get_departure(self): + return self.get_ordered_bus_stops()[0] + + def get_arrival(self): + return self.get_ordered_bus_stops()[-1] + + def get_travel_time(self): + return self.get_arrival().visit_date_time - self.get_departure().visit_date_time + + def __str__(self): + return f"{self.id} - {self.bus} - {self.driver}" \ No newline at end of file diff --git a/padam_django/apps/pathing/models/bus_stop.py b/padam_django/apps/pathing/models/bus_stop.py new file mode 100644 index 00000000..a2ca41cc --- /dev/null +++ b/padam_django/apps/pathing/models/bus_stop.py @@ -0,0 +1,29 @@ +from django.core.exceptions import ValidationError +from django.db import models + +from padam_django.apps.geography.models import Place +from padam_django.apps.pathing.models.bus_shift import BusShift +from django.utils.timezone import now + + +class BusStop(models.Model): + visit_date_time = models.DateTimeField() + place = models.ForeignKey(Place, on_delete=models.CASCADE, related_name="bus_stops") + bus_shift = models.ForeignKey(BusShift, on_delete=models.CASCADE, related_name="bus_stops", null=True, blank=True) + + def clean(self): + super().clean() + + if self.visit_date_time and self.visit_date_time <= now(): + raise ValidationError("The visit date and time can't be in the past.") + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + class Meta: + verbose_name = "Bus Stop" + verbose_name_plural = "Bus Stops" + + def __str__(self): + return f"{self.visit_date_time} - {self.place}" \ No newline at end of file diff --git a/padam_django/apps/pathing/tests/__init__.py b/padam_django/apps/pathing/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/pathing/tests/conftest.py b/padam_django/apps/pathing/tests/conftest.py new file mode 100644 index 00000000..f035bd6a --- /dev/null +++ b/padam_django/apps/pathing/tests/conftest.py @@ -0,0 +1,17 @@ +import pytest +from django.contrib.auth import get_user_model + +@pytest.fixture +def create_admin_user(): + user_model = get_user_model() + admin_user = user_model.objects.create_superuser( + username='admin', + email='admin@example.com', + password='adminpassword' + ) + return admin_user + +@pytest.fixture +def authenticated_client(create_admin_user, client): + client.login(username='admin', password='adminpassword') + return client diff --git a/padam_django/apps/pathing/tests/test_bus_shift.py b/padam_django/apps/pathing/tests/test_bus_shift.py new file mode 100644 index 00000000..76480e2e --- /dev/null +++ b/padam_django/apps/pathing/tests/test_bus_shift.py @@ -0,0 +1,69 @@ +import datetime +from django.utils import timezone + +import pytest +from django.urls import reverse +from padam_django.apps.fleet.factories import DriverFactory, BusFactory +from padam_django.apps.pathing.factories.bus_shift_factory import BusShiftFactory +from padam_django.apps.pathing.factories.bus_stop_factory import BusStopFactory +from padam_django.apps.pathing.models import BusShift + + +@pytest.mark.django_db +class TestAdminBusShift: + def test_add_bus_shift(self, authenticated_client): + bus = BusFactory() + driver = DriverFactory() + bus_stop_1 = BusStopFactory() + bus_stop_2 = BusStopFactory() + data = { + 'bus': bus.id, + 'driver': driver.id, + 'bus_stops': [bus_stop_1.id, bus_stop_2.id], + } + url = reverse('admin:pathing_busshift_add') + response = authenticated_client.post(url, data) + assert response.status_code == 302 + assert BusShift.objects.count() == 1 + bus_shift = BusShift.objects.first() + assert bus_shift.bus.id == bus.id + + def test_add_bus_shift_only_one_stop(self, authenticated_client): + bus = BusFactory() + driver = DriverFactory() + bus_stop_1 = BusStopFactory() + data = { + 'bus': bus.id, + 'driver': driver.id, + 'bus_stops': [bus_stop_1.id], + } + url = reverse('admin:pathing_busshift_add') + response = authenticated_client.post(url, data) + assert response.status_code == 200 + assert BusShift.objects.count() == 0 + assert "You must select at least 2 bus stops." in response.content.decode() + + + def test_add_bus_shift_overlap_bus(self, authenticated_client): + bus = BusFactory() + driver = DriverFactory() + now = timezone.now() + bus_stop_1 = BusStopFactory(visit_date_time=now + datetime.timedelta(hours=1)) + bus_stop_2 = BusStopFactory(visit_date_time=now + datetime.timedelta(hours=2)) + bus_stop_3 = BusStopFactory(visit_date_time=now + datetime.timedelta(hours=3)) + bus_stop_4 = BusStopFactory(visit_date_time=now + datetime.timedelta(hours=4)) + shift = BusShiftFactory(bus=bus, bus_stops=[bus_stop_2, bus_stop_4]) + shift.bus_stops.add(bus_stop_2, bus_stop_4) + data = { + 'bus': bus.id, + 'driver': driver.id, + 'bus_stops': [bus_stop_1.id, bus_stop_3.id], + } + url = reverse('admin:pathing_busshift_add') + response = authenticated_client.post(url, data) + assert response.status_code == 200 + assert "This bus or driver is already assigned to a shift during this time." in response.content.decode() + + + + diff --git a/padam_django/apps/pathing/tests/test_bus_stop.py b/padam_django/apps/pathing/tests/test_bus_stop.py new file mode 100644 index 00000000..be3f3ae2 --- /dev/null +++ b/padam_django/apps/pathing/tests/test_bus_stop.py @@ -0,0 +1,69 @@ +import pytest +import datetime +from django.urls import reverse +from django.utils.timezone import now +from padam_django.apps.geography.factories import PlaceFactory +from padam_django.apps.pathing.models import BusStop +from django.core.exceptions import ValidationError + + +@pytest.mark.django_db +class TestAdminBusStop: + + def test_admin_add_bus_stop(self, authenticated_client): + place = PlaceFactory() + + url = reverse('admin:pathing_busstop_add') + future_datetime = now() + datetime.timedelta(days=1) + data = { + 'visit_date_time_0': future_datetime.strftime('%Y-%m-%d'), + 'visit_date_time_1': future_datetime.strftime('%H:%M:%S'), + 'place': place.id, + } + + response = authenticated_client.post(url, data) + + assert response.status_code == 302 + assert BusStop.objects.count() == 1 + bus_stop = BusStop.objects.first() + assert bus_stop.place == place + + def test_admin_add_bus_stop_passed_datetime(self, authenticated_client): + place = PlaceFactory() + + url = reverse('admin:pathing_busstop_add') + future_datetime = now() - datetime.timedelta(days=1) + data = { + 'visit_date_time_0': future_datetime.strftime('%Y-%m-%d'), + 'visit_date_time_1': future_datetime.strftime('%H:%M:%S'), + 'place': place.id, + } + + response = authenticated_client.post(url, data) + assert response.status_code == 200 + assert BusStop.objects.count() == 0 + assert "The visit date and time can't be in the past." in response.content.decode() + +@pytest.mark.django_db +class TestBusStopModel: + def test_bus_stop_model(self): + place = PlaceFactory() + future_datetime = now() + datetime.timedelta(days=1) + bus_stop = BusStop( + visit_date_time= future_datetime, + place= place, + ) + bus_stop.save() + assert BusStop.objects.count() == 1 + + def test_bus_stop_model_past_date(self): + place = PlaceFactory() + past_datetime = now() - datetime.timedelta(days=1) + bus_stop = BusStop( + visit_date_time= past_datetime, + place= place, + ) + with pytest.raises(ValidationError, match="The visit date and time can't be in the past."): + bus_stop.save() + + assert BusStop.objects.count() == 0 \ No newline at end of file diff --git a/padam_django/settings.py b/padam_django/settings.py index 129e922c..d939020f 100644 --- a/padam_django/settings.py +++ b/padam_django/settings.py @@ -11,6 +11,7 @@ """ from pathlib import Path +import sys # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -40,11 +41,13 @@ 'django.contrib.staticfiles', # Third party apps 'django_extensions', + 'rest_framework', # Internal apps 'padam_django.apps.common', 'padam_django.apps.fleet', 'padam_django.apps.geography', 'padam_django.apps.users', + 'padam_django.apps.pathing', ] MIDDLEWARE = [ @@ -84,11 +87,10 @@ DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + 'NAME': ':memory:' if 'test' in sys.argv else BASE_DIR / 'db.sqlite3', } } - # Password validation # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators diff --git a/padam_django/urls.py b/padam_django/urls.py index 7ecf590e..afd0a99f 100644 --- a/padam_django/urls.py +++ b/padam_django/urls.py @@ -14,7 +14,7 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin -from django.urls import path +from django.urls import path, include urlpatterns = [ path('admin/', admin.site.urls), diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..28b734b2 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE = padam_django.settings +python_files = tests.py test_*.py *_tests.py \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 863fd63d..794dc299 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,6 @@ ipython==8.29.0 factory-boy==3.2.0 Faker==8.10.1 + +# ajout par le developeur testé : +djangorestframework==3.15.2 \ No newline at end of file