From ed04175aaff1c26fa24ca4762754b52512ec6752 Mon Sep 17 00:00:00 2001 From: AnthonyLeDu Date: Sat, 16 Nov 2024 09:30:04 +0100 Subject: [PATCH 1/4] feat - setup project to use postgres --- .gitignore | 4 ++++ padam_django/secrets_example.json | 4 ++++ padam_django/settings.py | 28 ++++++++++++++++++++++++---- 3 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 padam_django/secrets_example.json diff --git a/.gitignore b/.gitignore index d7d26693..56427a7f 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/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..1696d1b0 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 @@ -82,9 +98,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"), } } From 7af7d1b1d4e9a0806f9cb3eb4a6d4a527606b042 Mon Sep 17 00:00:00 2001 From: AnthonyLeDu Date: Sat, 16 Nov 2024 12:37:46 +0100 Subject: [PATCH 2/4] feat - first implementation of BusShift and BusStop --- .gitignore | 2 +- manage.py | 4 +- padam_django/apps/shifts/__init__.py | 0 padam_django/apps/shifts/admin.py | 96 ++++++++++++++++++ padam_django/apps/shifts/apps.py | 5 + padam_django/apps/shifts/factories.py | 25 +++++ .../apps/shifts/migrations/0001_initial.py | 34 +++++++ .../0002_rename_time_busstop_date_time.py | 18 ++++ .../migrations/0003_auto_20241116_1157.py | 32 ++++++ .../migrations/0004_auto_20241116_1200.py | 33 ++++++ .../migrations/0005_auto_20241116_1211.py | 25 +++++ .../apps/shifts/migrations/__init__.py | 0 padam_django/apps/shifts/models.py | 89 ++++++++++++++++ padam_django/settings.py | 1 + requirements.txt | Bin 106 -> 264 bytes 15 files changed, 361 insertions(+), 3 deletions(-) create mode 100644 padam_django/apps/shifts/__init__.py create mode 100644 padam_django/apps/shifts/admin.py create mode 100644 padam_django/apps/shifts/apps.py create mode 100644 padam_django/apps/shifts/factories.py create mode 100644 padam_django/apps/shifts/migrations/0001_initial.py create mode 100644 padam_django/apps/shifts/migrations/0002_rename_time_busstop_date_time.py create mode 100644 padam_django/apps/shifts/migrations/0003_auto_20241116_1157.py create mode 100644 padam_django/apps/shifts/migrations/0004_auto_20241116_1200.py create mode 100644 padam_django/apps/shifts/migrations/0005_auto_20241116_1211.py create mode 100644 padam_django/apps/shifts/migrations/__init__.py create mode 100644 padam_django/apps/shifts/models.py diff --git a/.gitignore b/.gitignore index 56427a7f..81322895 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ __pycache__/ # virtualenv venv/ -.venv/ +.venv*/ ENV/ # pipenv: https://github.com/kennethreitz/pipenv /Pipfile 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..4235c15b --- /dev/null +++ b/padam_django/apps/shifts/admin.py @@ -0,0 +1,96 @@ +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", + "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/factories.py b/padam_django/apps/shifts/factories.py new file mode 100644 index 00000000..a9720695 --- /dev/null +++ b/padam_django/apps/shifts/factories.py @@ -0,0 +1,25 @@ +import factory +from faker import Faker + +from . import models + + +fake = Faker(['fr']) +fake_date = fake.date() + +# TODO : test +class BusStopFactory(factory.django.DjangoModelFactory): + place = factory.SubFactory( + 'padam_django.apps.geography.factories.PlaceFactory' + ) + date_time = fake.datetime_between(fake_date, fake_date) + + class Meta: + model = models.BusStop + + +class BusShiftFactory(factory.django.DjangoModelFactory): + #TODO + + class Meta: + model = models.BusShift 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..ff2c9a50 --- /dev/null +++ b/padam_django/apps/shifts/models.py @@ -0,0 +1,89 @@ +from typing import Iterable, Union +from django.db import models +from django.core.exceptions import ValidationError + + +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}' at {self.date_time} (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 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"[{self.first_stop.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/settings.py b/padam_django/settings.py index 1696d1b0..aa92ce19 100644 --- a/padam_django/settings.py +++ b/padam_django/settings.py @@ -58,6 +58,7 @@ def get_secret(setting, secrets=secrets): 'django_extensions', # Internal apps 'padam_django.apps.common', + 'padam_django.apps.shifts', 'padam_django.apps.fleet', 'padam_django.apps.geography', 'padam_django.apps.users', diff --git a/requirements.txt b/requirements.txt index 89c9f0c84004dc1d25776036a35d89fcabed77e8..61de486c0456f7c831fe128d533a33790f52efa0 100644 GIT binary patch literal 264 zcmYk0OA5k35JcZv@F)?JAex0c@d~c}#u&&1Kj3(HwYp_ghMsQdS5^IbE;zBKqb4xd zD;ZI!CJY&P?f!_2J$J79J1#WRSWmiKy3#c(5ocN+>Nj~CVXk9U?nFaRl+)2YY(Lem xP_9ua`=HylqJrEa85>RUoA+@vb}6p)Ommuf$}h`^UUx=$1x>Yb(nXcniZ_LqCT#!! literal 106 zcmXAhy9$6X3;_51i=nNEF4@FiaIPBb0~)CntY0tH@i^|<&3Y$9B^s;%rVk6gBl~j| ue`cexgr39x@a2dMD%7wZDK6rPYBUupfZ2{wLMjGH|4l(>cMe1i>Ri0kQy)M8 From 16f5242c7784ce73da24e875eb69efa8c120867f Mon Sep 17 00:00:00 2001 From: AnthonyLeDu Date: Sat, 16 Nov 2024 12:41:23 +0100 Subject: [PATCH 3/4] clean - remove factory pushed by accident --- padam_django/apps/shifts/factories.py | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 padam_django/apps/shifts/factories.py diff --git a/padam_django/apps/shifts/factories.py b/padam_django/apps/shifts/factories.py deleted file mode 100644 index a9720695..00000000 --- a/padam_django/apps/shifts/factories.py +++ /dev/null @@ -1,25 +0,0 @@ -import factory -from faker import Faker - -from . import models - - -fake = Faker(['fr']) -fake_date = fake.date() - -# TODO : test -class BusStopFactory(factory.django.DjangoModelFactory): - place = factory.SubFactory( - 'padam_django.apps.geography.factories.PlaceFactory' - ) - date_time = fake.datetime_between(fake_date, fake_date) - - class Meta: - model = models.BusStop - - -class BusShiftFactory(factory.django.DjangoModelFactory): - #TODO - - class Meta: - model = models.BusShift From e5a91d3f2716a4f268adf64019e4fcf97ec47553 Mon Sep 17 00:00:00 2001 From: AnthonyLeDu Date: Sat, 16 Nov 2024 13:04:53 +0100 Subject: [PATCH 4/4] feat - format date_time --- padam_django/apps/shifts/admin.py | 3 ++- padam_django/apps/shifts/models.py | 21 +++++++++++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/padam_django/apps/shifts/admin.py b/padam_django/apps/shifts/admin.py index 4235c15b..dafae3c3 100644 --- a/padam_django/apps/shifts/admin.py +++ b/padam_django/apps/shifts/admin.py @@ -76,9 +76,10 @@ def clean(self): @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", diff --git a/padam_django/apps/shifts/models.py b/padam_django/apps/shifts/models.py index ff2c9a50..8317ba41 100644 --- a/padam_django/apps/shifts/models.py +++ b/padam_django/apps/shifts/models.py @@ -1,6 +1,11 @@ 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): @@ -10,7 +15,8 @@ class BusStop(models.Model): def __str__(self): # TODO: Format date time to be human-readable return ( - f"BusStop: '{self.place.name}' at {self.date_time} (id: {self.pk})" + f"BusStop: '{self.place.name}' [{fmt_date_time(self.date_time)}] " + f"(id: {self.pk})" ) @@ -28,6 +34,10 @@ class BusShift(models.Model): 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() @@ -36,17 +46,15 @@ def last_stop(self): 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 + # 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 + """Check that the BusShift is fully ready (does not violate constraints and has both a Driver and a Bus assigned to. Returns: @@ -83,7 +91,8 @@ def __str__(self): driver_last_name = self.driver.user.last_name if self.driver else "" return ( f"BusShift: {bus_license_plate} " - f"[{self.first_stop.date_time} - {self.last_stop.date_time}] " + 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})" )