Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
18 changes: 18 additions & 0 deletions padam_django/apps/busplanning/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from django.contrib import admin
from .models import BusShift, BusStop, BusShiftStop
from .forms import BusShiftForm

# Register your models here.
@admin.register(BusShift)
class ShiftAdmin(admin.ModelAdmin):
form = BusShiftForm


@admin.register(BusStop)
class StopsAdmin(admin.ModelAdmin):
pass


@admin.register(BusShiftStop)
class ShiftStopsAdmin(admin.ModelAdmin):
pass
6 changes: 6 additions & 0 deletions padam_django/apps/busplanning/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class BusplanningConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'padam_django.apps.busplanning'
46 changes: 46 additions & 0 deletions padam_django/apps/busplanning/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Q

from padam_django.apps.busplanning.models import BusShift
from padam_django.apps.fleet.models import Driver


class BusShiftForm(forms.ModelForm):
class Meta:
model = BusShift
fields = "__all__"
# TODO add ModelChoiceField to add the BusStops at the creation

def clean(self):
cleaned_data = super().clean()
driver = cleaned_data["driver"]
bus = cleaned_data["bus"]
departure = cleaned_data["start_time"]
arrival = cleaned_data["end_time"]

# Get overlapping schedules for driver or buses:
# For any driver or matching bus:
# - start_time < departure < end_time: if the departure overlaps
# - start_time < arrival < end_time: if the arrival overlaps
# - departure < star_time < arrival: if another shift begins during this one
# Any result os overlapping
overlapping_shifts = BusShift.objects.filter(
Q(
Q(driver=driver) | Q(bus=bus)
) &
Q(
Q(start_time__lte=departure, end_time__gte=departure) |
Q(start_time__lte=arrival, end_time__gte=arrival) |
Q(start_time__gte=departure, end_time__lte=arrival)
)
)
print(overlapping_shifts)
if len(overlapping_shifts) > 0:
raise ValidationError(
"Schedule is overlapping with other schedules: %(schedules)s",
code="overlapping",
params={"schedules": overlapping_shifts}
)

return self.cleaned_data
57 changes: 57 additions & 0 deletions padam_django/apps/busplanning/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Generated by Django 3.2.5 on 2024-11-08 13:05

from django.db import migrations, models
import django.db.models.deletion
import django.db.models.expressions


class Migration(migrations.Migration):

initial = True

dependencies = [
('fleet', '0002_auto_20211109_1456'),
('geography', '0001_initial'),
]

operations = [
migrations.CreateModel(
name='BusShift',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('start_time', models.TimeField(verbose_name='Departure time')),
('end_time', models.TimeField(verbose_name='Arrival time')),
('bus', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='fleet.bus')),
('driver', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='fleet.driver')),
],
),
migrations.CreateModel(
name='BusStop',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=63, verbose_name='Stop name')),
('location', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='geography.place')),
],
),
migrations.CreateModel(
name='BusShiftStops',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('index', models.PositiveIntegerField()),
('shift', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='busplanning.busshift')),
('stop', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='busplanning.busstop')),
],
options={
'db_table': 'bus_shift_stops',
'unique_together': {('shift', 'index'), ('shift', 'stop')},
},
),
migrations.AddConstraint(
model_name='busshift',
constraint=models.CheckConstraint(check=models.Q(('driver', django.db.models.expressions.F('driver')), ('start_time__gte', django.db.models.expressions.F('start_time')), ('start_time__lte', django.db.models.expressions.F('start_time')), ('driver', django.db.models.expressions.F('driver')), ('end_time__gte', django.db.models.expressions.F('start_time')), ('end_time__lte', django.db.models.expressions.F('start_time'))), name='driver_planning_constraint'),
),
migrations.AddConstraint(
model_name='busshift',
constraint=models.CheckConstraint(check=models.Q(('bus', django.db.models.expressions.F('bus')), ('start_time__gte', django.db.models.expressions.F('start_time')), ('start_time__lte', django.db.models.expressions.F('start_time')), ('bus', django.db.models.expressions.F('bus')), ('end_time__gte', django.db.models.expressions.F('start_time')), ('end_time__lte', django.db.models.expressions.F('start_time'))), name='bus_planning_constraint'),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 3.2.5 on 2024-11-08 13:56

from django.db import migrations, models
import django.db.models.expressions


class Migration(migrations.Migration):

dependencies = [
('busplanning', '0001_initial'),
]

operations = [
migrations.RemoveConstraint(
model_name='busshift',
name='driver_planning_constraint',
),
migrations.RemoveConstraint(
model_name='busshift',
name='bus_planning_constraint',
),
migrations.AddConstraint(
model_name='busshift',
constraint=models.CheckConstraint(check=models.Q(('start_time__lte', django.db.models.expressions.F('end_time'))), name='start_before_end_constraint'),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 3.2.5 on 2024-11-09 16:51

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('busplanning', '0002_auto_20241108_1456'),
]

operations = [
migrations.RenameModel(
old_name='BusShiftStops',
new_name='BusShiftStop',
),
migrations.AlterModelTable(
name='busshiftstop',
table='bus_shift_stop',
),
]
Empty file.
62 changes: 62 additions & 0 deletions padam_django/apps/busplanning/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from django.db import models
from django.db.models import CheckConstraint, Q, F
from ..fleet.models import Bus, Driver
from ..geography.models import Place


# Create your models here.
class BusStop(models.Model):
name = models.CharField("Stop name", max_length=63)

location = models.OneToOneField(
Place,
on_delete=models.CASCADE,
unique=True
)

def __str__(self):
return f"Stop name: {self.name} (id: {self.pk})"


class BusShift(models.Model):
class Meta:
constraints = [
CheckConstraint(
check=Q(start_time__lte=F('end_time')),
name="end_before_start_constraint"
),
# A driver can only drive outside already allocated time
# TODO create custom constraints check ing against the DB existing records to avoid Duplicates schedule,
# equivalent of postgres exclusion constraint
# - Currently done with admin forms
]

driver = models.ForeignKey(Driver, on_delete=models.SET_NULL, null=True) # can be null but must raise an error

bus = models.ForeignKey(Bus, on_delete=models.SET_NULL, null=True) # can be null but must raise an error

start_time = models.TimeField("Departure time", null=False)
end_time = models.TimeField("Arrival time", null=False)

def __str__(self):
return f"Shift-{self.pk}: {self.driver} // {self.bus}: ({self.start_time}-{self.end_time})"


class BusShiftStop(models.Model):
class Meta:
db_table = 'bus_shift_stop'
unique_together = (
("shift", "stop"), # A stop cannot be twice in a same shift/path
("shift", "index"),
)

shift = models.ForeignKey(
BusShift,
on_delete=models.CASCADE
)
stop = models.ForeignKey(
BusStop,
on_delete=models.CASCADE
)

index = models.PositiveIntegerField() # for ordering purposes
78 changes: 78 additions & 0 deletions padam_django/apps/busplanning/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from django.db import IntegrityError, transaction
from django.db.models import QuerySet
from django.test import TestCase
from .models import BusStop, BusShift, BusShiftStop
from ..fleet.factories import DriverFactory, BusFactory
from ..fleet.models import Driver, Bus
from ..geography.factories import PlaceFactory
from ..geography.models import Place
from ..users.factories import UserFactory


# Create your tests here.
class BusStopsTestCase(TestCase):
def test_create(self):
PlaceFactory.create_batch(size=10)
places: QuerySet = Place.objects.all()[2:7]
stops: list[BusStop] = []

for place in places:
stop = BusStop(place.name, place)
stops.append(stop)


class BusShiftTestCase(TestCase):
def test_create(self):
UserFactory.create_batch(size=10)
DriverFactory.create_batch(size=5)
BusFactory.create_batch(size=5)

driver: Driver = Driver.objects.filter()[1]
bus: Bus = Bus.objects.filter()[1]

stops = BusStop.objects.filter()[0:4]

start_time_0 = '01:00:00'
end_time_0 = '02:00:00'

start_time_1 = '11:00:00'
end_time_1 = '12:00:00'

start_time_2 = '21:00:00'
end_time_2 = '22:00:00'

bs0: BusShift = BusShift(driver=driver, bus=bus, start_time=start_time_0, end_time=end_time_0)
bs0.save()

bs2: BusShift = BusShift(driver=driver, bus=bus, start_time=start_time_2, end_time=end_time_2)
bs2.save()

bs1: BusShift = BusShift(driver=driver, bus=bus, start_time=start_time_1, end_time=end_time_1)
bs1.save()

e0: BusShift = BusShift(driver=driver, bus=bus, start_time=end_time_0, end_time=start_time_0)
try:
with transaction.atomic():
e0.save()
self.fail("end before start")
except IntegrityError:
pass

# TODO test form

# Those tests will fail because they don't use the form that checks for overlapping schedules
e1: BusShift = BusShift(driver=driver, bus=bus, start_time=start_time_0, end_time=end_time_2)
try:
with transaction.atomic():
e1.save()
self.fail("No concurrent shifts allowed")
except IntegrityError:
pass

e2: BusShift = BusShift(driver=driver, bus=bus, start_time=start_time_0, end_time=end_time_2)
try:
with transaction.atomic():
e2.save()
self.fail("No concurrent shifts allowed")
except IntegrityError:
pass
4 changes: 4 additions & 0 deletions padam_django/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
# Third party apps
'django_extensions',
# Internal apps
'padam_django.apps.busplanning',
'padam_django.apps.common',
'padam_django.apps.fleet',
'padam_django.apps.geography',
Expand Down Expand Up @@ -136,3 +137,6 @@
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

# Added in case I wanted ot see the logs in testing
# NOSE_ARGS = ['--nocapture', '--nologcapture',]