diff --git a/.gitignore b/.gitignore index 93789e4..33480ed 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ __pycache__ __init__.py *.sqlite3 *.crt -*.key \ No newline at end of file +*.key +conf.env \ No newline at end of file diff --git a/README.md b/README.md index c619d14..0a76a3e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Application Django pour l’enregistrement des temps de courses des coureurs. 1. Créez et activez un environnement virtuel Python 2. Installez les dépendances : ```bash - pip install django channels reportlab qrcode pandas + pip install -r requirements.txt ``` 3. Appliquez les migrations : ```bash @@ -26,13 +26,35 @@ Application Django pour l’enregistrement des temps de courses des coureurs. ``` 5. Lancez le serveur : ```bash - python manage.py runserver + python manage.py runserver 0.0.0.0:8000 ``` 6. Accès à l'interface admin depuis : ```bash https://USER:PORT/admin ``` +## Tester en HTTPS en local avec Daphne + +Depuis la racine du projet, après avoir activé l'environnement virtuel : + +1. Générez un certificat autosigné pour `localhost` : + ```bash + mkdir -p certs + openssl req -x509 -newkey rsa:2048 -nodes -keyout certs/localhost.key -out certs/localhost.crt -days 365 -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" + ``` +2. Si vous utilisez la connexion ou l'administration, ajoutez cette origine dans `conf.env` afin que la protection CSRF accepte les requêtes HTTPS : + ```env + DJANGO_CSRF_TRUSTED_ORIGINS=https://localhost,https://127.0.0.1 + ``` +3. Collectez les fichiers statiques puis lancez Daphne avec TLS : + ```bash + python manage.py collectstatic --noinput + daphne -e ssl:8443:interface=0.0.0.0:privateKey=certs/localhost.key:certKey=certs/localhost.crt crossapp.asgi:application + ``` +4. Ouvrez [https://localhost:8443](https://localhost:8443). Le navigateur affichera un avertissement car le certificat est autosigné ; c'est attendu pour un test local. + +Les certificats sont ignorés par Git. Ce certificat convient à `localhost` et `127.0.0.1` ; pour tester depuis un autre appareil ou avec une adresse IP différente, générez un certificat contenant cette adresse dans `subjectAltName` et ajoutez son origine HTTPS à `DJANGO_CSRF_TRUSTED_ORIGINS`. + ## Fonctionnalités interface admin - Gestion des coureurs (ajout, modification, suppression) -- Import en masse des coureurs via CSV dans la rubrique "Coureurs" \ No newline at end of file +- Import en masse des coureurs dans la rubrique "Coureurs" via CSV ou depuis export Pronote \ No newline at end of file diff --git a/crossapp/settings.py b/crossapp/settings.py index 2c06554..29e4c84 100644 --- a/crossapp/settings.py +++ b/crossapp/settings.py @@ -1,4 +1,5 @@ from pathlib import Path +import ipaddress import os # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -26,7 +27,12 @@ OPENAI_API_KEY = os.getenv('OPENAI_API_KEY', '') # SECURITY WARNING: don't run with debug turned on in production! DEBUG = os.getenv('DJANGO_DEBUG', 'True') == 'True' -ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') +ALLOWED_HOSTS = [] +for host in os.getenv('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1').split(','): + if '/' in host: + ALLOWED_HOSTS.extend(str(address) for address in ipaddress.ip_network(host, strict=False).hosts()) + else: + ALLOWED_HOSTS.append(host) CSRF_TRUSTED_ORIGINS = os.getenv('DJANGO_CSRF_TRUSTED_ORIGINS', 'http://localhost,http://127.0.0.1').split(',') diff --git a/main/admin.py b/main/admin.py index 3d4c705..a6d702f 100644 --- a/main/admin.py +++ b/main/admin.py @@ -1,4 +1,8 @@ +import csv +import io +import unicodedata + from django.contrib import admin, messages from django.urls import path from django.shortcuts import render, redirect @@ -6,6 +10,35 @@ from django import forms from .models import Course, Arrivee, Coureur +def _normalize_csv_header(header): + return ''.join( + character for character in unicodedata.normalize('NFKD', header).encode('ascii', 'ignore').decode('ascii').lower() + if character.isalnum() + ) + + +def _pronote_csv_reader(content): + first_line = content.splitlines()[0] if content.splitlines() else '' + try: + dialect = csv.Sniffer().sniff(first_line, delimiters='\t;,') + delimiter = dialect.delimiter + except csv.Error: + delimiter = max(('\t', ';', ','), key=first_line.count) + + reader = csv.reader(io.StringIO(content, newline=''), delimiter=delimiter) + headers = next(reader, []) + columns = {_normalize_csv_header(header): index for index, header in enumerate(headers)} + return reader, columns + + +def _split_pronote_student(student): + parts = student.split() + for index, part in enumerate(parts): + if any(character.islower() for character in part): + return ' '.join(parts[:index]), ' '.join(parts[index:]) + return None, None + + @admin.register(Course) class CourseAdmin(admin.ModelAdmin): list_display = ('nom', 'date', 'type', 'owner', 'depart', 'fin') @@ -33,9 +66,28 @@ class CoureurAdmin(admin.ModelAdmin): urls = super().get_urls() custom_urls = [ path('import-csv/', self.admin_site.admin_view(self.import_csv), name='main_coureur_import_csv'), + path('import-pronote/', self.admin_site.admin_view(self.import_pronote), name='main_coureur_import_pronote'), + path('clear-all/', self.admin_site.admin_view(self.clear_all), name='main_coureur_clear_all'), ] return custom_urls + urls + def clear_all(self, request): + if request.method == 'POST': + coureur_count = Coureur.objects.count() + arrivee_count = Arrivee.objects.count() + Coureur.objects.all().delete() + self.message_user( + request, + f"Suppression terminée : {coureur_count} coureur(s) et {arrivee_count} arrivée(s) supprimé(s).", + messages.SUCCESS, + ) + return redirect('..') + + return render(request, "admin/main/clear_coureurs.html", { + "coureur_count": Coureur.objects.count(), + "arrivee_count": Arrivee.objects.count(), + }) + class CsvImportForm(forms.Form): csv_file = forms.FileField(label="Fichier CSV (nom;prenom;classe)") @@ -62,3 +114,48 @@ class CoureurAdmin(admin.ModelAdmin): else: form = self.CsvImportForm() return render(request, "admin/main/import_csv.html", {"form": form}) + + class PronoteImportForm(forms.Form): + csv_file = forms.FileField(label="Fichier généré via client Pronote (.txt ou .csv)") + + def clean_csv_file(self): + csv_file = self.cleaned_data['csv_file'] + if not csv_file.name.lower().endswith(('.txt', '.csv')): + raise forms.ValidationError("Le fichier doit être au format .txt ou .csv.") + return csv_file + + def import_pronote(self, request): + form = self.PronoteImportForm(request.POST or None, request.FILES or None) + if request.method == 'POST' and form.is_valid(): + try: + content = form.cleaned_data['csv_file'].read().decode('utf-8-sig') + reader, columns = _pronote_csv_reader(content) + except (UnicodeDecodeError, csv.Error, IndexError): + form.add_error('csv_file', "Le fichier n'a pas pu être lu comme un export CSV Pronote UTF-8.") + else: + if 'eleve' not in columns or 'classe' not in columns: + form.add_error('csv_file', "Les colonnes « Élève » et « Classe » sont obligatoires.") + else: + imported = 0 + ignored = 0 + for row in reader: + if max(columns['eleve'], columns['classe']) >= len(row): + ignored += 1 + continue + nom, prenom = _split_pronote_student(row[columns['eleve']].strip()) + classe = row[columns['classe']].strip().replace(' ', '') + if not nom or not prenom or not classe: + ignored += 1 + continue + if Coureur.objects.filter(nom=nom, prenom=prenom, classe=classe).exists(): + ignored += 1 + continue + Coureur.objects.create(nom=nom, prenom=prenom, classe=classe) + imported += 1 + self.message_user( + request, + f"Import Pronote terminé : {imported} ajoutés, {ignored} ignorés (doublons ou lignes invalides).", + messages.SUCCESS, + ) + return redirect('..') + return render(request, "admin/main/import_pronote.html", {"form": form}) diff --git a/main/templates/admin/main/clear_coureurs.html b/main/templates/admin/main/clear_coureurs.html new file mode 100644 index 0000000..ecad17d --- /dev/null +++ b/main/templates/admin/main/clear_coureurs.html @@ -0,0 +1,28 @@ +{% extends "admin/base_site.html" %} +{% block extrastyle %} +{{ block.super }} + +{% endblock %} +{% block content %} +
Confirmez-vous la suppression de {{ coureur_count }} coureur(s) ?
+Cette action supprimera également {{ arrivee_count }} arrivée(s) associée(s). Elle est irréversible.
+ +{% endblock %} \ No newline at end of file diff --git a/main/templates/admin/main/coureur_changelist.html b/main/templates/admin/main/coureur_changelist.html index 02a475f..83f4e99 100644 --- a/main/templates/admin/main/coureur_changelist.html +++ b/main/templates/admin/main/coureur_changelist.html @@ -1,7 +1,31 @@ {% extends "admin/change_list.html" %} -{% block object-tools %} +{% block extrastyle %} +{{ block.super }} + +{% endblock %} +{% block object-tools-items %} {{ block.super }}