Browse Source

Idem last commit

master
scayac 1 month ago
parent
commit
44281843e4
  1. 3
      .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

3
.gitignore vendored

@ -8,4 +8,5 @@ __pycache__ @@ -8,4 +8,5 @@ __pycache__
__init__.py
*.sqlite3
*.crt
*.key
*.key
conf.env

28
README.md

@ -13,7 +13,7 @@ Application Django pour l’enregistrement des temps de courses des coureurs. @@ -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. @@ -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"
- Import en masse des coureurs dans la rubrique "Coureurs" via CSV ou depuis export Pronote

8
crossapp/settings.py

@ -1,4 +1,5 @@ @@ -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', '') @@ -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(',')

97
main/admin.py

@ -1,4 +1,8 @@ @@ -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 @@ -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): @@ -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): @@ -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})

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

@ -0,0 +1,28 @@ @@ -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 @@ @@ -1,7 +1,31 @@
{% 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 }}
<li>
<a href="import-csv/" class="addlink">Importer CSV</a>
</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 %}

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

@ -0,0 +1,9 @@ @@ -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