Browse Source

Idem last commit

master
scayac 1 month ago
parent
commit
44281843e4
  1. 1
      .gitignore
  2. 28
      README.md
  3. 8
      crossapp/settings.py
  4. 97
      main/admin.py
  5. 28
      main/templates/admin/main/clear_coureurs.html
  6. 26
      main/templates/admin/main/coureur_changelist.html
  7. 9
      main/templates/admin/main/import_pronote.html

1
.gitignore vendored

@ -9,3 +9,4 @@ __init__.py
*.sqlite3 *.sqlite3
*.crt *.crt
*.key *.key
conf.env

28
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 1. Créez et activez un environnement virtuel Python
2. Installez les dépendances : 2. Installez les dépendances :
```bash ```bash
pip install django channels reportlab qrcode pandas pip install -r requirements.txt
``` ```
3. Appliquez les migrations : 3. Appliquez les migrations :
```bash ```bash
@ -26,13 +26,35 @@ Application Django pour l’enregistrement des temps de courses des coureurs.
``` ```
5. Lancez le serveur : 5. Lancez le serveur :
```bash ```bash
python manage.py runserver python manage.py runserver 0.0.0.0:8000
``` ```
6. Accès à l'interface admin depuis : 6. Accès à l'interface admin depuis :
```bash ```bash
https://USER:PORT/admin 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 ## Fonctionnalités interface admin
- Gestion des coureurs (ajout, modification, suppression) - Gestion des coureurs (ajout, modification, suppression)
- Import en masse des coureurs via CSV dans la rubrique "Coureurs" - Import en masse des coureurs dans la rubrique "Coureurs" via CSV ou depuis export Pronote

8
crossapp/settings.py

@ -1,4 +1,5 @@
from pathlib import Path from pathlib import Path
import ipaddress
import os import os
# Build paths inside the project like this: BASE_DIR / 'subdir'. # 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! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.getenv('DJANGO_DEBUG', 'True') == 'True' 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(',') CSRF_TRUSTED_ORIGINS = os.getenv('DJANGO_CSRF_TRUSTED_ORIGINS', 'http://localhost,http://127.0.0.1').split(',')

97
main/admin.py

@ -1,4 +1,8 @@
import csv
import io
import unicodedata
from django.contrib import admin, messages from django.contrib import admin, messages
from django.urls import path from django.urls import path
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
@ -6,6 +10,35 @@ from django import forms
from .models import Course, Arrivee, Coureur 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) @admin.register(Course)
class CourseAdmin(admin.ModelAdmin): class CourseAdmin(admin.ModelAdmin):
list_display = ('nom', 'date', 'type', 'owner', 'depart', 'fin') list_display = ('nom', 'date', 'type', 'owner', 'depart', 'fin')
@ -33,9 +66,28 @@ class CoureurAdmin(admin.ModelAdmin):
urls = super().get_urls() urls = super().get_urls()
custom_urls = [ custom_urls = [
path('import-csv/', self.admin_site.admin_view(self.import_csv), name='main_coureur_import_csv'), 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 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): class CsvImportForm(forms.Form):
csv_file = forms.FileField(label="Fichier CSV (nom;prenom;classe)") csv_file = forms.FileField(label="Fichier CSV (nom;prenom;classe)")
@ -62,3 +114,48 @@ class CoureurAdmin(admin.ModelAdmin):
else: else:
form = self.CsvImportForm() form = self.CsvImportForm()
return render(request, "admin/main/import_csv.html", {"form": form}) 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})

28
main/templates/admin/main/clear_coureurs.html

@ -0,0 +1,28 @@
{% extends "admin/base_site.html" %}
{% block extrastyle %}
{{ block.super }}
<style>
.clear-all-button {
background-color: #c0392b !important;
border: 1px solid #a93226 !important;
border-radius: 4px;
color: #fff !important;
display: inline-block;
padding: 8px 12px;
}
.clear-all-button:hover,
.clear-all-button:focus {
background-color: #a93226 !important;
color: #fff !important;
}
</style>
{% endblock %}
{% block content %}
<h1>Vider tous les coureurs</h1>
<p>Confirmez-vous la suppression de {{ coureur_count }} coureur(s) ?</p>
<p>Cette action supprimera également {{ arrivee_count }} arrivée(s) associée(s). Elle est irréversible.</p>
<form method="post">{% csrf_token %}
<button type="submit" class="clear-all-button">Oui, supprimer tous les coureurs</button>
<a href=".." class="button">Annuler</a>
</form>
{% endblock %}

26
main/templates/admin/main/coureur_changelist.html

@ -1,7 +1,31 @@
{% extends "admin/change_list.html" %} {% extends "admin/change_list.html" %}
{% block object-tools %} {% block extrastyle %}
{{ block.super }}
<style>
.clear-all-button {
background-color: #c0392b !important;
border: 1px solid #a93226 !important;
border-radius: 4px;
color: #fff !important;
display: inline-block;
padding: 8px 12px;
}
.clear-all-button:hover,
.clear-all-button:focus {
background-color: #a93226 !important;
color: #fff !important;
}
</style>
{% endblock %}
{% block object-tools-items %}
{{ block.super }} {{ block.super }}
<li> <li>
<a href="import-csv/" class="addlink">Importer CSV</a> <a href="import-csv/" class="addlink">Importer CSV</a>
</li> </li>
<li>
<a href="import-pronote/" class="addlink">Import csv depuis client Pronote</a>
</li>
<li>
<a href="clear-all/" class="clear-all-button">Vider tous les coureurs</a>
</li>
{% endblock %} {% endblock %}

9
main/templates/admin/main/import_pronote.html

@ -0,0 +1,9 @@
{% extends "admin/base_site.html" %}
{% block content %}
<h1>Import csv depuis client Pronote</h1>
<form method="post" enctype="multipart/form-data">{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="default">Importer</button>
<a href=".." class="button">Annuler</a>
</form>
{% endblock %}
Loading…
Cancel
Save