diff --git a/.gitignore b/.gitignore
index d7d26693..54e1906b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,6 @@ ENV/
# Editors stuff
.idea
.vscode
+
+# Local stuff
+.python-version
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 73f69e09..00000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,8 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
-# Datasource local storage ignored files
-/dataSources/
-/dataSources.local.xml
-# Editor-based HTTP Client requests
-/httpRequests/
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
deleted file mode 100644
index 03d9549e..00000000
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
deleted file mode 100644
index 105ce2da..00000000
--- a/.idea/inspectionProfiles/profiles_settings.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index 574ec96e..00000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index 2253d389..00000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/padam-django-tech-test.iml b/.idea/padam-django-tech-test.iml
deleted file mode 100644
index c7ffe09b..00000000
--- a/.idea/padam-django-tech-test.iml
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/padam_django/apps/common/management/commands/create_data.py b/padam_django/apps/common/management/commands/create_data.py
index a149a937..68abfee8 100644
--- a/padam_django/apps/common/management/commands/create_data.py
+++ b/padam_django/apps/common/management/commands/create_data.py
@@ -4,11 +4,11 @@
class Command(BaseCommand):
-
- help = 'Create test data'
+ help = "Create test data"
def handle(self, *args, **options):
- management.call_command('create_users', number=5)
- 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_users", number=5)
+ 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=40)
diff --git a/padam_django/apps/fleet/admin.py b/padam_django/apps/fleet/admin.py
deleted file mode 100644
index 3fba5023..00000000
--- a/padam_django/apps/fleet/admin.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from django.contrib import admin
-
-from . import models
-
-
-@admin.register(models.Bus)
-class BusAdmin(admin.ModelAdmin):
- pass
-
-
-@admin.register(models.Driver)
-class DriverAdmin(admin.ModelAdmin):
- pass
diff --git a/padam_django/apps/fleet/admin/__init__.py b/padam_django/apps/fleet/admin/__init__.py
new file mode 100644
index 00000000..c686947d
--- /dev/null
+++ b/padam_django/apps/fleet/admin/__init__.py
@@ -0,0 +1 @@
+from .panels import *
diff --git a/padam_django/apps/fleet/admin/formsets.py b/padam_django/apps/fleet/admin/formsets.py
new file mode 100644
index 00000000..943d6ee5
--- /dev/null
+++ b/padam_django/apps/fleet/admin/formsets.py
@@ -0,0 +1,38 @@
+from django import forms
+from django.core.exceptions import ValidationError
+
+
+class ScheduledStopInlineFormSet(forms.models.BaseInlineFormSet):
+ def clean(self):
+ super().clean()
+
+ # Validate non-overlapping shift schedule for current instance driver and bus
+ times = [
+ form.cleaned_data["time"]
+ for form in self.forms
+ if form.cleaned_data.get("time", None)
+ ]
+
+ if times and len(times) >= 2:
+ departure = min(times)
+ arrival = max(times)
+
+ if (
+ self.instance.driver
+ and self.instance.driver.shifts.exclude(pk=self.instance.pk)
+ .overlapping(departure, arrival)
+ .exists()
+ ):
+ raise ValidationError(
+ "The selected driver already has a shift overlapping this one"
+ )
+
+ if (
+ self.instance.bus
+ and self.instance.bus.shifts.exclude(pk=self.instance.pk)
+ .overlapping(departure, arrival)
+ .exists()
+ ):
+ raise ValidationError(
+ "The selected bus already has a shift overlapping this one"
+ )
diff --git a/padam_django/apps/fleet/admin/inlines.py b/padam_django/apps/fleet/admin/inlines.py
new file mode 100644
index 00000000..f5e3e306
--- /dev/null
+++ b/padam_django/apps/fleet/admin/inlines.py
@@ -0,0 +1,14 @@
+from django.contrib import admin
+
+from padam_django.apps.fleet import models
+from padam_django.apps.fleet.admin.formsets import ScheduledStopInlineFormSet
+
+
+class ScheduledStopInlineAdmin(admin.TabularInline):
+ formset = ScheduledStopInlineFormSet
+ model = models.BusShiftScheduledStop
+ min_num = 2
+
+ def get_formset(self, request, obj=None, **kwargs):
+ kwargs.update(validate_min=True)
+ return super().get_formset(request, obj, **kwargs)
diff --git a/padam_django/apps/fleet/admin/panels.py b/padam_django/apps/fleet/admin/panels.py
new file mode 100644
index 00000000..466f74ad
--- /dev/null
+++ b/padam_django/apps/fleet/admin/panels.py
@@ -0,0 +1,21 @@
+from django.contrib import admin
+
+from padam_django.apps.fleet import models
+from padam_django.apps.fleet.admin.inlines import ScheduledStopInlineAdmin
+
+
+@admin.register(models.Bus)
+class BusAdmin(admin.ModelAdmin):
+ pass
+
+
+@admin.register(models.Driver)
+class DriverAdmin(admin.ModelAdmin):
+ pass
+
+
+@admin.register(models.BusShift)
+class BusShiftAdmin(admin.ModelAdmin):
+ list_display = ("pk", "bus", "driver", "departure", "arrival")
+ fields = ("bus", "driver")
+ inlines = (ScheduledStopInlineAdmin,)
diff --git a/padam_django/apps/fleet/factories.py b/padam_django/apps/fleet/factories.py
index c78c832e..d5e69fb2 100644
--- a/padam_django/apps/fleet/factories.py
+++ b/padam_django/apps/fleet/factories.py
@@ -4,11 +4,11 @@
from . import models
-fake = Faker(['fr'])
+fake = Faker(["fr"])
class DriverFactory(factory.django.DjangoModelFactory):
- user = factory.SubFactory('padam_django.apps.users.factories.UserFactory')
+ user = factory.SubFactory("padam_django.apps.users.factories.UserFactory")
class Meta:
model = models.Driver
@@ -19,3 +19,11 @@ class BusFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.Bus
+
+
+class BusStopFactory(factory.django.DjangoModelFactory):
+ name = factory.LazyFunction(fake.address)
+ place = factory.SubFactory("padam_django.apps.geography.factories.PlaceFactory")
+
+ class Meta:
+ model = models.BusStop
diff --git a/padam_django/apps/fleet/management/commands/create_bus_stops.py b/padam_django/apps/fleet/management/commands/create_bus_stops.py
new file mode 100644
index 00000000..c2fe47d0
--- /dev/null
+++ b/padam_django/apps/fleet/management/commands/create_bus_stops.py
@@ -0,0 +1,12 @@
+from padam_django.apps.common.management.base import CreateDataBaseCommand
+
+from padam_django.apps.fleet.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/fleet/migrations/0003_add_bus_stop_and_bus_shift_models.py b/padam_django/apps/fleet/migrations/0003_add_bus_stop_and_bus_shift_models.py
new file mode 100644
index 00000000..a08db74a
--- /dev/null
+++ b/padam_django/apps/fleet/migrations/0003_add_bus_stop_and_bus_shift_models.py
@@ -0,0 +1,140 @@
+# Generated by Django 3.2.5 on 2024-09-24 13:04
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ 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.PROTECT,
+ related_name="shifts",
+ related_query_name="shift",
+ to="fleet.bus",
+ ),
+ ),
+ (
+ "driver",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="shifts",
+ related_query_name="shift",
+ 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",
+ ),
+ ),
+ (
+ "name",
+ models.CharField(
+ max_length=200, verbose_name="Name of the bus stop"
+ ),
+ ),
+ (
+ "place",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="bus_stop",
+ to="geography.place",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Bus stop",
+ "verbose_name_plural": "Bus stops",
+ },
+ ),
+ migrations.CreateModel(
+ name="BusShiftScheduledStop",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "time",
+ models.TimeField(verbose_name="Time of passage through the stop"),
+ ),
+ (
+ "shift",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="scheduled_stops",
+ related_query_name="scheduled_stop",
+ to="fleet.busshift",
+ ),
+ ),
+ (
+ "stop",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, to="fleet.busstop"
+ ),
+ ),
+ ],
+ options={
+ "ordering": ("time",),
+ },
+ ),
+ migrations.AddField(
+ model_name="busshift",
+ name="stops",
+ field=models.ManyToManyField(
+ related_name="shifts",
+ related_query_name="shift",
+ through="fleet.BusShiftScheduledStop",
+ to="fleet.BusStop",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="busshiftscheduledstop",
+ constraint=models.UniqueConstraint(
+ fields=("stop", "shift"), name="unique_for_stop_shift"
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="busshiftscheduledstop",
+ constraint=models.UniqueConstraint(
+ fields=("shift", "time"), name="unique_for_shift_time"
+ ),
+ ),
+ ]
diff --git a/padam_django/apps/fleet/models.py b/padam_django/apps/fleet/models.py
index 4cd3f19d..d0fbf3a6 100644
--- a/padam_django/apps/fleet/models.py
+++ b/padam_django/apps/fleet/models.py
@@ -2,7 +2,9 @@
class Driver(models.Model):
- user = models.OneToOneField('users.User', on_delete=models.CASCADE, related_name='driver')
+ user = models.OneToOneField(
+ "users.User", on_delete=models.CASCADE, related_name="driver"
+ )
def __str__(self):
return f"Driver: {self.user.username} (id: {self.pk})"
@@ -16,3 +18,88 @@ class Meta:
def __str__(self):
return f"Bus: {self.licence_plate} (id: {self.pk})"
+
+
+class BusStop(models.Model):
+ name = models.CharField("Name of the bus stop", max_length=200)
+ place = models.OneToOneField(
+ "geography.Place", on_delete=models.PROTECT, related_name="bus_stop"
+ )
+
+ class Meta:
+ verbose_name = "Bus stop"
+ verbose_name_plural = "Bus stops"
+
+ def __str__(self):
+ return f"Bus stop: {self.name} (id: {self.pk})"
+
+
+class BusShiftScheduledStop(models.Model):
+ stop = models.ForeignKey("fleet.BusStop", on_delete=models.CASCADE)
+ shift = models.ForeignKey(
+ "fleet.BusShift",
+ on_delete=models.CASCADE,
+ related_name="scheduled_stops",
+ related_query_name="scheduled_stop",
+ )
+ time = models.TimeField("Time of passage through the stop")
+
+ class Meta:
+ ordering = ("time",)
+ constraints = (
+ models.UniqueConstraint(
+ name="unique_for_stop_shift", fields=("stop", "shift")
+ ),
+ models.UniqueConstraint(
+ name="unique_for_shift_time", fields=("shift", "time")
+ ),
+ )
+
+ def __str__(self):
+ return f"{self.stop.name} {self.time}"
+
+
+class BusShiftQueryset(models.QuerySet):
+ def overlapping(self, departure, arrival):
+ return self.filter(
+ scheduled_stop__time__gte=departure,
+ scheduled_stop__time__lte=arrival,
+ )
+
+
+class BusShift(models.Model):
+ objects = BusShiftQueryset.as_manager()
+ bus = models.ForeignKey(
+ "fleet.Bus",
+ on_delete=models.PROTECT,
+ related_name="shifts",
+ related_query_name="shift",
+ )
+ driver = models.ForeignKey(
+ "fleet.Driver",
+ on_delete=models.PROTECT,
+ related_name="shifts",
+ related_query_name="shift",
+ )
+ stops = models.ManyToManyField(
+ "fleet.BusStop",
+ through=BusShiftScheduledStop,
+ related_name="shifts",
+ related_query_name="shift",
+ )
+
+ def departure(self):
+ return self.scheduled_stops.order_by("time").first()
+
+ def arrival(self):
+ return self.scheduled_stops.order_by("time").last()
+
+ def time_range(self):
+ return self.departure().time, self.arrival().time
+
+ class Meta:
+ verbose_name = "Bus shift"
+ verbose_name_plural = "Bus shifts"
+
+ def __str__(self):
+ return f"Bus shift: {self.departure()} — {self.arrival()} (id: {self.pk})"