diff --git a/.gitignore b/.gitignore index d7d26693..81322895 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ __pycache__/ # virtualenv venv/ +.venv*/ ENV/ # pipenv: https://github.com/kennethreitz/pipenv /Pipfile @@ -20,3 +21,6 @@ ENV/ # Editors stuff .idea .vscode + +# Secrets +secrets.json diff --git a/manage.py b/manage.py index dcd7b5c7..4a2f5709 100755 --- a/manage.py +++ b/manage.py @@ -6,7 +6,7 @@ def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'padam_django.settings') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "padam_django.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -18,5 +18,5 @@ def main(): execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/padam_django/apps/shifts/__init__.py b/padam_django/apps/shifts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/shifts/admin.py b/padam_django/apps/shifts/admin.py new file mode 100644 index 00000000..dafae3c3 --- /dev/null +++ b/padam_django/apps/shifts/admin.py @@ -0,0 +1,97 @@ +from django.contrib import admin +from django import forms +from django.core.exceptions import ValidationError + +from . import models + + +@admin.register(models.BusStop) +class BusStopAdmin(admin.ModelAdmin): + pass + + +class BusShiftAdminForm(forms.ModelForm): + class Meta: + model = models.BusShift + fields = "__all__" + + #TODO: Form filtering to avoid seeing BusStops from all BusShifts + + def clean(self): + """Validate that a single Bus or Driver cannot be assigned at + the same time to several BusShifts. + + Raises: + ValidationError: Field/description dict containing + the violation error(s). + """ + validation_errors = {} + + # Check that BusShift has at least 2 stops + stops: list[models.BusStop] = self.cleaned_data.get("stops", []) + stops_count = len(stops) + if stops_count < 2: + validation_errors["stops"] = ( + "BusShift must have at least two BusStops " + f"({stops_count} found)." + ) + + # If no bus and no driver is set, no possible conflict + if ( + self.cleaned_data.get("bus") is None + and self.cleaned_data.get("driver") is None + ): + return + # Check that BusShift is not overlapping with another shift + for shift in models.BusShift.objects.all(): + if shift.pk == self.instance.pk or ( + shift.bus != self.cleaned_data.get("bus") + and shift.driver != self.cleaned_data.get("driver") + ): + # It's the same shift, or both the bus and the driver differ + continue + if ( + shift.last_stop.date_time + >= self.cleaned_data.get("stops").order_by("date_time").first().date_time + and shift.first_stop.date_time + <= self.cleaned_data.get("stops").order_by("date_time").last().date_time + ): + if shift.bus == self.cleaned_data.get("bus"): + validation_errors["bus"] = ( + "Bus is not available at this time (conflicts with " + f"'{shift}')." + ) + if shift.driver == self.cleaned_data.get("driver"): + validation_errors["driver"] = ( + f"{shift.driver} is not available at this time " + f"(conflicts with '{shift}')." + ) + + if validation_errors: + raise ValidationError(validation_errors) + + return self.cleaned_data + + +@admin.register(models.BusShift) +class BusShiftAdmin(admin.ModelAdmin): + # TODO: ordering (by first stop date_time) + + list_display = ( + "pk", + "first_stop_date_time", + "bus", + "driver", + "stops_count", + "first_stop", + "last_stop", + # "is_valid", # Not implemented yet + ) + + # def is_valid(self, obj): + # return obj.is_valid + + # is_valid.boolean = True + # is_valid.short_description = "Is valid" + + form = BusShiftAdminForm diff --git a/padam_django/apps/shifts/apps.py b/padam_django/apps/shifts/apps.py new file mode 100644 index 00000000..0d80c557 --- /dev/null +++ b/padam_django/apps/shifts/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ShiftsConfig(AppConfig): + name = 'padam_django.apps.shifts' diff --git a/padam_django/apps/shifts/migrations/0001_initial.py b/padam_django/apps/shifts/migrations/0001_initial.py new file mode 100644 index 00000000..6b5108eb --- /dev/null +++ b/padam_django/apps/shifts/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.5 on 2024-11-16 09:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('fleet', '0002_auto_20211109_1456'), + ('geography', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='BusStop', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('time', models.DateTimeField()), + ('place', models.OneToOneField(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.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='fleet.bus')), + ('driver', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='fleet.driver')), + ('stops', models.ManyToManyField(to='shifts.BusStop')), + ], + ), + ] diff --git a/padam_django/apps/shifts/migrations/0002_rename_time_busstop_date_time.py b/padam_django/apps/shifts/migrations/0002_rename_time_busstop_date_time.py new file mode 100644 index 00000000..73f1d077 --- /dev/null +++ b/padam_django/apps/shifts/migrations/0002_rename_time_busstop_date_time.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.5 on 2024-11-16 09:56 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('shifts', '0001_initial'), + ] + + operations = [ + migrations.RenameField( + model_name='busstop', + old_name='time', + new_name='date_time', + ), + ] diff --git a/padam_django/apps/shifts/migrations/0003_auto_20241116_1157.py b/padam_django/apps/shifts/migrations/0003_auto_20241116_1157.py new file mode 100644 index 00000000..f93fb991 --- /dev/null +++ b/padam_django/apps/shifts/migrations/0003_auto_20241116_1157.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.5 on 2024-11-16 10:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0002_auto_20211109_1456'), + ('shifts', '0002_rename_time_busstop_date_time'), + ] + + operations = [ + migrations.RemoveField( + model_name='busshift', + name='bus', + ), + migrations.AddField( + model_name='busshift', + name='bus', + field=models.ManyToManyField(to='fleet.Bus'), + ), + migrations.RemoveField( + model_name='busshift', + name='driver', + ), + migrations.AddField( + model_name='busshift', + name='driver', + field=models.ManyToManyField(to='fleet.Driver'), + ), + ] diff --git a/padam_django/apps/shifts/migrations/0004_auto_20241116_1200.py b/padam_django/apps/shifts/migrations/0004_auto_20241116_1200.py new file mode 100644 index 00000000..699ce936 --- /dev/null +++ b/padam_django/apps/shifts/migrations/0004_auto_20241116_1200.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.5 on 2024-11-16 11:00 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0002_auto_20211109_1456'), + ('shifts', '0003_auto_20241116_1157'), + ] + + operations = [ + migrations.RemoveField( + model_name='busshift', + name='bus', + ), + migrations.AddField( + model_name='busshift', + name='bus', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='fleet.bus'), + ), + migrations.RemoveField( + model_name='busshift', + name='driver', + ), + migrations.AddField( + model_name='busshift', + name='driver', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='fleet.driver'), + ), + ] diff --git a/padam_django/apps/shifts/migrations/0005_auto_20241116_1211.py b/padam_django/apps/shifts/migrations/0005_auto_20241116_1211.py new file mode 100644 index 00000000..f33087ac --- /dev/null +++ b/padam_django/apps/shifts/migrations/0005_auto_20241116_1211.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.5 on 2024-11-16 11:11 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0002_auto_20211109_1456'), + ('shifts', '0004_auto_20241116_1200'), + ] + + operations = [ + migrations.AlterField( + model_name='busshift', + name='bus', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='fleet.bus'), + ), + migrations.AlterField( + model_name='busshift', + name='driver', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='fleet.driver'), + ), + ] diff --git a/padam_django/apps/shifts/migrations/__init__.py b/padam_django/apps/shifts/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/shifts/models.py b/padam_django/apps/shifts/models.py new file mode 100644 index 00000000..8317ba41 --- /dev/null +++ b/padam_django/apps/shifts/models.py @@ -0,0 +1,98 @@ +from typing import Iterable, Union +from django.db import models +from django.core.exceptions import ValidationError +from datetime import datetime + + +def fmt_date_time(date_time: datetime): + return date_time.strftime("%d/%m/%Y à %H:%M") + + +class BusStop(models.Model): + place = models.OneToOneField("geography.Place", on_delete=models.CASCADE) + date_time = models.DateTimeField() + + def __str__(self): + # TODO: Format date time to be human-readable + return ( + f"BusStop: '{self.place.name}' [{fmt_date_time(self.date_time)}] " + f"(id: {self.pk})" + ) + + +class BusShift(models.Model): + bus = models.ForeignKey( + "fleet.Bus", null=True, blank=True, on_delete=models.CASCADE + ) + driver = models.ForeignKey( + "fleet.Driver", null=True, blank=True, on_delete=models.CASCADE + ) + # TODO: Order by date_time to avoid doing so in other places + stops = models.ManyToManyField(BusStop) + + @property + def first_stop(self): + return self.stops.all().order_by("date_time").first() + + @property + def first_stop_date_time(self): + return self.first_stop.date_time if self.first_stop else None + + @property + def last_stop(self): + return self.stops.all().order_by("date_time").last() + + @property + def stops_count(self): + return len(self.stops.all()) + + def validate(self): + # TODO: Mutualize validation from BusShiftAdminForm + add check that both Bus and Driver are set + # Skip validation if the object is not saved yet (no primary key) + if not self.pk: + return + + @property + def is_valid(self) -> bool: + """Check that the BusShift is fully ready (does not violate + constraints and has both a Driver and a Bus assigned to. + + Returns: + bool: Validity status + """ + try: + self.validate() + except ValidationError: + return False + return True + + def clean(self) -> None: + try: + self.validate() + except ValidationError as e: + raise (e) + + def save( + self, + force_insert: bool = False, + force_update: bool = False, + using: Union[str, None] = None, + update_fields: Union[Iterable[str], None] = None, + ) -> None: + # Calling clean to validate data before saving + self.full_clean() + return super().save(force_insert, force_update, using, update_fields) + + def __str__(self): + bus_license_plate = self.bus.licence_plate if self.bus else "" + driver_first_name = ( + self.driver.user.first_name if self.driver else "" + ) + driver_last_name = self.driver.user.last_name if self.driver else "" + return ( + f"BusShift: {bus_license_plate} " + f"[{fmt_date_time(self.first_stop.date_time)} - " + f"{fmt_date_time(self.last_stop.date_time)}] " + f"conducted by {driver_first_name} {driver_last_name} " + f"(id: {self.pk})" + ) diff --git a/padam_django/secrets_example.json b/padam_django/secrets_example.json new file mode 100644 index 00000000..d29efcc1 --- /dev/null +++ b/padam_django/secrets_example.json @@ -0,0 +1,4 @@ +{ + "SECRET_KEY": "", + "DB_PASSWORD": "" +} \ No newline at end of file diff --git a/padam_django/settings.py b/padam_django/settings.py index 129e922c..aa92ce19 100644 --- a/padam_django/settings.py +++ b/padam_django/settings.py @@ -11,6 +11,9 @@ """ from pathlib import Path +import os +import json +from django.core.exceptions import ImproperlyConfigured # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -19,8 +22,21 @@ # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ +with open(os.path.join(BASE_DIR, "padam_django\\secrets.json")) as secrets_file: + secrets = json.load(secrets_file) + + +def get_secret(setting, secrets=secrets): + """Get secret setting or fail with ImproperlyConfigured""" + try: + return secrets[setting] + except KeyError: + raise ImproperlyConfigured( + f"Failed to get {setting} setting. Please add it to secrets.json" + ) + # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-&r2)+_fdqxe2dtc@1vizr6tsh6!1cesaptlfgj@ug*%3=fnq=i' +SECRET_KEY = get_secret("SECRET_KEY") # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -42,6 +58,7 @@ 'django_extensions', # Internal apps 'padam_django.apps.common', + 'padam_django.apps.shifts', 'padam_django.apps.fleet', 'padam_django.apps.geography', 'padam_django.apps.users', @@ -82,9 +99,13 @@ # 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.postgresql", + "NAME": "padam_django_test", + "USER": "padam_django_test", + "HOST": "127.0.0.1", + "PORT": "5432", + "PASSWORD": get_secret("DB_PASSWORD"), } } diff --git a/requirements.txt b/requirements.txt index 89c9f0c8..61de486c 100644 Binary files a/requirements.txt and b/requirements.txt differ