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
46 changes: 46 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,51 @@
# N: Set the number of test data items to create (default: 5)
# Example: make create_test_data/buses N=10
N ?= 5

.PHONY: help
help: ## Show this help message.
@echo "Usage: make [target]"
@echo ""
@echo "Available targets:"
@grep -E '^[a-zA-Z_/\-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " %-30s %s\n", $$1, $$2}'
@echo ""
@echo "Options:"
@echo " N=<number> Set the number of test data items to create (default: 5)"
@echo ""
@echo "Examples:"
@echo " make create_test_data/buses"
@echo " make create_test_data/buses N=10"
@echo " make create_test_data/drivers N=3"

run: ## Run the test server.
python manage.py runserver_plus

install: ## Install the python requirements.
pip install -r requirements.txt

migrate: ## Apply migrations.
python manage.py migrate

test: ## Run tests.
python manage.py test

create_test_data: ## Create test data.
python manage.py create_data

create_test_data/buses: ## Create test data for buses.
python manage.py create_buses -n $(N)

create_test_data/drivers: ## Create test data for drivers.
python manage.py create_drivers -n $(N)

create_test_data/places: ## Create test data for places.
python manage.py create_places -n $(N)

create_test_data/users: ## Create test data for users.
python manage.py create_users -n $(N)

create_test_data/bus_shifts: ## Create test data for bus shifts.
python manage.py create_bus_shifts -n $(N)

create_super_user: ## Create a superuser.
python manage.py createsuperuser
98 changes: 98 additions & 0 deletions README_project.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Django technical test / Backend (English version first, French version below)

## Technical stack

| Name | Version |
| ------ | ------- |
| Python | 3.9 |
| Django | 4.2.16 |

- This project was created using Python 3.7. You are free to use another version, but this is the one we recommend.
recommended.
- The database is freely selectable. The project is configured to use `sqlite` by default.

/ ! \ To keep the `ipython==8.29.0` requirement, a minimum Python version of `3.10` is required; it can be removed if necessary, as it is not used in the project.

### Start the project

All Makefile commands are accessible with

```bash
make help
```

*From your Python virtualenv*:

```bash
make install
make migrate
make run
```

Scripts are available to help you quickly create data and take control of the project and are available via Makefile targets.
in hand:

- `create_data`
- `create_buses`
- `create_drivers`
- `create_places`
- `create_users`
- `create_bus_shifts`

For example:

```bash
make create_test_data
make create_test_data/drivers N=5
```




# Test technique Django / Backend

## Stack Technique

| Nom | Version |
| ------ | ------- |
| Python | 3.9 |
| Django | 4.2.16 |

- Le projet à été réalisé en utilisant Python 3.7. Vous êtes libre d'utiliser une autre version mais c'est celle que
nous vous conseillons.
- La base de donnée est au choix. Le projet est configuré pour utiliser `sqlite` par défaut.

/ ! \ Pour conserver le requirement `ipython==8.29.0` il est nécessaire d'utiliser une version minimum `3.10` de Python, il est possible de le retirer si besoin car il n'est pas utilisé dans le projet.

### Démarrer le projet

L'ensemble des commandes du Makefile sont accessibles avec

```bash
make help
```

*Depuis votre virtualenv Python*:

```bash
make install
make migrate
make run
```

Des scripts sont à votre disposition pour vous permettre de rapidement créer de la donnée et de prendre le projet en
main et sont disponibles via des targets du Makefile:

- `create_data`
- `create_buses`
- `create_drivers`
- `create_places`
- `create_users`
- `create_bus_shifts`

Par exemple:

```bash
make create_test_data
make create_test_data/drivers N=5
```
Empty file.
106 changes: 106 additions & 0 deletions padam_django/apps/busshifts/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
from django.contrib import admin
from django.core.exceptions import ValidationError
from django.forms.models import BaseInlineFormSet
from django.utils import timezone

from . import models
from .services import ShiftService

class BusStopInlineFormSet(BaseInlineFormSet):
def _get_valid_forms(self):
return [
form for form in self.forms
if form.is_valid() and form.cleaned_data and not form.cleaned_data.get('DELETE', False)
]

def _get_stops_data(self, valid_forms):
return [
{
'place': form.cleaned_data.get('place'),
'stop_datetime': self._clean_stop_datetime(form.cleaned_data.get('stop_datetime')),
}
for form in valid_forms
]

def _clean_stop_datetime(self, stop_datetime):
if isinstance(stop_datetime, str):
value = stop_datetime.replace('Z', '+00:00')
stop_datetime = timezone.datetime.fromisoformat(value)
return stop_datetime

def clean(self):
super().clean()

if any(self.errors):
return

stops_data = self._get_stops_data(self._get_valid_forms())

try:
ShiftService.validate_shift_data(
bus=self.instance.bus,
driver=self.instance.driver,
stops_data=stops_data,
exclude_pk=self.instance.pk,
)
except ValidationError as exc:
raise ValidationError(exc.messages)


class BusStopInline(admin.TabularInline):
model = models.BusStop
extra = 1
formset = BusStopInlineFormSet
autocomplete_fields = ['place']

def get_queryset(self, request):
queryset = super().get_queryset(request)
return queryset.select_related('place').order_by('stop_datetime')


@admin.register(models.BusShift)
class BusShiftAdmin(admin.ModelAdmin):
inlines = [BusStopInline]
list_display = ('bus', 'driver', 'departure_time_display', 'arrival_time_display', 'duration_display')
readonly_fields = ('departure_time_display', 'arrival_time_display', 'duration_display')
search_fields = ["bus__licence_plate", "driver__user__username", "bus_stops__place__name"]
autocomplete_fields = ['bus', 'driver']
ordering = ('-departure_time',)
list_filter = ('bus', 'driver', 'departure_time')
exclude = ('departure_time', 'arrival_time')

def departure_time_display(self, obj):
if not obj.departure_time:
return '-'
return obj.departure_time.strftime('%d/%m/%Y %H:%M')

departure_time_display.short_description = 'Departure time'
departure_time_display.admin_order_field = 'departure_time'

def arrival_time_display(self, obj):
if not obj.arrival_time:
return '-'
return obj.arrival_time.strftime('%d/%m/%Y %H:%M')

arrival_time_display.short_description = 'Arrival time'
arrival_time_display.admin_order_field = 'arrival_time'

def duration_display(self, obj):
return obj.duration_display

duration_display.short_description = 'Duration'

def save_related(self, request, form, formsets, change):
super().save_related(request, form, formsets, change)
ShiftService.recalculate_bus_shift_times(form.instance.pk)

def get_queryset(self, request):
return super().get_queryset(request).select_related("bus", "driver__user")


@admin.register(models.BusStop)
class BusStopAdmin(admin.ModelAdmin):
search_fields = ["place__name"]

def has_module_permission(self, request):
return False
6 changes: 6 additions & 0 deletions padam_django/apps/busshifts/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class BusshiftsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'padam_django.apps.busshifts'
52 changes: 52 additions & 0 deletions padam_django/apps/busshifts/factories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import factory
from faker import Faker
from datetime import timedelta
from django.utils import timezone

from . import models
from .services import ShiftService
from padam_django.apps.fleet.factories import BusFactory, DriverFactory
from padam_django.apps.geography.factories import PlaceFactory

fake = Faker(['fr'])


class BusShiftFactory(factory.django.DjangoModelFactory):

bus = factory.SubFactory(BusFactory)
driver = factory.SubFactory(DriverFactory)

class Meta:
model = models.BusShift

@classmethod
def _create(cls, model_class, *args, **kwargs):
bus = kwargs.pop('bus')
driver = kwargs.pop('driver')
stops_data = kwargs.pop('stops_data', None)

if not stops_data:
num_stops = fake.random_int(min=2, max=5)
base_time = timezone.now().replace(hour=8, minute=0, second=0, microsecond=0) + timedelta(days=fake.random_int(1, 30))

stops_data = []
for i in range(num_stops):
stop_time = base_time + timedelta(hours=i, minutes=fake.random_int(0, 59))
stops_data.append({
'place': PlaceFactory(),
'stop_datetime': stop_time,
})

return ShiftService.create_bus_shift_with_stops(
bus=bus,
driver=driver,
stops_data=stops_data,
)

class BusStopFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.BusStop

place = factory.SubFactory(PlaceFactory)
stop_datetime = factory.LazyFunction(timezone.now)
bus_shift = factory.SubFactory(BusShiftFactory)
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from padam_django.apps.common.management.base import CreateDataBaseCommand

from padam_django.apps.busshifts.factories import BusShiftFactory


class Command(CreateDataBaseCommand):

help = 'Create few bus shifts with stops'

def handle(self, *args, **options):
super().handle(*args, **options)
self.stdout.write(f'Creating {self.number} bus shifts ...')
BusShiftFactory.create_batch(size=self.number)
57 changes: 57 additions & 0 deletions padam_django/apps/busshifts/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Generated by Django 4.2.16 on 2026-08-17 14:37

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='BusShift',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('departure_time', models.DateTimeField(blank=True, null=True, verbose_name='Departure time')),
('arrival_time', models.DateTimeField(blank=True, null=True, verbose_name='Arrival time')),
('bus', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='bus_shifts', to='fleet.bus')),
('driver', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='bus_shifts', 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')),
('stop_datetime', models.DateTimeField(verbose_name='Stop datetime')),
('bus_shift', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bus_stops', to='busshifts.busshift')),
('place', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='bus_stops', to='geography.place')),
],
options={
'verbose_name': 'Bus Stop',
'verbose_name_plural': 'Bus Stops',
'indexes': [models.Index(fields=['bus_shift', 'stop_datetime'], name='busshifts_b_bus_shi_eb04e2_idx')],
},
),
migrations.AddIndex(
model_name='busshift',
index=models.Index(fields=['bus', 'departure_time', 'arrival_time'], name='busshifts_b_bus_id_5bc7ec_idx'),
),
migrations.AddIndex(
model_name='busshift',
index=models.Index(fields=['driver', 'departure_time', 'arrival_time'], name='busshifts_b_driver__93ec42_idx'),
),
migrations.AddConstraint(
model_name='busshift',
constraint=models.CheckConstraint(check=models.Q(('arrival_time__gte', models.F('departure_time'))), name='busshift_arrival_after_departure'),
),
]
Empty file.
Loading