diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..c5dd6ce5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.venv +.git +.gitignore diff --git a/.env b/.env new file mode 100644 index 00000000..9052a103 --- /dev/null +++ b/.env @@ -0,0 +1,7 @@ +DEBUG=1 +DJANGO_ALLOWED_HOSTS=localhost +DB_NAME= +DB_USERNAME= +DB_PASSWORD= +DB_HOSTNAME= +DB_PORT= diff --git a/.gitignore b/.gitignore index d7d26693..a660ddff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.idea/ +*.iml # Byte-compiled / optimized / DLL files __pycache__/ .pytest_cache/ @@ -20,3 +22,139 @@ ENV/ # Editors stuff .idea .vscode +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments + +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.venv-dev + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +/data + +media/ +mediafiles/ +staticfiles/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..a112ed0c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,40 @@ +repos: + - repo: 'https://github.com/pre-commit/pre-commit-hooks' + rev: 'v4.6.0' + hooks: + - id: 'check-yaml' + - id: 'end-of-file-fixer' + - id: 'trailing-whitespace' + + - repo: 'https://github.com/psf/black' + rev: '24.3.0' + hooks: + - id: 'black' + language_version: 'python3.11' + files: '\.py$' + exclude: 'migrations/' + + - repo: 'https://github.com/PyCQA/isort' + rev: '5.13.2' + hooks: + - id: 'isort' + language_version: 'python3.11' + files: '\.py$' + exclude: 'migrations/' + + - repo: 'https://github.com/PyCQA/flake8' + rev: '7.0.0' + hooks: + - id: 'flake8' + language_version: 'python3.11' + files: '\.py$' + exclude: 'migrations/' + + - repo: 'https://github.com/pre-commit/mirrors-pylint' + rev: 'main' + hooks: + - id: 'pylint' + language_version: 'python3.11' + files: '\.py$' + exclude: 'migrations/' + args: ['--disable=E0401'] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..66028b22 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.11-slim + +ENV PIP_DISABLE_PIP_VERSION_CHECK 1 +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +WORKDIR /code + +COPY ./requirements.txt . + +RUN apt-get update -y && \ + apt-get install -y netcat-openbsd && \ + pip install --upgrade pip && \ + pip install -r requirements.txt + +COPY ./entrypoint.sh . +RUN chmod +x /code/entrypoint.sh + +COPY . . + +ENTRYPOINT ["/code/entrypoint.sh"] diff --git a/Makefile b/Makefile index 4062f4c4..658e31e4 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,35 @@ -run: ## Run the test server. - python manage.py runserver_plus +# Start the docker containers +docker-up: ## Start the docker containers + docker-compose up --build + +# Stop the docker containers +docker-down: ## Stop the docker containers + docker-compose down + +# Access the web container shell +docker-sh: ## Access the docker container shell + docker exec -it padam-web sh + +dokcer-migrate: ## Apply database migrations inside the container + docker exec -it padam-web python manage.py migrate + +# Create a Django superuser inside the container +docker-superuser: ## Create a superuser for the Django admin + docker exec -it padam-web python manage.py createsuperuser + +# Create sample data inside the container +docker-create-data: ## Create sample data + docker exec -it padam-web python manage.py create_data + +# Assign permissions to non-driver users inside the container +docker-assign-perms: ## Make non-driver users staff and assign BusShift & BusStop permissions + docker exec -it padam-web python manage.py update_non_drivers + +# Assign permissions to non-driver users inside the container +docker-user-test: ## + docker exec -it padam-web python manage.py create_test_user + +# Run tests inside the container +docker-test: ## Run tests inside the container + docker exec -it padam-web python manage.py test -install: ## Install the python requirements. - pip install -r requirements.txt diff --git a/README_SOLUTION.md b/README_SOLUTION.md new file mode 100644 index 00000000..e23651f2 --- /dev/null +++ b/README_SOLUTION.md @@ -0,0 +1,114 @@ +# Solution: Lancer et tester le projet Django avec Docker + +## Table des matières + +1. [Lancer le projet avec Docker et Makefile](#lancer-le-projet-avec-docker-et-makefile) +2. [Management commands utiles](#management-commands-utiles) +3. [Tests unitaires](#tests-unitaires) +4. [Remarques](#remarques-importantes) +5. [Résumé des workflows](#résumé-des-workflows) +6. [Outils de développement](#outils-de-développement) + + +--- + +## 1. Lancer le projet avec Docker et Makefile + +Le projet est configuré pour s’exécuter avec **Docker** et une base **PostgreSQL**. +Des scripts `Makefile` facilitent les opérations courantes pour le développement et les tests. + +### Commandes à exécuter: + +1 - **Démarrer les conteneurs Docker** + `make docker-up` + +2 - **Accéder à l’interface Django admin** + [Django admin](http://127.0.0.1:8001/admin) + +3 - **Créer des données d’exemple** + `make docker-create-data` + +4 - **Créer un superutilisateur Django** + `make docker-superuser` + +5 - **Update un utilisateur de test** + `make docker-user-test` + +6 - **Assigner les permissions aux utilisateurs non-driver** + `make docker-assign-perms` + +7 - **Se connecter avec l’utilisateur de test** : + + - `username = "andreechretien"` + - `password = "test12356"` + +8 - **Faire les tests sur l'interface admin Django `Add bus shift`** + +9 - **Exécuter les tests unitaires** + `make docker-test` + +--- + +## 2. Management commands utiles + +- **Arrêter les conteneurs Docker** + `make docker-down` + +- **Accéder au shell du conteneur web** + `make docker-sh` + +- **Appliquer les migrations de la base de données** + `make docker-migrate` + +--- + +## 3. Tests unitaires + +- Les tests sont contenus dans `test_bus_shift_service.py`. +- Ils couvrent la validation métier et la logique de calcul des temps pour BusShift et BusStop. +- Les tests sont isolés et utilisent `setUp` pour créer des entités réutilisables (Bus, Driver, Place). +- Représente un exemple clair de tests unitaires pour un service métier. + +--- + +## 4. Remarques + +### create_test_user.py + +- Permet de créer ou modifier le mot de passe d’un utilisateur de test. +- Utilisation: choisir un utilisateur de la base de donnée **non driver** par exemple : + - `username = "andreechretien"` + - `password = "test12356"` + +--- + +## 5. Résumé des workflows + +1. Démarrer les conteneurs Docker → `make docker-up` +2. Créer les données et superutilisateur → `make docker-create-data` + `make docker-superuser` +3. Assigner les permissions → `make docker-assign-perms` +4. Créer et utiliser l’utilisateur de test +5. Exécuter les tests → `make docker-test` +6. Accéder à l’admin Django → [Django admin](http://127.0.0.1:8001/admin) + +--- + +# 6 Outils de développement : formatage et linting + +Le projet inclut des outils pour garantir la qualité et la cohérence du code : + +- **black** : formatage automatique du code Python +- **isort** : tri cohérent des imports +- **flake8** : vérification du style et détection d’erreurs courantes +- **pre-commit** : exécution automatique des hooks avant chaque commit + +## Installation + +```bash +pip install -r dev-requirements.txt +pre-commit install +``` + +## Utilisation +- Chaque commit exécutera automatiquement les outils configurés dans .pre-commit-config.yaml. +- Les corrections automatiques seront appliquées. \ No newline at end of file diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 00000000..edac1fb4 --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,5 @@ +# Outils de formatage et linting +black==24.3.0 +flake8==7.0.0 +isort==5.13.2 +pre-commit diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..a7ae9809 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + web: + build: . + container_name: padam-web + command: python /code/manage.py runserver 0.0.0.0:8001 + volumes: + - .:/code + env_file: + - ./.env + ports: + - "8001:8001" + depends_on: + - db + + db: + container_name: padam-db + image: postgres:14 + volumes: + - postgres_data:/var/lib/postgresql/data/ + environment: + - "POSTGRES_HOST_AUTH_METHOD=trust" + - POSTGRES_USER=${DB_USERNAME} + - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_DB=${DB_NAME} + + +volumes: + postgres_data: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 00000000..cebbb509 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/sh +echo 'Waiting for postgres...' + +while ! nc -z $DB_HOSTNAME $DB_PORT; do + sleep 0.1 +done + +echo 'PostgreSQL started' + +echo 'Installing requirements...' +pip install -r requirements.txt + +echo 'Making migrations...' +python manage.py makemigrations + +echo 'Running migrations...' +python manage.py migrate + +echo 'Collecting static files...' +python manage.py collectstatic --no-input + +exec "$@" diff --git a/padam_django/apps/common/management/commands/create_data.py b/padam_django/apps/common/management/commands/create_data.py index a149a937..ba051342 100644 --- a/padam_django/apps/common/management/commands/create_data.py +++ b/padam_django/apps/common/management/commands/create_data.py @@ -1,3 +1,4 @@ +from django.core import management from django.core.management.base import BaseCommand from django.core import management diff --git a/padam_django/apps/fleet/management/commands/create_buses.py b/padam_django/apps/fleet/management/commands/create_buses.py index eaadc0a8..0a5c2be9 100644 --- a/padam_django/apps/fleet/management/commands/create_buses.py +++ b/padam_django/apps/fleet/management/commands/create_buses.py @@ -1,5 +1,4 @@ from padam_django.apps.common.management.base import CreateDataBaseCommand - from padam_django.apps.fleet.factories import BusFactory diff --git a/padam_django/apps/fleet/models.py b/padam_django/apps/fleet/models.py index 4cd3f19d..b92ecfca 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})" diff --git a/padam_django/apps/geography/management/commands/create_places.py b/padam_django/apps/geography/management/commands/create_places.py index beb41514..283883f8 100644 --- a/padam_django/apps/geography/management/commands/create_places.py +++ b/padam_django/apps/geography/management/commands/create_places.py @@ -1,5 +1,4 @@ from padam_django.apps.common.management.base import CreateDataBaseCommand - from padam_django.apps.geography.factories import PlaceFactory diff --git a/padam_django/apps/transport/__init__.py b/padam_django/apps/transport/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/transport/admin.py b/padam_django/apps/transport/admin.py new file mode 100644 index 00000000..0654fd98 --- /dev/null +++ b/padam_django/apps/transport/admin.py @@ -0,0 +1,28 @@ +from django.contrib import admin +from django.db import transaction + +from .bus_shift_service import update_shift_times +from .forms import BusShiftForm +from .models import BusShift, BusStop + + +class BusStopInline(admin.TabularInline): + model = BusStop + extra = 2 + fields = ('place', 'time', 'order') + ordering = ('order',) + +@admin.register(BusShift) +class BusShiftAdmin(admin.ModelAdmin): + form = BusShiftForm + inlines = [BusStopInline] + list_display = ['bus', 'driver', 'start_time', 'end_time', 'duration'] + + @transaction.atomic + def save_formset(self, request, form, formset, change): + """ + Sauvegarde des inlines puis calcul automatique des temps du trajet. + """ + super().save_formset(request, form, formset, change) + # Calcul des temps et vérification chevauchements + update_shift_times(form.instance) diff --git a/padam_django/apps/transport/apps.py b/padam_django/apps/transport/apps.py new file mode 100644 index 00000000..606db56f --- /dev/null +++ b/padam_django/apps/transport/apps.py @@ -0,0 +1,4 @@ +from django.apps import AppConfig + +class TripsConfig(AppConfig): + name = "padam_django.apps.transport" \ No newline at end of file diff --git a/padam_django/apps/transport/bus_shift_service.py b/padam_django/apps/transport/bus_shift_service.py new file mode 100644 index 00000000..9492ae62 --- /dev/null +++ b/padam_django/apps/transport/bus_shift_service.py @@ -0,0 +1,37 @@ +from django.db import transaction +from django.core.exceptions import ValidationError +from django.db.models import Q +from .models import BusShift + +def update_shift_times(shift): + """ + Met à jour start_time, end_time et duration pour un BusShift + après la sauvegarde des BusStop. + """ + stops = list(shift.stops.all()) + with transaction.atomic(): + # Lock du shift pour éviter modifications concurrentes + shift = BusShift.objects.select_for_update().get(pk=shift.pk) + if len(stops) < 2: + raise ValidationError("Un trajet doit avoir au moins 2 arrêts.") + + # Tri par order + stops_sorted = sorted(stops, key=lambda s: s.order) + shift.start_time = stops_sorted[0].time + shift.end_time = stops_sorted[-1].time + shift.duration = shift.end_time - shift.start_time + + # Validation cohérente + if shift.end_time <= shift.start_time: + raise ValidationError("La fin doit être après le début.") + + # Vérification chevauchements bus/driver + overlapping = shift.__class__.objects.exclude(pk=shift.pk).filter( + Q(bus=shift.bus) | Q(driver=shift.driver), + start_time__lt=shift.end_time, + end_time__gt=shift.start_time, + ) + if overlapping.exists(): + raise ValidationError("Ce trajet chevauche un autre trajet existant.") + + shift.save() diff --git a/padam_django/apps/transport/forms.py b/padam_django/apps/transport/forms.py new file mode 100644 index 00000000..33f63347 --- /dev/null +++ b/padam_django/apps/transport/forms.py @@ -0,0 +1,8 @@ +from django import forms +from .models import BusShift + +class BusShiftForm(forms.ModelForm): + + class Meta: + model = BusShift + fields = ['bus', 'driver'] diff --git a/padam_django/apps/transport/migrations/0001_initial.py b/padam_django/apps/transport/migrations/0001_initial.py new file mode 100644 index 00000000..6ebcb466 --- /dev/null +++ b/padam_django/apps/transport/migrations/0001_initial.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.16 on 2025-12-06 18:01 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + 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')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('status', models.CharField(choices=[('draft', 'Brouillon'), ('planned', 'Planifié'), ('ongoing', 'En cours'), ('completed', 'Terminé'), ('cancelled', 'Annulé')], db_index=True, default='draft', max_length=20)), + ('bus', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='shifts', to='fleet.bus')), + ('driver', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='shifts', to='fleet.driver')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='BusStop', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('time', models.DateTimeField()), + ('order', models.PositiveIntegerField(help_text="Ordre de l'arrêt dans le trajet")), + ('place', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='geography.place')), + ('shift', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stops', to='transport.busshift')), + ], + options={ + 'ordering': ['order'], + }, + ), + migrations.AddConstraint( + model_name='busstop', + constraint=models.UniqueConstraint(fields=('shift', 'order'), name='unique_stop_order_per_shift'), + ), + ] diff --git a/padam_django/apps/transport/migrations/0002_remove_busstop_unique_stop_order_per_shift_and_more.py b/padam_django/apps/transport/migrations/0002_remove_busstop_unique_stop_order_per_shift_and_more.py new file mode 100644 index 00000000..2782851d --- /dev/null +++ b/padam_django/apps/transport/migrations/0002_remove_busstop_unique_stop_order_per_shift_and_more.py @@ -0,0 +1,45 @@ +# Generated by Django 4.2.16 on 2025-12-06 18:26 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0002_auto_20211109_1456'), + ('geography', '0001_initial'), + ('transport', '0001_initial'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='busstop', + name='unique_stop_order_per_shift', + ), + migrations.AlterField( + model_name='busshift', + name='bus', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fleet.bus'), + ), + migrations.AlterField( + model_name='busshift', + name='driver', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fleet.driver'), + ), + migrations.AlterField( + model_name='busshift', + name='status', + field=models.CharField(choices=[('brouillon', 'Brouillon'), ('planned', 'Prévu'), ('done', 'Terminé')], default='brouillon', max_length=20), + ), + migrations.AlterField( + model_name='busstop', + name='order', + field=models.PositiveIntegerField(default=1), + ), + migrations.AlterField( + model_name='busstop', + name='place', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='geography.place'), + ), + ] diff --git a/padam_django/apps/transport/migrations/0003_alter_busshift_status.py b/padam_django/apps/transport/migrations/0003_alter_busshift_status.py new file mode 100644 index 00000000..951fd1ab --- /dev/null +++ b/padam_django/apps/transport/migrations/0003_alter_busshift_status.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2025-12-06 18:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('transport', '0002_remove_busstop_unique_stop_order_per_shift_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='busshift', + name='status', + field=models.CharField(choices=[('brouillon', 'Brouillon'), ('prévu', 'Prévu'), ('terminé', 'Terminé')], default='brouillon', max_length=20), + ), + ] diff --git a/padam_django/apps/transport/migrations/0004_rename_shift_busstop_bus_shift_and_more.py b/padam_django/apps/transport/migrations/0004_rename_shift_busstop_bus_shift_and_more.py new file mode 100644 index 00000000..3eda6c30 --- /dev/null +++ b/padam_django/apps/transport/migrations/0004_rename_shift_busstop_bus_shift_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.16 on 2025-12-06 19:24 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('transport', '0003_alter_busshift_status'), + ] + + operations = [ + migrations.RenameField( + model_name='busstop', + old_name='shift', + new_name='bus_shift', + ), + migrations.RemoveField( + model_name='busstop', + name='created_at', + ), + migrations.RemoveField( + model_name='busstop', + name='updated_at', + ), + migrations.AlterField( + model_name='busstop', + name='time', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/padam_django/apps/transport/migrations/0005_rename_bus_shift_busstop_shift_busshift_duration_and_more.py b/padam_django/apps/transport/migrations/0005_rename_bus_shift_busstop_shift_busshift_duration_and_more.py new file mode 100644 index 00000000..82bd66ab --- /dev/null +++ b/padam_django/apps/transport/migrations/0005_rename_bus_shift_busstop_shift_busshift_duration_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 4.2.16 on 2025-12-07 05:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('transport', '0004_rename_shift_busstop_bus_shift_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='busstop', + old_name='bus_shift', + new_name='shift', + ), + migrations.AddField( + model_name='busshift', + name='duration', + field=models.DurationField(blank=True, null=True), + ), + migrations.AddField( + model_name='busshift', + name='end_time', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='busshift', + name='start_time', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='busshift', + name='status', + field=models.CharField(choices=[('draft', 'Brouillon'), ('planned', 'Prévu'), ('done', 'Terminé')], default='draft', max_length=20), + ), + migrations.AlterField( + model_name='busstop', + name='order', + field=models.PositiveIntegerField(), + ), + migrations.AlterField( + model_name='busstop', + name='time', + field=models.DateTimeField(), + ), + ] diff --git a/padam_django/apps/transport/migrations/0006_alter_busshift_bus_alter_busshift_driver_and_more.py b/padam_django/apps/transport/migrations/0006_alter_busshift_bus_alter_busshift_driver_and_more.py new file mode 100644 index 00000000..855409a0 --- /dev/null +++ b/padam_django/apps/transport/migrations/0006_alter_busshift_bus_alter_busshift_driver_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.16 on 2025-12-07 05:58 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('geography', '0001_initial'), + ('fleet', '0002_auto_20211109_1456'), + ('transport', '0005_rename_bus_shift_busstop_shift_busshift_duration_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='busshift', + name='bus', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='shifts', to='fleet.bus'), + ), + migrations.AlterField( + model_name='busshift', + name='driver', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='shifts', to='fleet.driver'), + ), + migrations.AlterField( + model_name='busshift', + name='status', + field=models.CharField(choices=[('Brouillon', 'Brouillon'), ('Prévu', 'Prévu'), ('Terminé', 'Terminé')], default='Brouillon', max_length=20), + ), + migrations.AlterField( + model_name='busstop', + name='place', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='geography.place'), + ), + ] diff --git a/padam_django/apps/transport/migrations/0007_remove_busshift_created_at_and_more.py b/padam_django/apps/transport/migrations/0007_remove_busshift_created_at_and_more.py new file mode 100644 index 00000000..31284720 --- /dev/null +++ b/padam_django/apps/transport/migrations/0007_remove_busshift_created_at_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.16 on 2025-12-07 08:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('transport', '0006_alter_busshift_bus_alter_busshift_driver_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='busshift', + name='created_at', + ), + migrations.RemoveField( + model_name='busshift', + name='updated_at', + ), + migrations.AlterField( + model_name='busshift', + name='status', + field=models.CharField(default='Brouillon', max_length=20), + ), + ] diff --git a/padam_django/apps/transport/migrations/0008_busstop_unique_stop_order_per_shift.py b/padam_django/apps/transport/migrations/0008_busstop_unique_stop_order_per_shift.py new file mode 100644 index 00000000..e157670b --- /dev/null +++ b/padam_django/apps/transport/migrations/0008_busstop_unique_stop_order_per_shift.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.16 on 2025-12-07 08:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('transport', '0007_remove_busshift_created_at_and_more'), + ] + + operations = [ + migrations.AddConstraint( + model_name='busstop', + constraint=models.UniqueConstraint(fields=('shift', 'order'), name='unique_stop_order_per_shift'), + ), + ] diff --git a/padam_django/apps/transport/migrations/0009_remove_busshift_status.py b/padam_django/apps/transport/migrations/0009_remove_busshift_status.py new file mode 100644 index 00000000..8077a88b --- /dev/null +++ b/padam_django/apps/transport/migrations/0009_remove_busshift_status.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.16 on 2025-12-10 03:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('transport', '0008_busstop_unique_stop_order_per_shift'), + ] + + operations = [ + migrations.RemoveField( + model_name='busshift', + name='status', + ), + ] diff --git a/padam_django/apps/transport/migrations/__init__.py b/padam_django/apps/transport/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/transport/models.py b/padam_django/apps/transport/models.py new file mode 100644 index 00000000..ac4f4fe9 --- /dev/null +++ b/padam_django/apps/transport/models.py @@ -0,0 +1,20 @@ +from django.db import models + +class BusShift(models.Model): + bus = models.ForeignKey('fleet.Bus', on_delete=models.PROTECT, related_name='shifts') + driver = models.ForeignKey('fleet.Driver', on_delete=models.PROTECT, related_name='shifts') + start_time = models.DateTimeField(null=True, blank=True) + end_time = models.DateTimeField(null=True, blank=True) + duration = models.DurationField(null=True, blank=True) + +class BusStop(models.Model): + shift = models.ForeignKey(BusShift, on_delete=models.CASCADE, related_name='stops') + place = models.ForeignKey('geography.Place', on_delete=models.PROTECT) + time = models.DateTimeField() + order = models.PositiveIntegerField() + + class Meta: + ordering = ['order'] + constraints = [ + models.UniqueConstraint(fields=['shift', 'order'], name='unique_stop_order_per_shift') + ] diff --git a/padam_django/apps/transport/tests/__init__.py b/padam_django/apps/transport/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/padam_django/apps/transport/tests/test_bus_shift_service.py b/padam_django/apps/transport/tests/test_bus_shift_service.py new file mode 100644 index 00000000..f40cf80f --- /dev/null +++ b/padam_django/apps/transport/tests/test_bus_shift_service.py @@ -0,0 +1,149 @@ +from django.test import TestCase +from django.core.exceptions import ValidationError +from datetime import timedelta +from django.utils import timezone + +from padam_django.apps.transport.models import BusShift, BusStop +from padam_django.apps.fleet.models import Bus, Driver +from padam_django.apps.geography.models import Place +from padam_django.apps.users.models import User + +from ..bus_shift_service import update_shift_times + + +class UpdateShiftTimesTest(TestCase): + + def setUp(self): + # Création d’un User obligatoire pour Driver + self.user = User.objects.create(username="driver1") + + # Driver valide + self.driver = Driver.objects.create(user=self.user) + + # Bus valide + self.bus = Bus.objects.create(licence_plate="ABC123") + + # Places valides (avec coordonnées obligatoires) + self.placeA = Place.objects.create(name="A", latitude=48.8566, longitude=2.3522) + self.placeB = Place.objects.create(name="B", latitude=48.8570, longitude=2.3530) + self.placeC = Place.objects.create(name="C", latitude=48.8575, longitude=2.3540) + self.placeD = Place.objects.create(name="D", latitude=48.8580, longitude=2.3550) + + def test_less_than_two_stops_raises_error(self): + shift = BusShift.objects.create(bus=self.bus, driver=self.driver) + + BusStop.objects.create( + shift=shift, + place=self.placeA, + time=timezone.now(), + order=1 + ) + + with self.assertRaises(ValidationError): + update_shift_times(shift) + + def test_correct_start_end_duration_with_inverted_order(self): + """ + Test que update_shift_times calcule correctement start/end/duration + avec des stops correctement ordonnés. + """ + shift = BusShift.objects.create(bus=self.bus, driver=self.driver) + now = timezone.now() # datetime aware + + stop1 = BusStop.objects.create( + shift=shift, + place=self.placeA, + time=now, + order=1 # premier stop + ) + + stop2 = BusStop.objects.create( + shift=shift, + place=self.placeB, + time=now + timedelta(minutes=30), + order=2 # deuxième stop + ) + + update_shift_times(shift) + shift.refresh_from_db() + + self.assertEqual(shift.start_time, stop1.time) + self.assertEqual(shift.end_time, stop2.time) + self.assertEqual(shift.duration, stop2.time - stop1.time) + + + def test_correct_start_end_duration(self): + """ + Test standard avec stops correctement ordonnés. + """ + shift = BusShift.objects.create(bus=self.bus, driver=self.driver) + now = timezone.now() + + stop1 = BusStop.objects.create( + shift=shift, + place=self.placeA, + time=now, + order=1 + ) + + stop2 = BusStop.objects.create( + shift=shift, + place=self.placeB, + time=now + timedelta(minutes=30), + order=2 + ) + + update_shift_times(shift) + shift.refresh_from_db() + + self.assertEqual(shift.start_time, stop1.time) + self.assertEqual(shift.end_time, stop2.time) + self.assertEqual(shift.duration, stop2.time - stop1.time) + + def test_overlapping_shift_raises_error(self): + """ + Test pour vérifier que deux shifts qui se chevauchent déclenchent ValidationError. + """ + now = timezone.now() + + shift1 = BusShift.objects.create(bus=self.bus, driver=self.driver) + BusStop.objects.create(shift=shift1, place=self.placeA, time=now, order=1) + BusStop.objects.create(shift=shift1, place=self.placeB, time=now + timedelta(minutes=30), order=2) + + shift2 = BusShift.objects.create(bus=self.bus, driver=self.driver) + BusStop.objects.create(shift=shift2, place=self.placeC, time=now + timedelta(minutes=15), order=1) + BusStop.objects.create(shift=shift2, place=self.placeD, time=now + timedelta(minutes=45), order=2) + + # shift1 est valide + update_shift_times(shift1) + + # shift2 chevauche → doit lever une erreur + with self.assertRaises(ValidationError): + update_shift_times(shift2) + + def test_zero_duration_raises_error(self): + """ + Vérifie qu'un trajet avec deux arrêts au même moment + lève ValidationError (duration = 0 interdit). + """ + shift = BusShift.objects.create(bus=self.bus, driver=self.driver) + now = timezone.now() + + BusStop.objects.create( + shift=shift, + place=self.placeA, + time=now, + order=1 + ) + + BusStop.objects.create( + shift=shift, + place=self.placeB, + time=now, # même datetime + order=2 + ) + + with self.assertRaises(ValidationError) as cm: + update_shift_times(shift) + + self.assertIn("La fin doit être après le début", str(cm.exception)) \ No newline at end of file diff --git a/padam_django/apps/users/admin.py b/padam_django/apps/users/admin.py index 2bc531c6..519d3da3 100644 --- a/padam_django/apps/users/admin.py +++ b/padam_django/apps/users/admin.py @@ -9,5 +9,6 @@ class UserAdmin(admin.ModelAdmin): def is_driver(self, obj): return obj.is_driver + is_driver.boolean = True is_driver.short_description = 'Is driver' diff --git a/padam_django/apps/users/management/commands/create_test_user.py b/padam_django/apps/users/management/commands/create_test_user.py new file mode 100644 index 00000000..536dc529 --- /dev/null +++ b/padam_django/apps/users/management/commands/create_test_user.py @@ -0,0 +1,23 @@ +# padam_django/apps/users/management/commands/create_test_user.py +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model +from django.apps import apps + +User = get_user_model() + +class Command(BaseCommand): + help = "Créer un compte test pour l'évaluateur avec mot de passe connu" + + def handle(self, *args, **kwargs): + username = "andreechretien" + password = "test12356" + + user, created = User.objects.get_or_create(username=username) + user.set_password(password) + user.is_staff = True # pour avoir accès à l'admin + user.save() + + if created: + self.stdout.write(self.style.SUCCESS(f"Compte {username} créé avec succès.")) + else: + self.stdout.write(self.style.SUCCESS(f"Mot de passe pour {username} mis à jour.")) diff --git a/padam_django/apps/users/management/commands/update_non_drivers.py b/padam_django/apps/users/management/commands/update_non_drivers.py new file mode 100644 index 00000000..6d8c3399 --- /dev/null +++ b/padam_django/apps/users/management/commands/update_non_drivers.py @@ -0,0 +1,41 @@ +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.apps import apps + +User = get_user_model() + +class Command(BaseCommand): + help = "Met à jour tous les utilisateurs non-driver pour qu'ils puissent accéder aux BusShift dans l'admin" + + def handle(self, *args, **options): + + # Récupérer les permissions add/change/view + busshift_perms = Permission.objects.filter( + content_type__app_label='transport', + content_type__model='busshift', + codename__in=['add_busshift', 'change_busshift', 'view_busshift'] + ) + + + # Permissions BusStop + busstop_perms = Permission.objects.filter( + content_type__app_label='transport', + content_type__model='busstop', + codename__in=['add_busstop', 'change_busstop', 'view_busstop'] + ) + + # Filtrer les utilisateurs non drivers + non_drivers = User.objects.filter(driver__isnull=True) + count = 0 + + for user in non_drivers: + user.is_staff = True + user.user_permissions.add(*busshift_perms) + user.user_permissions.add(*busstop_perms) + user.save() + count += 1 + + self.stdout.write(self.style.SUCCESS( + f"{count} utilisateurs non-driver mis à jour avec is_staff=True et permissions BusShift." + )) diff --git a/padam_django/apps/users/models.py b/padam_django/apps/users/models.py index 672f6a15..73309e08 100644 --- a/padam_django/apps/users/models.py +++ b/padam_django/apps/users/models.py @@ -7,3 +7,4 @@ class User(AbstractUser): def is_driver(self) -> bool: """Define if the user is related to a driver.""" return hasattr(self, 'driver') + diff --git a/padam_django/settings.py b/padam_django/settings.py index 129e922c..6f9bba6d 100644 --- a/padam_django/settings.py +++ b/padam_django/settings.py @@ -10,8 +10,11 @@ https://docs.djangoproject.com/en/3.2/ref/settings/ """ +import os from pathlib import Path +from decouple import config + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -20,7 +23,7 @@ # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-&r2)+_fdqxe2dtc@1vizr6tsh6!1cesaptlfgj@ug*%3=fnq=i' +SECRET_KEY = "django-insecure-&r2)+_fdqxe2dtc@1vizr6tsh6!1cesaptlfgj@ug*%3=fnq=i" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -45,6 +48,7 @@ 'padam_django.apps.fleet', 'padam_django.apps.geography', 'padam_django.apps.users', + 'padam_django.apps.transport', ] MIDDLEWARE = [ @@ -82,9 +86,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": config("DB_NAME"), + "USER": config("DB_USERNAME"), + "PASSWORD": config("DB_PASSWORD"), + "HOST": config("DB_HOSTNAME"), + "PORT": config("DB_PORT", cast=int), } } @@ -132,6 +140,7 @@ STATIC_URL = '/static/' +STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') # Default primary key field type # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..9ca2bc57 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[tool.black] +line-length = 88 +target-version = ['py311'] +include = '\.pyi?$' + +[tool.isort] +profile = 'django' +combine_as_imports = true +include_trailing_comma = true +line_length = 88 +multi_line_output = 3 +known_first_party = ['config'] +known_third_party = ['django', 'rest_framework', 'geopy', 'oauth2_provider'] +force_sort_within_sections = true +force_alphabetical_sort_within_sections = true +order_by_type = false + +[tool.flake8] +max-line-length = 88 +exclude = '.git,__pycache__,env,venv,.venv,.mypy_cache,.pytest_cache' diff --git a/requirements.txt b/requirements.txt index 863fd63d..b570384b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,9 @@ ipython==8.29.0 factory-boy==3.2.0 Faker==8.10.1 +python-decouple==3.6 +psycopg2-binary + +# Outils pour Django et tests +pytest +pytest-django