diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..f73a2eaa --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,14 @@ +repos: + - repo: local + hooks: + - id: black + name: apply black + language: system + types: [python] + entry: pipenv run black + - id: ruff + name: ruff + language: system + types: [python] + entry: pipenv run ruff check + args: [--fix] diff --git a/Makefile b/Makefile index 4062f4c4..9d0af543 100644 --- a/Makefile +++ b/Makefile @@ -3,3 +3,6 @@ run: ## Run the test server. install: ## Install the python requirements. pip install -r requirements.txt + +migrate: ## Run the test server. + python manage.py migrate diff --git a/padam_django/apps/shift/__init__.py b/padam_django/apps/shift/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/shift/admin.py b/padam_django/apps/shift/admin.py new file mode 100644 index 00000000..1b83afb4 --- /dev/null +++ b/padam_django/apps/shift/admin.py @@ -0,0 +1,58 @@ +from django.contrib import admin + +from padam_django.apps.shift.forms import ScheduleStopForm + +from .models import BusShift, BusStop, ScheduleStop + + +@admin.register(BusShift) +class BusShiftAdmin(admin.ModelAdmin): + # Make field not last stop, first_stop not editable + readonly_fields = ("first_stop", "last_stop") + list_display = ( + "bus", + "driver", + "get_stop_count", + "get_validation_stop_information", + "get_shift_start_information", + "get_shift_end_information", + "get_shift_duration_information", + ) + + @admin.display(description="Stop count") + def get_stop_count(self, obj): + return ScheduleStop.objects.filter(bus_shift=obj.id).count() + + @admin.display(description="Stop count validation") + def get_validation_stop_information(self, obj): + stop_count = ScheduleStop.objects.filter(bus_shift=obj.id).count() + if stop_count < 2: + return "Missing bus stops" + return "Valid bus stops count" + + @admin.display(description="Shift start") + def get_shift_start_information(self, obj): + return obj.first_stop + + @admin.display(description="Shift end") + def get_shift_end_information(self, obj): + return obj.last_stop + + @admin.display(description="Total duration (hours)") + def get_shift_duration_information(self, obj): + return obj.shift_duration + + +@admin.register(BusStop) +class BusStopAdmin(admin.ModelAdmin): + list_display = ("id", "name", "place") + + +@admin.register(ScheduleStop) +class ScheduleStopAdmin(admin.ModelAdmin): + form = ScheduleStopForm + list_display = ( + "bus_shift", + "bus_stop", + "arrival", + ) diff --git a/padam_django/apps/shift/apps.py b/padam_django/apps/shift/apps.py new file mode 100644 index 00000000..d3e8f503 --- /dev/null +++ b/padam_django/apps/shift/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class ShiftConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "padam_django.apps.shift" + + def ready(self): + import padam_django.apps.shift.receivers # noqa diff --git a/padam_django/apps/shift/factories.py b/padam_django/apps/shift/factories.py new file mode 100644 index 00000000..5901ab4a --- /dev/null +++ b/padam_django/apps/shift/factories.py @@ -0,0 +1,36 @@ +from random import randint + +import factory +from django.utils import timezone + +from padam_django.apps.fleet.factories import BusFactory, DriverFactory +from padam_django.apps.geography.factories import PlaceFactory + +from . import models + + +class BusStopFactory(factory.django.DjangoModelFactory): + name = factory.Sequence(lambda n: "Bus stop %01d" % n) + place = factory.SubFactory(PlaceFactory) + + class Meta: + model = models.BusStop + + +class BusShiftFactory(factory.django.DjangoModelFactory): + bus = factory.SubFactory(BusFactory) + driver = factory.SubFactory(DriverFactory) + first_stop = timezone.now() + last_stop = timezone.now() + + class Meta: + model = models.BusShift + + +class ScheduleStopFactory(factory.django.DjangoModelFactory): + bus_shift = factory.SubFactory(BusShiftFactory) + bus_stop = factory.SubFactory(BusStopFactory) + arrival = timezone.now() + timezone.timedelta(hours=randint(7, 22)) + + class Meta: + model = models.ScheduleStop diff --git a/padam_django/apps/shift/forms.py b/padam_django/apps/shift/forms.py new file mode 100644 index 00000000..96ca0319 --- /dev/null +++ b/padam_django/apps/shift/forms.py @@ -0,0 +1,32 @@ +from django import forms +from django.core.exceptions import ValidationError + +from .models import BusShift, ScheduleStop + + +class ScheduleStopForm(forms.ModelForm): + class Meta: + model = ScheduleStop + fields = "__all__" + + def clean(self): + cleaned_data = super().clean() + bus_shift = cleaned_data["bus_shift"] + arrival = cleaned_data["arrival"] + + # Check bus + bus_shifts = BusShift.objects.filter( + bus=bus_shift.bus, first_stop__lte=arrival, last_stop__gte=arrival + ).count() + + if bus_shifts > 0: + raise ValidationError("Bus is not available") + + driver_shifts = BusShift.objects.filter( + driver=bus_shift.driver, first_stop__lte=arrival, last_stop__gte=arrival + ).count() + + if driver_shifts > 0: + raise ValidationError("Driver is not available") + + return cleaned_data diff --git a/padam_django/apps/shift/migrations/0001_initial.py b/padam_django/apps/shift/migrations/0001_initial.py new file mode 100644 index 00000000..533f84b4 --- /dev/null +++ b/padam_django/apps/shift/migrations/0001_initial.py @@ -0,0 +1,100 @@ +# Generated by Django 3.2.5 on 2024-06-14 07:29 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("geography", "0001_initial"), + ("fleet", "0002_auto_20211109_1456"), + ] + + operations = [ + migrations.CreateModel( + name="BusShift", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("first_stop", models.DateTimeField(blank=True, null=True)), + ("last_stop", models.DateTimeField(blank=True, null=True)), + ( + "bus", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="fleet.bus" + ), + ), + ( + "driver", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="fleet.driver" + ), + ), + ], + ), + migrations.CreateModel( + name="BusStop", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "name", + models.CharField( + max_length=20, verbose_name="Name of the bus stop" + ), + ), + ( + "place", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="geography.place", + ), + ), + ], + ), + migrations.CreateModel( + name="ScheduleStop", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("arrival", models.DateTimeField()), + ( + "bus_shift", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="shift.busshift" + ), + ), + ( + "bus_stop", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="shift.busstop" + ), + ), + ], + ), + ] diff --git a/padam_django/apps/shift/migrations/__init__.py b/padam_django/apps/shift/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/shift/models.py b/padam_django/apps/shift/models.py new file mode 100644 index 00000000..ab761d8d --- /dev/null +++ b/padam_django/apps/shift/models.py @@ -0,0 +1,41 @@ +import uuid + +from django.db import models + + +class BusStop(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField("Name of the bus stop", max_length=20) + place = models.ForeignKey("geography.place", on_delete=models.PROTECT) + + def __str__(self): + return f"Name: {self.name} (id: {self.pk})" + + +class BusShift(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + bus = models.ForeignKey("fleet.bus", on_delete=models.PROTECT) + driver = models.ForeignKey("fleet.driver", on_delete=models.PROTECT) + first_stop = models.DateTimeField( + auto_now=False, auto_now_add=False, null=True, blank=True + ) # Use this fields as "cache" + last_stop = models.DateTimeField( + auto_now=False, auto_now_add=False, null=True, blank=True + ) # Use this fields as "cache" + + @property + def shift_duration(self): + return self.last_stop - self.first_stop + + def __str__(self): + return f"Bus: {self.bus.licence_plate} driver: {self.driver.user.username} (id: {self.pk})" + + +class ScheduleStop(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + bus_shift = models.ForeignKey(BusShift, on_delete=models.PROTECT) + bus_stop = models.ForeignKey(BusStop, on_delete=models.PROTECT) + arrival = models.DateTimeField(auto_now=False, auto_now_add=False) + + def __str__(self): + return f"Shift: {self.bus_shift} stop: {self.bus_stop.name} (id: {self.pk})" diff --git a/padam_django/apps/shift/receivers.py b/padam_django/apps/shift/receivers.py new file mode 100644 index 00000000..9e6fdeec --- /dev/null +++ b/padam_django/apps/shift/receivers.py @@ -0,0 +1,21 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from .models import BusShift, ScheduleStop + + +@receiver(post_save, sender=ScheduleStop) +def update_bus_shift_start_or_end(sender, instance, **kwargs): + bus_shift = BusShift.objects.get(id=instance.bus_shift.id) + + if bus_shift.first_stop is None and bus_shift.last_stop is None: + bus_shift.first_stop = instance.arrival + bus_shift.last_stop = instance.arrival + bus_shift.save() + + if instance.arrival < bus_shift.first_stop: + bus_shift.first_stop = instance.arrival + bus_shift.save() + if instance.arrival > bus_shift.last_stop: + bus_shift.last_stop = instance.arrival + bus_shift.save() diff --git a/padam_django/apps/shift/tests.py b/padam_django/apps/shift/tests.py new file mode 100644 index 00000000..e004348c --- /dev/null +++ b/padam_django/apps/shift/tests.py @@ -0,0 +1,96 @@ +from django.test import TestCase +from django.utils import timezone + +from padam_django.apps.fleet.factories import BusFactory, DriverFactory +from padam_django.apps.shift.factories import ( + BusShiftFactory, + BusStopFactory, + ScheduleStopFactory, +) +from padam_django.apps.shift.forms import ScheduleStopForm + + +class BusShiftTests(TestCase): + def setUp(self): + self.bus = BusFactory() + self.driver = DriverFactory() + + def test_create_shift_and_schedule_success(self): + """ + Test if creation of shift and schedule works. + """ + initial_datetime = timezone.now() + bus_stop = BusStopFactory() + bus_shift = BusShiftFactory(bus=self.bus, driver=self.driver) + ScheduleStopFactory( + bus_shift=bus_shift, bus_stop=bus_stop, arrival=initial_datetime + ) + + # try to create a new ScheduleStop instance + form = ScheduleStopForm( + data={ + "bus_shift": bus_shift, + "bus_stop": bus_stop, + "arrival": initial_datetime + timezone.timedelta(hours=2), + } + ) + + self.assertTrue(form.is_valid()) + + def test_create_shift_and_schedule_error_bus_not_available(self): + """ + Test if bus shifts overlap. + """ + initial_datetime = timezone.now() + bus_stop = BusStopFactory() + bus_shift = BusShiftFactory(bus=self.bus, driver=self.driver) + ScheduleStopFactory( + bus_shift=bus_shift, bus_stop=bus_stop, arrival=initial_datetime + ) + ScheduleStopFactory( + bus_shift=bus_shift, + bus_stop=bus_stop, + arrival=initial_datetime + timezone.timedelta(hours=3), + ) + + # try to create a new ScheduleStop instance + form = ScheduleStopForm( + data={ + "bus_shift": bus_shift, + "bus_stop": bus_stop, + "arrival": initial_datetime + timezone.timedelta(hours=2), + } + ) + + self.assertFalse(form.is_valid()) + self.assertIn("Bus is not available", form.errors["__all__"][0]) + + def test_create_shift_and_schedule_error_driver_not_available(self): + """ + Test if driver shifts overlap. + """ + initial_datetime = timezone.now() + new_bus = BusFactory() + bus_stop = BusStopFactory() + bus_shift = BusShiftFactory(bus=self.bus, driver=self.driver) + new_bus_shift = BusShiftFactory(bus=new_bus, driver=self.driver) + ScheduleStopFactory( + bus_shift=new_bus_shift, bus_stop=bus_stop, arrival=initial_datetime + ) + ScheduleStopFactory( + bus_shift=new_bus_shift, + bus_stop=bus_stop, + arrival=initial_datetime + timezone.timedelta(hours=3), + ) + + # try to create a new ScheduleStop instance + form = ScheduleStopForm( + data={ + "bus_shift": bus_shift, + "bus_stop": bus_stop, + "arrival": initial_datetime + timezone.timedelta(hours=2), + } + ) + + self.assertFalse(form.is_valid()) + self.assertIn("Driver is not available", form.errors["__all__"][0]) diff --git a/padam_django/apps/shift/views.py b/padam_django/apps/shift/views.py new file mode 100644 index 00000000..60f00ef0 --- /dev/null +++ b/padam_django/apps/shift/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/padam_django/settings.py b/padam_django/settings.py index 129e922c..2ff64064 100644 --- a/padam_django/settings.py +++ b/padam_django/settings.py @@ -20,7 +20,7 @@ # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-&r2)+_fdqxe2dtc@1vizr6tsh6!1cesaptlfgj@ug*%3=fnq=i' +SECRET_KEY = "django-insecure-&r2)+_fdqxe2dtc@1vizr6tsh6!1cesaptlfgj@ug*%3=fnq=i" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -32,59 +32,60 @@ INSTALLED_APPS = [ # Django apps - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", # Third party apps - 'django_extensions', + "django_extensions", # Internal apps - 'padam_django.apps.common', - 'padam_django.apps.fleet', - 'padam_django.apps.geography', - 'padam_django.apps.users', + "padam_django.apps.common", + "padam_django.apps.fleet", + "padam_django.apps.geography", + "padam_django.apps.shift", + "padam_django.apps.users", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'padam_django.urls' +ROOT_URLCONF = "padam_django.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'padam_django.wsgi.application' +WSGI_APPLICATION = "padam_django.wsgi.application" # Database # https://docs.djangoproject.com/en/3.2/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", } } @@ -94,16 +95,16 @@ AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] @@ -116,9 +117,9 @@ # Internationalization # https://docs.djangoproject.com/en/3.2/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -130,9 +131,9 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.2/howto/static-files/ -STATIC_URL = '/static/' +STATIC_URL = "/static/" # Default primary key field type # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/padam_test-diagram.pdf b/padam_test-diagram.pdf new file mode 100644 index 00000000..9af63615 Binary files /dev/null and b/padam_test-diagram.pdf differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..41e79cae --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[tool.ruff] + +# Maximum line length to 120 characters. +line-length = 120 +# Support Python 3.7+. +target-version = "py37" +src = ["."] + +# Rules to apply +select = [ + "E", # pycodestyle + "W", # pycodestyle + "F", # pyflakes + "UP", # pyupgrade + "I", # isort +] + +# List what was automatically fixed +show-fixes=true + +# Exclude a variety of commonly ignored directories +exclude = [ + "__pypackages__", + "makefile", + "migrations/*", + "requirements/*", + ".env*", + ".git*", + ".ruff_cache", + "*.md", + "*.pyc", +] diff --git a/requirements.txt b/requirements.txt index 89c9f0c8..453a80a3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,7 @@ ipython==7.25.0 factory-boy==3.2.0 Faker==8.10.1 + +black +pre-commit==2.21.0 +ruff