From 4fd1c14d1d365328dd83826d351b58e8d72be6d0 Mon Sep 17 00:00:00 2001 From: Louis Mazin Date: Thu, 15 Oct 2026 16:45:02 +0200 Subject: [PATCH] test --- .env.example | 40 +++ README.md | 82 +++--- backend/common/__init__.py | 0 backend/common/admin_api.py | 151 +++++++++++ backend/common/apps.py | 6 + backend/common/cms_api.py | 179 +++++++++++++ backend/common/cms_urls.py | 14 + backend/common/management/__init__.py | 0 .../common/management/commands/__init__.py | 0 .../management/commands/ensure_admin.py | 29 +++ backend/common/migrations/0001_initial.py | 34 +++ .../migrations/0002_import_legacy_content.py | 13 + backend/common/migrations/__init__.py | 0 backend/common/models.py | 46 ++++ backend/common/tests.py | 65 +++++ backend/common/tests_cms.py | 44 ++++ backend/common/urls.py | 15 ++ backend/common/views.py | 9 + backend/core/origins.py | 31 +++ backend/core/settings.py | 240 ++++++++---------- backend/core/urls.py | 11 +- backend/education/editor_views.py | 63 +---- backend/education/tests.py | 29 +-- backend/education/urls.py | 23 +- backend/entrypoint.sh | 11 + backend/requirements.txt | 14 +- frontend/src/App.jsx | 17 +- frontend/src/components/NavBar.jsx | 19 +- frontend/src/components/cms/CmsBlocks.jsx | 22 ++ frontend/src/components/cms/CmsFields.jsx | 53 ++++ frontend/src/components/cms/CmsManager.jsx | 165 ++++++++++++ frontend/src/components/cms/CmsTheme.jsx | 31 +++ frontend/src/components/cms/CmsUsers.jsx | 38 +++ frontend/src/context/EditorContext.jsx | 124 ++++----- frontend/src/index.css | 27 ++ frontend/src/pages/editor/AdminPage.jsx | 24 +- frontend/src/pages/education/ContactPage.jsx | 11 +- frontend/src/pages/education/OfferPage.jsx | 5 +- .../src/pages/education/ResourcesPage.jsx | 9 +- frontend/src/pages/education/ReviewsPage.jsx | 12 +- frontend/src/services/adminService.js | 7 + frontend/src/services/apiClient.js | 49 ++++ frontend/src/services/authService.js | 7 + frontend/src/services/cmsService.js | 21 ++ frontend/src/services/educationService.js | 13 + frontend/vite.config.js | 65 +++-- start.sh | 101 +++++--- 47 files changed, 1524 insertions(+), 445 deletions(-) create mode 100644 .env.example create mode 100644 backend/common/__init__.py create mode 100644 backend/common/admin_api.py create mode 100644 backend/common/apps.py create mode 100644 backend/common/cms_api.py create mode 100644 backend/common/cms_urls.py create mode 100644 backend/common/management/__init__.py create mode 100644 backend/common/management/commands/__init__.py create mode 100644 backend/common/management/commands/ensure_admin.py create mode 100644 backend/common/migrations/0001_initial.py create mode 100644 backend/common/migrations/0002_import_legacy_content.py create mode 100644 backend/common/migrations/__init__.py create mode 100644 backend/common/models.py create mode 100644 backend/common/tests.py create mode 100644 backend/common/tests_cms.py create mode 100644 backend/common/urls.py create mode 100644 backend/common/views.py create mode 100644 backend/core/origins.py create mode 100644 backend/entrypoint.sh create mode 100644 frontend/src/components/cms/CmsBlocks.jsx create mode 100644 frontend/src/components/cms/CmsFields.jsx create mode 100644 frontend/src/components/cms/CmsManager.jsx create mode 100644 frontend/src/components/cms/CmsTheme.jsx create mode 100644 frontend/src/components/cms/CmsUsers.jsx create mode 100644 frontend/src/services/adminService.js create mode 100644 frontend/src/services/apiClient.js create mode 100644 frontend/src/services/authService.js create mode 100644 frontend/src/services/cmsService.js create mode 100644 frontend/src/services/educationService.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..45b2cb9 --- /dev/null +++ b/.env.example @@ -0,0 +1,40 @@ +# MySQL (dans Docker Compose, le serveur MySQL s'appelle "db") +MYSQL_HOST=db +MYSQL_PORT=3306 +MYSQL_DATABASE=s105_AccroCBio +MYSQL_USER=u105_0kSRKdIEvn +MYSQL_PASSWORD=U0gpgwI+gN141Q9AjwBnmR!c +MYSQL_ROOT_PASSWORD=U0gpgwI+gN141Q9AjwBnmR!c + +# Django +SECRET_KEY=pizirbfparivbaepvjejnze10247y30uuzrjfcj +DEBUG=True + +# Ports directs d'écoute (dans Pterodactyl ET Docker Compose) +FRONTEND_PORT=1036 +BACKEND_PORT=1037 + +# Admin : créé au premier démarrage seulement +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 +ADMIN_FIRST_NAME=Valérie +ADMIN_LAST_NAME=Chaillou + +# Noms de domaines publics autorisés (séparés par des virgules) +ALLOWED_HOSTS=localhost,127.0.0.1,backend,https://accrocbio.louismazin.ovh +CORS_ALLOWED_ORIGINS=http://localhost,http://127.0.0.1,https://accrocbio.louismazin.ovh +CSRF_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1,https://accrocbio.louismazin.ovh +AUTO_PORT_CORS=True +AUTO_PORT_CSRF=True + +SESSION_COOKIE_SECURE=False +CSRF_COOKIE_SECURE=False + +# Notifications facultatives des formulaires (sinon messages dans /admin/). +CONTACT_NOTIFICATION_EMAIL= +DEFAULT_FROM_EMAIL=accrocbio@gmail.com +EMAIL_HOST=localhost +EMAIL_PORT=25 +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= +EMAIL_USE_TLS=False diff --git a/README.md b/README.md index f2a42af..3af1f88 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,64 @@ -# AccroCbio — site React / Django +# AccroCBio — site pédagogique sur le template commun -Refonte éditoriale du site coursbiologiechaillou.fr, basée sur l'architecture `Site`. +Site AccroCBio conservé visuellement, restructuré autour du même socle que `Template` : **Django 5 / DRF**, **React + Vite / shadcn/ui**, **MySQL 8**, **Oxlint**, services d'API distincts des composants React, authentification Django **par session avec CSRF**, administration React à `/admin/` (aucun `django-admin`). -## Stack conservée +Les pages pédagogiques d'origine restent présentes : accueil, présentation, services à la personne, crédit d'impôt, ressources et leurs sous-rubriques, avis, offre de rentrée, contact, mentions légales et confidentialité. Les ressources, témoignages, offres et messages reçus restent dans leurs modèles Django existants. -- **Frontend** : React 19, Vite, Tailwind CSS 4, composants shadcn existants, port **1028**. -- **Backend** : Django + Django REST Framework, port **1029**. -- **Base** : MySQL 8 / MariaDB compatible, identifiants via `.env`. -- **Docker** : `docker-compose.yml`, `frontend/dockerfile` et `backend/dockerfile` **inchangés**. -- **Gestion du contenu** : Django Admin (`/admin/`) pour les PDF/liens, les avis, les offres et les messages. +## Installer / migrer depuis l'ancien AccroCBio -## Mise en place +**Avant de remplacer les fichiers : sauvegardez impérativement la base MySQL et `backend/media/`.** Ce projet réutilise la base et les migrations existantes : il ne faut pas réinitialiser le volume MySQL. Le déploiement Compose reste volontairement sur **MySQL 8.0**, comme AccroCBio d'origine. -1. Utiliser **une nouvelle base de données**, distincte de celle du site de boulangerie. Ne pas pointer sur une base de production existante. -2. Copier `.env.example` vers `.env`, renseigner les secrets forts et le domaine réel (`ALLOWED_HOSTS`). -3. `docker compose up -d --build` (ou le script `start.sh` dans un conteneur Pterodactyl configuré comme auparavant). -4. `docker compose exec backend python manage.py migrate`. -5. `docker compose exec backend python manage.py createsuperuser` : il permet l'accès à `/admin/`. -6. En production, placer le frontend et `/api/` derrière nginx sous le même domaine HTTPS. Vite preview/Django runserver restent des outils de développement : pour une exposition publique durable, préférer un serveur statique et Gunicorn. +1. Appliquer les fichiers de l'archive de modifications à la racine de l'ancien AccroCBio, en conservant l'arborescence et les autres fichiers existants. +2. **Garder le `.env` réel** et les valeurs des paramètres `MYSQL_*`, `SECRET_KEY`, ports et domaines. Ne pas remplacer les mots de passe par les valeurs de `.env.example`. +3. Vérifier `ALLOWED_HOSTS`, `CORS_ALLOWED_ORIGINS`, `CSRF_TRUSTED_ORIGINS` pour le domaine public ; en HTTPS, mettre `SESSION_COOKIE_SECURE=True`, `CSRF_COOKIE_SECURE=True` et `DEBUG=False`. +4. Ajouter `FRONTEND_PORT` et `BACKEND_PORT` dans le `.env` existant si absents (exemple : `FRONTEND_PORT=1036` et `BACKEND_PORT=1037`). Lancer `docker compose up -d --build`. Le backend exécute `migrate`, `ensure_admin`, `collectstatic` et démarre Django directement sur `BACKEND_PORT` ; Vite Preview écoute sur `FRONTEND_PORT`. +5. Se connecter à `/admin/` avec un compte **staff** déjà existant (`is_staff=True`) ou le compte admin initial configuré dans `.env` lors du tout premier démarrage. Les anciens jetons JWT de l'éditeur ne servent plus : une nouvelle connexion est requise. +6. Vérifier en priorité le formulaire de contact, les ressources, les médias et la page d'édition. -Les migrations insèrent des références à quelques **PDF déjà publics** (liens externes uniquement). L'auteur des documents et son hébergement gardent leurs droits. Il faut ajouter dans l'administration les PDF manquants dont le lien n'a pas été vérifié. Aucune archive PDF tierce n'est embarquée. +**Conservation des données** : la migration `common.0002_import_legacy_content` copie les personnalisations de l'ancienne table `education.SiteContent` dans la nouvelle table CMS `common.SiteContent`, sans supprimer l'ancienne. Le modèle utilisateur personnalisé `users.User` et ses migrations sont conservés. Le dossier `./backend/media:/app/media` continue de stocker les anciennes images ; les nouveaux uploads utilisent `/api/cms/media/…`. -### Ressources administrables +**Ne pas** utiliser `docker compose down -v` lors de la mise à jour : cela effacerait les volumes de base de données. -- `GET /api/education/resources/?category=svt-premiere` -- `GET /api/education/testimonials/` -- `GET /api/education/announcements/` -- `POST /api/education/contact/` (limite anonyme : 5 par heure par adresse IP, messages visibles depuis l'admin). +## Administration « je peux tout modifier » -La notification des messages par mail est facultative et s'active via `CONTACT_NOTIFICATION_EMAIL`, `EMAIL_HOST`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`, etc. Sans SMTP, le contact **est enregistré en base** mais aucun mail n'est envoyé. +À `/admin/`, deux mécanismes se complètent : -### Pages livrées +- **Modifier directement les pages** : mode « Modifier » activé sur le site, pour les textes, liens de boutons et images marqués par des contrôles d'édition. +- **Éditeur avancé → Pages** : ajouter, publier/masquer, nommer et organiser de nouvelles pages. +- **Éditeur avancé → Navigation** : libellés, visibilité et ordre des liens internes. +- **Éditeur avancé → Blocs** : ajouter, modifier, masquer, déplacer et supprimer des titres, paragraphes, boutons ou images, y compris sur les pages historiques. +- **Éditeur avancé → Apparence** : changer les couleurs principales du thème clair/sombre sans reconstruire l'application. +- **Éditeur avancé → Textes / liens** : modifier et réinitialiser les contenus enregistrés. +- **Ressources, Avis, Offres, Messages** : garder la gestion des données métier de l'ancien AccroCBio. +- **Utilisateurs** : voir les comptes, créer des comptes (superutilisateur) et désactiver/réactiver ceux qui ne sont pas protégés. -Accueil, Présentation, Services à la personne, Avance immédiate, Ressources (SVT Seconde/Première/Terminale, BPH, SBM, STL Première/Terminale), Contact, Avis, Offre de rentrée, Mentions légales, Confidentialité. +La gestion couvre les contenus éditables et les sections ajoutées via le CMS. La création de nouveaux types de composants, la logique des formulaires et certaines zones techniques codées en dur nécessitent encore une intervention dans le code. -### Points à personnaliser avant publication +## Architecture -- Renseigner l'hébergeur et valider les mentions légales / la politique de confidentialité. -- Vérifier avec la propriétaire le droit d'utiliser le nom AccroCbio, les informations personnelles et les documents existants. -- Confirmer la validité de toute offre avant de l'activer dans Django Admin (offre de septembre 2026 désactivée par défaut). -- Définir une durée de rétention et un processus de purge pour les demandes de contact. -- Configurer HTTPS, `ALLOWED_HOSTS`, SMTP si nécessaire, sauvegardes DB et éventuel cache partagé pour le throttling si plusieurs workers. +```text +backend/ + common/ # Authentification par session, utilisateurs, CMS et migrations + core/ # Django, URLs, origines, réglages + education/ # API et modèles existants (cours, contact, ressources, avis, offres) + users/ # Modèle utilisateur et migrations existantes + media/ # Images et fichiers persistants sur l'hôte +frontend/ + src/services/ # API et services métier (aucun fetch dans les pages) + src/context/ # État de la session et de l'édition + src/components/cms/ # Éditeur, pages, blocs et thèmes + src/pages/education/ # Pages pédagogiques originales + src/pages/editor/ # Administration React intégrée +``` -## Tests +## Développement et paramètres -- Backend : `python manage.py test education` (requiert les dépendances Python et une DB de test). -- Frontend : `npm ci && npm run build`. +Les ports dans `.env.example` sont `FRONTEND_PORT=1036` et `BACKEND_PORT=1037`, à adapter à votre installation. **Ils doivent être ajoutés dans le `.env` réel** ; on n'utilise plus de ports internes `80/8000`. `docker compose up -d --build` lance MySQL, Django et Vite Preview, **sans Nginx**. Vite relaie `/api`, `/static` et `/media` à Django sur `BACKEND_PORT` ; sous Compose, le nom réseau `backend` est utilisé, et sous Pterodactyl, `127.0.0.1`. Quand `.git` existe, `start.sh` exécute `git fetch` puis `git reset --hard origin/main`, conformément au script fourni : **pousser les modifications sur Gitea avant de redémarrer**, sous peine de les voir écrasées. Le `start.sh` sert uniquement à un hébergement **hors Compose**, typiquement Pterodactyl, avec les dépendances Python/Node et une base MySQL déjà accessibles. Il garde la séquence Git → vérifications Django → migrations → ensure_admin → collectstatic → npm → build → Vite Preview → arrêt propre. Il ne crée ni n'installe la base MySQL. -## Sécurité +Pour une installation locale autonome : dans `backend/`, utiliser `pip install -r requirements.txt`, `python manage.py migrate`, `python manage.py ensure_admin`, `python manage.py runserver 0.0.0.0:$BACKEND_PORT` ; dans `frontend/`, utiliser `npm install` puis `npm run dev -- --port $FRONTEND_PORT`. Oxlint : `npm run lint`. -Les fichiers `.env`, `.git` et les anciens modules de gestion de la boulangerie sont exclus de l'archive de distribution. Le modèle utilisateur personnalisé `users.User` et ses migrations sont préservés pour compatibilité avec l'ancienne structure de la base et l'administration Django. +Le formulaire de contact conserve ses messages en base ; les notifications par e-mail sont optionnelles (`CONTACT_NOTIFICATION_EMAIL`, `DEFAULT_FROM_EMAIL`, `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`, `EMAIL_USE_TLS`). + +**Sécurité et données** : l'éditeur est réservé aux comptes `is_staff`, protégé par les sessions Django et CSRF ; les pages publiques ne donnent pas accès aux routes d'écriture administratives. Les médias sont limités à PNG/JPEG/WebP jusqu'à 5 Mo. Pour un site publié en production, activer HTTPS, contrôler les hôtes autorisés et conserver une sauvegarde de la base et des médias. + +**Important pour la mise à jour** : si l’ancien fichier `frontend/nginx.conf` existe encore, il peut être supprimé ; il n’est plus lu par le Dockerfile. Ne pas remplacer le fichier `.env` existant par `.env.example` : y ajouter simplement les deux variables de ports. En cas de changement des ports, recréer les deux services avec `docker compose up -d --build --force-recreate`. Pour un domaine public, ajouter le domaine dans `ALLOWED_HOSTS` (et les origines HTTPS dans `CSRF_TRUSTED_ORIGINS`). diff --git a/backend/common/__init__.py b/backend/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/common/admin_api.py b/backend/common/admin_api.py new file mode 100644 index 0000000..a479c4b --- /dev/null +++ b/backend/common/admin_api.py @@ -0,0 +1,151 @@ +"""API d'administration pour l'interface React (authentification Django par session).""" +import json + +from django.contrib.auth import authenticate, get_user_model, login, logout +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError +from django.db.models import Q +from django.http import JsonResponse +from django.middleware.csrf import get_token +from django.views.decorators.csrf import ensure_csrf_cookie +from django.views.decorators.http import require_GET, require_POST + + +def _error(message, status=400, **extra): + return JsonResponse({"detail": message, **extra}, status=status) + + +def _json_body(request): + try: + payload = json.loads(request.body or "{}") + except (ValueError, UnicodeDecodeError): + return None + return payload if isinstance(payload, dict) else None + + +def _admin_only(request, superuser=False): + if not request.user.is_authenticated: + return _error("Connexion requise.", 401) + if not request.user.is_active or not request.user.is_staff: + return _error("Accès administrateur requis.", 403) + if superuser and not request.user.is_superuser: + return _error("Action réservée aux superutilisateurs.", 403) + return None + + +def _user_data(user): + return { + "id": user.pk, + "username": user.get_username(), + "first_name": user.first_name, + "last_name": user.last_name, + "email": user.email, + "is_active": user.is_active, + "is_staff": user.is_staff, + "is_superuser": user.is_superuser, + "last_login": user.last_login.isoformat() if user.last_login else None, + } + + +@require_GET +@ensure_csrf_cookie +def csrf(request): + # Prépare un cookie CSRF avant la première requête POST (connexion incluse). + get_token(request) + return JsonResponse({"detail": "CSRF prêt."}) + + +@require_GET +def me(request): + if not request.user.is_authenticated: + return _error("Connexion requise.", 401) + return JsonResponse({"user": _user_data(request.user)}) + + +@require_POST +def sign_in(request): + data = _json_body(request) + if data is None: + return _error("Données JSON invalides.") + username = str(data.get("username", "")).strip() + password = data.get("password", "") + if not isinstance(password, str): + return _error("Mot de passe invalide.") + user = authenticate(request, username=username, password=password) + if user is None: + return _error("Identifiants incorrects.", 401) + if not user.is_staff: + return _error("Accès administrateur requis.", 403) + login(request, user) + return JsonResponse({"user": _user_data(user)}) + + +@require_POST +def sign_out(request): + logout(request) + return JsonResponse({"detail": "Déconnexion effectuée."}) + + +@require_GET +def dashboard(request): + denied = _admin_only(request) + if denied: + return denied + User = get_user_model() + users = User.objects.all() + return JsonResponse({ + "stats": { + "total": users.count(), + "active": users.filter(is_active=True).count(), + "staff": users.filter(is_staff=True).count(), + }, + "users": [_user_data(user) for user in users.order_by("-date_joined", "-pk")[:200]], + }) + + +@require_POST +def create_user(request): + denied = _admin_only(request, superuser=True) + if denied: + return denied + data = _json_body(request) + if data is None: + return _error("Données JSON invalides.") + username = str(data.get("username", "")).strip() + password = data.get("password") + email = str(data.get("email", "")).strip() + if not username or len(username) > 150: + return _error("Nom d'utilisateur invalide.") + if not isinstance(password, str) or not password: + return _error("Mot de passe requis.") + User = get_user_model() + if User.objects.filter(username__iexact=username).exists(): + return _error("Ce nom d'utilisateur existe déjà.", 409) + temp_user = User(username=username, email=email) + try: + temp_user.full_clean(exclude=["password"], validate_unique=False) + validate_password(password, user=temp_user) + except ValidationError as exc: + return _error(" ".join(exc.messages)) + user = User.objects.create_user(username=username, password=password, email=email) + return JsonResponse({"user": _user_data(user)}, status=201) + + +@require_POST +def set_user_active(request, user_id): + denied = _admin_only(request, superuser=True) + if denied: + return denied + data = _json_body(request) + if data is None or type(data.get("is_active")) is not bool: + return _error("La propriété is_active doit être un booléen.") + User = get_user_model() + try: + target = User.objects.get(pk=user_id) + except User.DoesNotExist: + return _error("Utilisateur introuvable.", 404) + if target.pk == request.user.pk or target.is_superuser: + return _error("Impossible de modifier ce compte.", 403) + target.is_active = data["is_active"] + target.save(update_fields=["is_active"]) + return JsonResponse({"user": _user_data(target)}) diff --git a/backend/common/apps.py b/backend/common/apps.py new file mode 100644 index 0000000..3e1544e --- /dev/null +++ b/backend/common/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CommonConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "common" diff --git a/backend/common/cms_api.py b/backend/common/cms_api.py new file mode 100644 index 0000000..a8ccc22 --- /dev/null +++ b/backend/common/cms_api.py @@ -0,0 +1,179 @@ +"""API du CMS, lecture publique et écriture réservée aux administrateurs. + +Authentification par session Django / protection CSRF native DRF. +""" +import re +import uuid +from pathlib import Path + +from django.conf import settings +from django.http import FileResponse, Http404 +from django.shortcuts import get_object_or_404 +from rest_framework import permissions, serializers, status +from rest_framework.parsers import FormParser, MultiPartParser +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import SiteContent, SitePage, SiteBlock + +KEY_PATTERN = re.compile(r'^[a-z][a-z0-9._-]{1,99}$') +MEDIA_PATTERN = re.compile(r'^[a-f0-9]{32}\.(png|jpg|webp)$') +IMAGE_TYPES = {'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp'} +FORBIDDEN_PAGE_SLUGS = {'admin', 'api', 'static', 'media', 'home'} + + +def is_admin(request): + return request.user.is_authenticated and request.user.is_active and request.user.is_staff + + +def allowed_link(value): + return not value or (value.startswith('/') and not value.startswith('//') and not value.startswith('/\\')) or value.startswith('https://') or bool(re.fullmatch(r'tel:[+0-9 ()-]{3,40}', value)) or bool(re.fullmatch(r'mailto:[a-zA-Z0-9._+%-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', value)) + + +class ContentView(APIView): + permission_classes = [permissions.AllowAny] + + def get(self, request): + return Response({row.key: row.value for row in SiteContent.objects.all()}) + + +class ContentEntryView(APIView): + permission_classes = [permissions.IsAdminUser] + + def put(self, request, key): + if not KEY_PATTERN.fullmatch(key): + return Response({'detail': 'Clé de contenu invalide.'}, status=400) + value = request.data.get('value') + if not isinstance(value, str) or len(value) > 12000: + return Response({'detail': 'Texte invalide (maximum 12 000 caractères).'}, status=400) + if key.endswith(('.url', '.href', '.image', '.logo')) and not allowed_link(value): + return Response({'detail': 'Utilisez un chemin interne, une URL HTTPS, mailto: ou tel:.'}, status=400) + if key.startswith('theme.') and not re.fullmatch(r'#[a-fA-F0-9]{6}', value): + return Response({'detail': 'Couleur hexadécimale attendue (#RRGGBB).'}, status=400) + obj, _ = SiteContent.objects.update_or_create(key=key, defaults={'value': value}) + return Response({'key': obj.key, 'value': obj.value}) + + def delete(self, request, key): + SiteContent.objects.filter(key=key).delete() + return Response(status=204) + + +class PageSerializer(serializers.ModelSerializer): + class Meta: + model = SitePage + fields = ('id', 'slug', 'title', 'description', 'nav_label', 'position', 'published', 'show_in_nav') + + def validate_slug(self, value): + if value in FORBIDDEN_PAGE_SLUGS or value in getattr(settings, 'CMS_RESERVED_SLUGS', set()): + raise serializers.ValidationError('Ce chemin est réservé.') + return value + + +class BlockSerializer(serializers.ModelSerializer): + class Meta: + model = SiteBlock + fields = ('id', 'page_slug', 'kind', 'title', 'body', 'image', 'url', 'position', 'published') + + def validate(self, attrs): + for field in ('url', 'image'): + value = attrs.get(field, getattr(self.instance, field, '')) + if not allowed_link(value): + raise serializers.ValidationError({field: 'Adresse non autorisée : URL HTTPS ou chemin interne uniquement.'}) + if len(attrs.get('body', '')) > 12000: + raise serializers.ValidationError({'body': 'Texte trop long.'}) + return attrs + + +class CollectionView(APIView): + permission_classes = [permissions.AllowAny] + model = None + serializer_class = None + + def get(self, request): + items = self.model.objects.all() + if not is_admin(request): + items = items.filter(published=True) + slug = request.query_params.get('page_slug') + if slug and self.model is SiteBlock: + items = items.filter(page_slug=slug) + return Response(self.serializer_class(items, many=True).data) + + def post(self, request): + if not is_admin(request): + return Response({'detail': 'Accès administrateur requis.'}, status=403) + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=201) + + +class ItemView(APIView): + permission_classes = [permissions.IsAdminUser] + model = None + serializer_class = None + + def patch(self, request, pk): + instance = get_object_or_404(self.model, pk=pk) + serializer = self.serializer_class(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + + def delete(self, request, pk): + get_object_or_404(self.model, pk=pk).delete() + return Response(status=204) + + +class PagesView(CollectionView): + model = SitePage + serializer_class = PageSerializer + + +class PageView(ItemView): + model = SitePage + serializer_class = PageSerializer + + +class BlocksView(CollectionView): + model = SiteBlock + serializer_class = BlockSerializer + + +class BlockView(ItemView): + model = SiteBlock + serializer_class = BlockSerializer + + +class UploadView(APIView): + permission_classes = [permissions.IsAdminUser] + parser_classes = [FormParser, MultiPartParser] + + def post(self, request): + image = request.FILES.get('image') + if not image or image.size > 5 * 1024 * 1024: + return Response({'detail': 'Image absente ou supérieure à 5 Mo.'}, status=400) + kind = IMAGE_TYPES.get(image.content_type) + if not kind: + return Response({'detail': 'Formats : PNG, JPEG, WebP.'}, status=400) + header = image.read(16) + image.seek(0) + valid = {'png': header.startswith(b'\x89PNG\r\n\x1a\n'), 'jpg': header.startswith(b'\xff\xd8\xff'), 'webp': header.startswith(b'RIFF') and header[8:12] == b'WEBP'} + if not valid[kind]: + return Response({'detail': 'Fichier image invalide.'}, status=400) + filename = f'{uuid.uuid4().hex}.{kind}' + folder = Path(settings.MEDIA_ROOT) / 'cms' + folder.mkdir(parents=True, exist_ok=True) + with (folder / filename).open('wb') as dest: + for chunk in image.chunks(): + dest.write(chunk) + return Response({'url': f'/api/cms/media/{filename}'}, status=201) + + +def serve_image(request, filename): + if not MEDIA_PATTERN.fullmatch(filename): + raise Http404() + file = Path(settings.MEDIA_ROOT) / 'cms' / filename + if not file.is_file(): + raise Http404() + extension = file.suffix + return FileResponse(file.open('rb'), content_type={'.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp'}[extension]) diff --git a/backend/common/cms_urls.py b/backend/common/cms_urls.py new file mode 100644 index 0000000..a27f021 --- /dev/null +++ b/backend/common/cms_urls.py @@ -0,0 +1,14 @@ +from django.urls import path +from .cms_api import (ContentView, ContentEntryView, PagesView, PageView, + BlocksView, BlockView, UploadView, serve_image) + +urlpatterns = [ + path('content/', ContentView.as_view()), + path('content//', ContentEntryView.as_view()), + path('pages/', PagesView.as_view()), + path('pages//', PageView.as_view()), + path('blocks/', BlocksView.as_view()), + path('blocks//', BlockView.as_view()), + path('upload/', UploadView.as_view()), + path('media/', serve_image), +] diff --git a/backend/common/management/__init__.py b/backend/common/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/common/management/commands/__init__.py b/backend/common/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/common/management/commands/ensure_admin.py b/backend/common/management/commands/ensure_admin.py new file mode 100644 index 0000000..dfadc17 --- /dev/null +++ b/backend/common/management/commands/ensure_admin.py @@ -0,0 +1,29 @@ +"""Crée le superutilisateur lors du premier démarrage, sans le modifier ensuite.""" +from decouple import config +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + help = "Crée le compte administrateur initial depuis le .env." + + def handle(self, *args, **options): + username = config("ADMIN_USERNAME", default="").strip() + password = config("ADMIN_PASSWORD", default="") + if not username or not password: + self.stdout.write(self.style.WARNING("ADMIN_USERNAME ou ADMIN_PASSWORD absent : création ignorée.")) + return + + user_model = get_user_model() + if user_model.objects.filter(username=username).exists(): + self.stdout.write("Compte administrateur déjà présent : mot de passe inchangé.") + return + + user_model.objects.create_superuser( + username=username, + email="", + password=password, + first_name=config("ADMIN_FIRST_NAME", default=""), + last_name=config("ADMIN_LAST_NAME", default=""), + ) + self.stdout.write(self.style.SUCCESS("Administrateur initial créé.")) diff --git a/backend/common/migrations/0001_initial.py b/backend/common/migrations/0001_initial.py new file mode 100644 index 0000000..ef4f27c --- /dev/null +++ b/backend/common/migrations/0001_initial.py @@ -0,0 +1,34 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + dependencies = [] + operations = [ + migrations.CreateModel(name='SiteContent', fields=[ + ('key', models.CharField(primary_key=True, max_length=100, serialize=False)), + ('value', models.TextField(blank=True)), + ('modified_at', models.DateTimeField(auto_now=True)), + ], options={'ordering': ['key']}), + migrations.CreateModel(name='SitePage', fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slug', models.SlugField(max_length=80, unique=True)), + ('title', models.CharField(max_length=200)), + ('description', models.CharField(blank=True, max_length=500)), + ('nav_label', models.CharField(blank=True, max_length=100)), + ('position', models.PositiveIntegerField(default=0)), + ('published', models.BooleanField(default=True)), + ('show_in_nav', models.BooleanField(default=True)), + ], options={'ordering': ['position', 'pk']}), + migrations.CreateModel(name='SiteBlock', fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('page_slug', models.SlugField(db_index=True, max_length=80)), + ('kind', models.CharField(choices=[('heading','Titre'),('text','Texte'),('image','Image'),('button','Bouton')], default='text', max_length=16)), + ('title', models.CharField(blank=True, max_length=250)), + ('body', models.TextField(blank=True)), + ('image', models.CharField(blank=True, max_length=1200)), + ('url', models.CharField(blank=True, max_length=1200)), + ('position', models.PositiveIntegerField(default=0)), + ('published', models.BooleanField(default=True)), + ], options={'ordering': ['position', 'pk']}), + ] diff --git a/backend/common/migrations/0002_import_legacy_content.py b/backend/common/migrations/0002_import_legacy_content.py new file mode 100644 index 0000000..0660616 --- /dev/null +++ b/backend/common/migrations/0002_import_legacy_content.py @@ -0,0 +1,13 @@ +from django.db import migrations + + +def import_existing_content(apps, schema_editor): + Old = apps.get_model('education', 'SiteContent') + New = apps.get_model('common', 'SiteContent') + for old in Old.objects.using(schema_editor.connection.alias).all().iterator(): + New.objects.using(schema_editor.connection.alias).get_or_create(key=old.key, defaults={'value': old.value}) + + +class Migration(migrations.Migration): + dependencies = [('common', '0001_initial'), ('education', '0004_alter_announcement_id_alter_contactmessage_id_and_more')] + operations = [migrations.RunPython(import_existing_content, migrations.RunPython.noop)] diff --git a/backend/common/migrations/__init__.py b/backend/common/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/common/models.py b/backend/common/models.py new file mode 100644 index 0000000..62a0377 --- /dev/null +++ b/backend/common/models.py @@ -0,0 +1,46 @@ +"""CMS réutilisable : contenus, pages libres et blocs réordonnables.""" +from django.db import models + + +class SiteContent(models.Model): + key = models.CharField(max_length=100, primary_key=True) + value = models.TextField(blank=True) + modified_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['key'] + + +class SitePage(models.Model): + slug = models.SlugField(max_length=80, unique=True) + title = models.CharField(max_length=200) + description = models.CharField(max_length=500, blank=True) + nav_label = models.CharField(max_length=100, blank=True) + position = models.PositiveIntegerField(default=0) + published = models.BooleanField(default=True) + show_in_nav = models.BooleanField(default=True) + + class Meta: + ordering = ['position', 'pk'] + + +class SiteBlock(models.Model): + class Kind(models.TextChoices): + HEADING = 'heading', 'Titre' + TEXT = 'text', 'Texte' + IMAGE = 'image', 'Image' + BUTTON = 'button', 'Bouton' + + # "home" = accueil ; "presentation", etc. = chemins existants ; + # les pages libres utilisent leur slug. + page_slug = models.SlugField(max_length=80, db_index=True) + kind = models.CharField(max_length=16, choices=Kind.choices, default=Kind.TEXT) + title = models.CharField(max_length=250, blank=True) + body = models.TextField(blank=True) + image = models.CharField(max_length=1200, blank=True) + url = models.CharField(max_length=1200, blank=True) + position = models.PositiveIntegerField(default=0) + published = models.BooleanField(default=True) + + class Meta: + ordering = ['position', 'pk'] diff --git a/backend/common/tests.py b/backend/common/tests.py new file mode 100644 index 0000000..fa567d3 --- /dev/null +++ b/backend/common/tests.py @@ -0,0 +1,65 @@ +"""Régressions des droits admin. Lancer avec : python manage.py test common.""" +from django.conf import settings +from django.contrib.auth import get_user_model +from django.test import Client, TestCase +from django.urls import Resolver404, resolve, reverse + + +class AdminApiTests(TestCase): + def setUp(self): + User = get_user_model() + self.superuser = User.objects.create_superuser(username="owner", password="TestPassword2026!") + self.staff = User.objects.create_user(username="staff", password="TestPassword2026!", is_staff=True) + self.member = User.objects.create_user(username="member", password="TestPassword2026!") + + def test_dashboard_rejects_anonymous_and_regular_users(self): + self.assertEqual(self.client.get(reverse("admin-dashboard")).status_code, 401) + self.client.force_login(self.member) + self.assertEqual(self.client.get(reverse("admin-dashboard")).status_code, 403) + + def test_staff_can_read_but_not_write(self): + self.client.force_login(self.staff) + response = self.client.get(reverse("admin-dashboard")) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["stats"]["total"], 3) + self.assertEqual(self.client.post(reverse("admin-create-user"), data='{"username":"newuser", "password":"AnotherPassword2026!"}', content_type="application/json").status_code, 403) + + def test_superuser_can_create_and_disable_standard_account(self): + self.client.force_login(self.superuser) + response = self.client.post(reverse("admin-create-user"), data='{"username":"newuser", "password":"AnotherPassword2026!"}', content_type="application/json") + self.assertEqual(response.status_code, 201) + created = get_user_model().objects.get(username="newuser") + self.assertFalse(created.is_staff) + response = self.client.post(reverse("admin-user-active", kwargs={"user_id": created.pk}), data='{"is_active":false}', content_type="application/json") + self.assertEqual(response.status_code, 200) + created.refresh_from_db() + self.assertFalse(created.is_active) + + def test_superuser_cannot_disable_self_or_other_superusers(self): + self.client.force_login(self.superuser) + response = self.client.post(reverse("admin-user-active", kwargs={"user_id": self.superuser.pk}), data='{"is_active":false}', content_type="application/json") + self.assertEqual(response.status_code, 403) + + def test_csrf_required_for_login(self): + client = Client(enforce_csrf_checks=True) + client.get(reverse("auth-csrf")) + self.assertEqual(client.post(reverse("auth-login"), data='{"username":"owner", "password":"TestPassword2026!"}', content_type="application/json").status_code, 403) + token = client.cookies["csrftoken"].value + response = client.post(reverse("auth-login"), data='{"username":"owner", "password":"TestPassword2026!"}', content_type="application/json", HTTP_X_CSRFTOKEN=token) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["user"]["username"], "owner") + + def test_ordinary_user_cannot_log_in_to_admin(self): + response = self.client.post(reverse("auth-login"), data='{"username":"member", "password":"TestPassword2026!"}', content_type="application/json") + self.assertEqual(response.status_code, 403) + self.assertNotIn("_auth_user_id", self.client.session) + + +class NoDjangoAdminTests(TestCase): + def test_native_django_admin_is_absent(self): + self.assertNotIn("django.contrib.admin", settings.INSTALLED_APPS) + with self.assertRaises(Resolver404): + resolve("/django-admin/") + + def test_custom_admin_api_still_exists(self): + self.assertEqual(reverse("admin-dashboard"), "/api/admin/dashboard/") diff --git a/backend/common/tests_cms.py b/backend/common/tests_cms.py new file mode 100644 index 0000000..495d01d --- /dev/null +++ b/backend/common/tests_cms.py @@ -0,0 +1,44 @@ +"""Tests : CMS public, droits, liens et pages libres.""" +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIClient + +from .models import SiteContent, SiteBlock, SitePage + + +class CMSTests(TestCase): + def setUp(self): + User = get_user_model() + self.admin = User.objects.create_superuser(username='editor', password='StrongPass2026!') + self.member = User.objects.create_user(username='member', password='StrongPass2026!') + self.client = APIClient() + + def test_public_content_read_only(self): + SiteContent.objects.create(key='home.title', value='Bonjour') + self.assertEqual(self.client.get('/api/cms/content/').data['home.title'], 'Bonjour') + self.assertEqual(self.client.put('/api/cms/content/home.title/', {'value': 'Non'}, format='json').status_code, 403) + self.client.force_authenticate(user=self.member) + self.assertEqual(self.client.put('/api/cms/content/home.title/', {'value': 'Non'}, format='json').status_code, 403) + + def test_admin_can_edit_and_reset(self): + self.client.force_authenticate(user=self.admin) + self.assertEqual(self.client.put('/api/cms/content/home.title/', {'value': 'Texte'}, format='json').status_code, 200) + self.assertEqual(self.client.delete('/api/cms/content/home.title/').status_code, 204) + self.assertFalse(SiteContent.objects.filter(key='home.title').exists()) + + def test_unsafe_links_rejected(self): + self.client.force_authenticate(user=self.admin) + self.assertEqual(self.client.put('/api/cms/content/contact.button.href/', {'value': 'javascript:alert(1)'}, format='json').status_code, 400) + self.assertEqual(self.client.post('/api/cms/blocks/', {'page_slug': 'home', 'kind': 'button', 'url': 'javascript:alert(1)'}, format='json').status_code, 400) + + def test_pages_and_blocks_publication(self): + page = SitePage.objects.create(slug='cours', title='Cours', published=False) + SiteBlock.objects.create(page_slug='cours', kind='text', body='Interne', published=False) + self.assertFalse(self.client.get('/api/cms/pages/').data) + self.assertFalse(self.client.get('/api/cms/blocks/').data) + self.client.force_authenticate(user=self.admin) + self.assertEqual(self.client.get('/api/cms/pages/').data[0]['id'], page.pk) + self.assertEqual(len(self.client.get('/api/cms/blocks/').data), 1) + + def test_media_upload_is_private(self): + self.assertEqual(self.client.post('/api/cms/upload/').status_code, 403) diff --git a/backend/common/urls.py b/backend/common/urls.py new file mode 100644 index 0000000..9a235fd --- /dev/null +++ b/backend/common/urls.py @@ -0,0 +1,15 @@ +from django.urls import path + +from .admin_api import create_user, csrf, dashboard, me, set_user_active, sign_in, sign_out +from .views import health + +urlpatterns = [ + path("health/", health, name="api-health"), + path("auth/csrf/", csrf, name="auth-csrf"), + path("auth/me/", me, name="auth-me"), + path("auth/login/", sign_in, name="auth-login"), + path("auth/logout/", sign_out, name="auth-logout"), + path("admin/dashboard/", dashboard, name="admin-dashboard"), + path("admin/users/", create_user, name="admin-create-user"), + path("admin/users//active/", set_user_active, name="admin-user-active"), +] diff --git a/backend/common/views.py b/backend/common/views.py new file mode 100644 index 0000000..39f6f6c --- /dev/null +++ b/backend/common/views.py @@ -0,0 +1,9 @@ +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def health(request): + return Response({"status": "ok", "message": "Django fonctionne !"}) diff --git a/backend/core/origins.py b/backend/core/origins.py new file mode 100644 index 0000000..70a7cd9 --- /dev/null +++ b/backend/core/origins.py @@ -0,0 +1,31 @@ +"""Helpers indépendants de Django pour composer les origines HTTP depuis .env.""" +from urllib.parse import urlsplit, urlunsplit + + +def csv_values(raw): + return list(dict.fromkeys(item.strip() for item in str(raw or "").split(",") if item.strip())) + + +def append_port(origin, port): + """Ajoute un port aux origines sans port explicite, sans doubler un port existant.""" + value = origin.strip().rstrip("/") + parsed = urlsplit(value) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise ValueError(f"Origine invalide : {origin!r}. Utiliser http:// ou https://") + if parsed.username or parsed.password or parsed.path or parsed.query or parsed.fragment: + raise ValueError(f"Une origine ne doit pas contenir de chemin ou d'identifiants : {origin!r}") + if not (1 <= int(port) <= 65535): + raise ValueError(f"Port hors plage : {port}") + # parsed.port valide aussi la syntaxe des ports explicites. + netloc = parsed.netloc if parsed.port is not None else f"{parsed.netloc}:{int(port)}" + return urlunsplit((parsed.scheme, netloc, "", "", "")) + + +def effective_origins(raw, port, auto_port=True): + items = csv_values(raw) + if auto_port: + items = [append_port(origin, port) for origin in items] + else: + for origin in items: + append_port(origin, port) # Valide la syntaxe sans changer les ports. + return list(dict.fromkeys(items)) diff --git a/backend/core/settings.py b/backend/core/settings.py index c300494..244dbc1 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -1,162 +1,135 @@ -""" -Django settings for core project. - -Generated by 'django-admin startproject' using Django 5.2.17. - -For more information on this file, see -https://docs.djangoproject.com/en/5.2/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/5.2/ref/settings/ -""" -from datetime import timedelta +"""Paramètres Django du template : origine + port composés automatiquement.""" from pathlib import Path from decouple import config -import dj_database_url -# Build paths inside the project like this: BASE_DIR / 'subdir'. +from .origins import csv_values, effective_origins + BASE_DIR = Path(__file__).resolve().parent.parent +SECRET_KEY = config("SECRET_KEY", default="") +if not SECRET_KEY: + raise RuntimeError("SECRET_KEY est vide : renseigne SECRET_KEY dans le fichier .env") +DEBUG = config("DEBUG", default=False, cast=bool) -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ +FRONTEND_PORT = config("FRONTEND_PORT", default=1036, cast=int) +BACKEND_PORT = config("BACKEND_PORT", default=1037, cast=int) +ALLOWED_HOSTS = csv_values(config("ALLOWED_HOSTS", default="localhost,127.0.0.1,backend")) -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = config('SECRET_KEY') -#it's -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = config('DEBUG', default=False, cast=bool) +# http://localhost -> http://localhost:FRONTEND_PORT pour CORS +CORS_ALLOWED_ORIGINS = effective_origins( + config("CORS_ALLOWED_ORIGINS", default="http://localhost"), + FRONTEND_PORT, + auto_port=config("AUTO_PORT_CORS", default=True, cast=bool), +) +CORS_ALLOWED_ORIGINS = list(dict.fromkeys([*csv_values(config("CORS_ALLOWED_ORIGINS", default="http://localhost")), *CORS_ALLOWED_ORIGINS])) -def csv_env(name, default=''): - """Lit une liste séparée par des virgules en ignorant les valeurs vides.""" - return [value.strip() for value in config(name, default=default).split(',') if value.strip()] - -ALLOWED_HOSTS = csv_env('ALLOWED_HOSTS', 'localhost,127.0.0.1,backend') - -# Application definition +# http://localhost -> http://localhost:BACKEND_PORT pour CSRF +_csrf_backend = effective_origins( + config("CSRF_TRUSTED_ORIGINS", default="http://localhost"), + BACKEND_PORT, + auto_port=config("AUTO_PORT_CSRF", default=True, cast=bool), +) +# Ajoute également l'origine du frontend : nécessaire aux requêtes CSRF +# du tableau de bord React utilisant les sessions Django. +# Conserver aussi les domaines en HTTPS sans numéro de port (proxy inverse 443). +# Le composeur de ports est utile en local mais ne doit pas casser le domaine public. +CSRF_TRUSTED_ORIGINS = list(dict.fromkeys([ + *csv_values(config("CSRF_TRUSTED_ORIGINS", default="http://localhost")), + *_csrf_backend, + *csv_values(config("CORS_ALLOWED_ORIGINS", default="http://localhost")), + *CORS_ALLOWED_ORIGINS, +])) INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - # Third party apps - 'rest_framework', - 'rest_framework_simplejwt', - 'rest_framework_simplejwt.token_blacklist', - 'corsheaders', - # Local apps - 'users', - 'education', + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "corsheaders", + "common", + "users", + "education", ] MIDDLEWARE = [ - 'corsheaders.middleware.CorsMiddleware', - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'core.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, +ROOT_URLCONF = "core.urls" +TEMPLATES = [{ + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ] }, -] - -WSGI_APPLICATION = 'core.wsgi.application' - - -# Database -# https://docs.djangoproject.com/en/5.2/ref/settings/#databases +}] +WSGI_APPLICATION = "core.wsgi.application" +ASGI_APPLICATION = "core.asgi.application" DATABASES = { - 'default': dj_database_url.config( - default=f"mysql://{config('MYSQL_USER', default='user')}:{config('MYSQL_PASSWORD', default='password')}@{config('MYSQL_HOST', default='host')}:{config('MYSQL_PORT', default='3306')}/{config('MYSQL_DATABASE', default='name')}" - ) + "default": { + "ENGINE": "django.db.backends.mysql", + "NAME": config("MYSQL_DATABASE"), + "USER": config("MYSQL_USER"), + "PASSWORD": config("MYSQL_PASSWORD"), + "HOST": config("MYSQL_HOST", default="db"), + "PORT": config("MYSQL_PORT", default=3306, cast=int), + "OPTIONS": {"charset": "utf8mb4"}, + "CONN_MAX_AGE": 60, + } } -# Password validation -# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators - AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] -AUTH_USER_MODEL = 'users.User' - -# Internationalization -# https://docs.djangoproject.com/en/5.2/topics/i18n/ - -LANGUAGE_CODE = 'fr-fr' - -TIME_ZONE = 'Europe/Paris' +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ["rest_framework.authentication.SessionAuthentication"], + "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], +} +LANGUAGE_CODE = "fr-fr" +TIME_ZONE = "Europe/Paris" USE_I18N = True - USE_TZ = True - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/5.2/howto/static-files/ - -STATIC_URL = 'static/' - -# CORS configuration -CORS_ALLOWED_ORIGINS = csv_env('CORS_ALLOWED_ORIGINS') -CORS_ALLOW_ALL_ORIGINS = config('CORS_ALLOW_ALL_ORIGINS', default=False, cast=bool) -CSRF_TRUSTED_ORIGINS = csv_env('CSRF_TRUSTED_ORIGINS') - -# REST Framework & JWT settings -REST_FRAMEWORK = { - 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework_simplejwt.authentication.JWTAuthentication', - ), - 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.AllowAny', - ], - 'DEFAULT_THROTTLE_RATES': {'contact': '5/hour', 'editor_login': '15/hour'}, +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +STORAGES = { + "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"}, } +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = "Lax" +CSRF_COOKIE_SAMESITE = "Lax" +SESSION_COOKIE_SECURE = config("SESSION_COOKIE_SECURE", default=False, cast=bool) +CSRF_COOKIE_SECURE = config("CSRF_COOKIE_SECURE", default=False, cast=bool) +SECURE_CONTENT_TYPE_NOSNIFF = True -SIMPLE_JWT = { - 'ACCESS_TOKEN_LIFETIME': timedelta(days=1), - 'REFRESH_TOKEN_LIFETIME': timedelta(days=36500), - 'ROTATE_REFRESH_TOKENS': True, - 'BLACKLIST_AFTER_ROTATION': True, - 'AUTH_HEADER_TYPES': ('Bearer',), -} +# Les images du CMS survivent aux redéploiements (volume Docker). +MEDIA_ROOT = BASE_DIR / "media" +MEDIA_URL = "/media/" -# Default primary key field type -# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field - - -# Notification facultative des messages du formulaire de contact. +# Conserve le modèle utilisateur AccroCBio et ses migrations existantes. +AUTH_USER_MODEL = 'users.User' CONTACT_NOTIFICATION_EMAIL = config('CONTACT_NOTIFICATION_EMAIL', default='') DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='no-reply@localhost') EMAIL_HOST = config('EMAIL_HOST', default='localhost') @@ -164,8 +137,7 @@ EMAIL_PORT = config('EMAIL_PORT', default=25, cast=int) EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='') EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='') EMAIL_USE_TLS = config('EMAIL_USE_TLS', default=False, cast=bool) +REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'] = {'contact': '5/hour'} -# Le tableau d'administration reste sous /admin/ et utilise les comptes Django. - -# Images téléversées depuis l’éditeur. Stockées sur le volume du backend. -MEDIA_ROOT = BASE_DIR / 'media' +# Réserver les routes déjà utilisées par le site pédagogique. +CMS_RESERVED_SLUGS = {"presentation", "services-a-la-personne", "credit-impot", "ressources", "contact", "avis", "offre-de-rentree", "mentions-legales", "confidentialite"} diff --git a/backend/core/urls.py b/backend/core/urls.py index 3ec2da2..ff62a01 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -1,11 +1,8 @@ -from django.contrib import admin -from django.urls import include, path - -admin.site.site_header = 'AccroCbio — Administration' -admin.site.site_title = 'AccroCbio' -admin.site.index_title = 'Gestion du site' +from django.urls import path, include +# /admin/ est une page React : aucun django-admin n'est exposé. urlpatterns = [ - path('admin/', admin.site.urls), + path('api/', include('common.urls')), + path('api/cms/', include('common.cms_urls')), path('api/education/', include('education.urls')), ] diff --git a/backend/education/editor_views.py b/backend/education/editor_views.py index d073b0d..2f59f8b 100644 --- a/backend/education/editor_views.py +++ b/backend/education/editor_views.py @@ -1,78 +1,21 @@ -"""Edition du site public via une interface React protégée par JWT.""" +"""Routes d’administration du contenu métier protégées par session Django.""" import re import uuid from pathlib import Path from django.conf import settings -from django.contrib.auth import authenticate from django.http import FileResponse, Http404 -from rest_framework import permissions, serializers, status, throttling +from rest_framework import permissions, serializers, status from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.response import Response from rest_framework.views import APIView -from rest_framework_simplejwt.tokens import RefreshToken -from .models import Announcement, ContactMessage, Resource, SiteContent, Testimonial +from .models import Announcement, ContactMessage, Resource, Testimonial KEY_PATTERN = re.compile(r'^[a-z][a-z0-9._-]{1,99}$') IMAGE_EXTENSIONS = {'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp'} -class EditorLoginThrottle(throttling.AnonRateThrottle): - scope = 'editor_login' - - -class EditorLoginView(APIView): - authentication_classes = [] - permission_classes = [permissions.AllowAny] - throttle_classes = [EditorLoginThrottle] - - def post(self, request): - username = str(request.data.get('username', ''))[:150] - password = request.data.get('password', '') - user = authenticate(request, username=username, password=password) - if not user or not user.is_active or not user.is_staff: - return Response({'detail': 'Identifiants incorrects ou accès administrateur refusé.'}, status=401) - token = RefreshToken.for_user(user) - return Response({'access': str(token.access_token), 'username': user.username}) - - -class EditorMeView(APIView): - permission_classes = [permissions.IsAdminUser] - - def get(self, request): - return Response({'username': request.user.username, 'is_staff': True}) - - -class SiteContentView(APIView): - authentication_classes = [] - permission_classes = [permissions.AllowAny] - - def get(self, request): - return Response({record.key: record.value for record in SiteContent.objects.all()}) - - -class SiteContentEntryView(APIView): - permission_classes = [permissions.IsAdminUser] - - def put(self, request, key): - if not KEY_PATTERN.fullmatch(key): - return Response({'detail': 'Clé de contenu invalide.'}, status=400) - value = request.data.get('value') - if not isinstance(value, str) or len(value) > 4000: - return Response({'detail': 'La valeur doit être un texte de 4000 caractères maximum.'}, status=400) - # Les liens ne peuvent pas contenir de protocole actif ou de URL relative suspecte. - if key.endswith('.url') or key.endswith('.href') or key.endswith('.image'): - if value and not ((value.startswith('/') and not value.startswith('//')) or value.startswith('https://') or (key.endswith('.href') and re.fullmatch(r'tel:[+0-9 ().-]{3,40}', value))): - return Response({'detail': 'Utilisez une route interne (/) ou une URL HTTPS.'}, status=400) - entry, _ = SiteContent.objects.update_or_create(key=key, defaults={'value': value}) - return Response({'key': entry.key, 'value': entry.value}) - - def delete(self, request, key): - SiteContent.objects.filter(key=key).delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - class EditorImageUploadView(APIView): permission_classes = [permissions.IsAdminUser] parser_classes = [MultiPartParser, FormParser] diff --git a/backend/education/tests.py b/backend/education/tests.py index 08b5c0d..8d8cf4d 100644 --- a/backend/education/tests.py +++ b/backend/education/tests.py @@ -33,43 +33,40 @@ class PublicApiTests(TestCase): class SiteEditorTests(TestCase): def setUp(self): from django.contrib.auth import get_user_model - from rest_framework.test import APIClient self.client = APIClient() User = get_user_model() self.admin_user = User.objects.create_superuser(username='editor', email='editor@example.com', password='a-safe-password!') self.normal_user = User.objects.create_user(username='visitor', password='a-safe-password!') def login_admin(self): - response = self.client.post('/api/education/editor/login/', {'username': 'editor', 'password': 'a-safe-password!'}) + response = self.client.post('/api/auth/login/', {'username': 'editor', 'password': 'a-safe-password!'}, format='json') self.assertEqual(response.status_code, 200) - self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {response.data["access"]}') def test_only_staff_can_log_in(self): - response = self.client.post('/api/education/editor/login/', {'username': 'visitor', 'password': 'a-safe-password!'}) - self.assertEqual(response.status_code, 401) + response = self.client.post('/api/auth/login/', {'username': 'visitor', 'password': 'a-safe-password!'}, format='json') + self.assertEqual(response.status_code, 403) - def test_public_can_read_but_not_edit_content(self): - from .models import SiteContent + def test_old_content_is_kept(self): + # Les anciennes personnalisations sont migrées par common.0002. + from common.models import SiteContent SiteContent.objects.create(key='home.hero.title.first', value='Bonjour') - response = self.client.get('/api/education/site-content/') - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['home.hero.title.first'], 'Bonjour') - response = self.client.put('/api/education/site-content/home.hero.title.first/', {'value': 'Interdit'}, format='json') + self.assertEqual(self.client.get('/api/cms/content/').data['home.hero.title.first'], 'Bonjour') + response = self.client.put('/api/cms/content/home.hero.title.first/', {'value': 'Interdit'}, format='json') self.assertIn(response.status_code, (401, 403)) def test_admin_can_edit_and_restore_content(self): self.login_admin() key = 'home.hero.title.first' - response = self.client.put(f'/api/education/site-content/{key}/', {'value': 'Nouveau titre'}, format='json') + response = self.client.put(f'/api/cms/content/{key}/', {'value': 'Nouveau titre'}, format='json') self.assertEqual(response.status_code, 200) - self.assertEqual(self.client.get('/api/education/site-content/').data[key], 'Nouveau titre') - response = self.client.delete(f'/api/education/site-content/{key}/') + self.assertEqual(self.client.get('/api/cms/content/').data[key], 'Nouveau titre') + response = self.client.delete(f'/api/cms/content/{key}/') self.assertEqual(response.status_code, 204) - self.assertNotIn(key, self.client.get('/api/education/site-content/').data) + self.assertNotIn(key, self.client.get('/api/cms/content/').data) def test_link_validator_rejects_scripts(self): self.login_admin() - response = self.client.put('/api/education/site-content/home.contact.href/', {'value': 'javascript:alert(1)'}, format='json') + response = self.client.put('/api/cms/content/home.contact.href/', {'value': 'javascript:alert(1)'}, format='json') self.assertEqual(response.status_code, 400) def test_resources_admin_crud(self): diff --git a/backend/education/urls.py b/backend/education/urls.py index 728c936..a02a0e5 100644 --- a/backend/education/urls.py +++ b/backend/education/urls.py @@ -1,19 +1,14 @@ from django.urls import path from .views import AnnouncementView, ContactView, ResourcesView, TestimonialsView -from .editor_views import (EditorLoginView, EditorMeView, SiteContentView, - SiteContentEntryView, EditorImageUploadView, serve_editor_image, EditorDataView, EditorDataDetailView) +from .editor_views import (serve_editor_image, EditorDataView, EditorDataDetailView) urlpatterns = [ - path('resources/', ResourcesView.as_view(), name='resources'), - path('testimonials/', TestimonialsView.as_view(), name='testimonials'), - path('announcements/', AnnouncementView.as_view(), name='announcements'), - path('contact/', ContactView.as_view(), name='contact'), - path('site-content/', SiteContentView.as_view(), name='site-content'), - path('site-content//', SiteContentEntryView.as_view(), name='site-content-edit'), - path('editor/login/', EditorLoginView.as_view(), name='editor-login'), - path('editor/me/', EditorMeView.as_view(), name='editor-me'), - path('editor/upload/', EditorImageUploadView.as_view(), name='editor-upload'), - path('editor-images/', serve_editor_image, name='editor-image'), - path('editor/data//', EditorDataView.as_view(), name='editor-data'), - path('editor/data///', EditorDataDetailView.as_view(), name='editor-data-detail'), + path('resources/', ResourcesView.as_view()), + path('testimonials/', TestimonialsView.as_view()), + path('announcements/', AnnouncementView.as_view()), + path('contact/', ContactView.as_view()), + # Conservé pour les images déjà publiées par l'ancien éditeur. + path('editor-images/', serve_editor_image), + path('editor/data//', EditorDataView.as_view()), + path('editor/data///', EditorDataDetailView.as_view()), ] diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..c865b2c --- /dev/null +++ b/backend/entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu + +: "${BACKEND_PORT:?BACKEND_PORT manquant dans .env}" + +python manage.py migrate --noinput +python manage.py ensure_admin +python manage.py collectstatic --noinput --verbosity 0 + +# Même port d'écoute dans le conteneur et sur l'hôte. +exec python manage.py runserver "0.0.0.0:${BACKEND_PORT}" --noreload diff --git a/backend/requirements.txt b/backend/requirements.txt index 3300a9c..fe4f308 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,7 +1,7 @@ -django -djangorestframework -djangorestframework-simplejwt -django-cors-headers -mysqlclient -python-decouple -dj-database-url +Django>=5.2,<6 +mysqlclient>=2.2,<3 +djangorestframework>=3.16,<4 +django-cors-headers>=4.7,<5 +python-decouple>=3.8,<4 +gunicorn>=23,<25 +whitenoise>=6.9,<7 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6dfcbe2..f6ea108 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,6 +7,8 @@ import NavBar from './components/NavBar'; import AdminPage from './pages/editor/AdminPage'; import { EditorProvider, useEditor } from '@/context/EditorContext'; import { EditText } from '@/components/education/Editable'; +import { CmsTheme } from '@/components/cms/CmsTheme'; +import { CmsBlocks, CmsDynamicPage } from '@/components/cms/CmsBlocks'; import { Edit3, LogOut, Settings2, Eye } from 'lucide-react'; import HomePage from './pages/education/HomePage'; import PresentationPage from './pages/education/PresentationPage'; @@ -50,14 +52,16 @@ function SiteFooter() { return
AccroCbio

Cours particuliers de biologie, SVT et sciences médicales à domicile dans le nord des Deux-Sèvres.

-
À proposRessourcesContactMentions légalesConfidentialité
+
À proposRessourcesContactMentions légalesConfidentialité
-
© {new Date().getFullYear()} AccroCbio · Valérie ChaillouSite de référence
+
© {new Date().getFullYear()} AccroCbio · Valérie ChaillouSite de référence
; } function SiteApp() { const { pathname } = useLocation(); + const contentSlug = pathname === '/' ? 'home' : pathname.startsWith('/ressources/') ? 'ressources' : pathname.split('/')[1]; + const isBuiltIn = ['/', '/presentation', '/services-a-la-personne', '/credit-impot', '/ressources', '/contact', '/avis', '/offre-de-rentree', '/mentions-legales', '/confidentialite'].includes(pathname) || pathname.startsWith('/ressources/'); if (pathname === '/admin' || pathname.startsWith('/admin/')) return ; return @@ -79,8 +83,10 @@ function SiteApp() { } /> } /> } /> + } /> } /> + {isBuiltIn && } @@ -88,4 +94,9 @@ function SiteApp() { ; } -export default function App() { return ; } +function CmsDynamicRoute() { + const { pathname } = useLocation(); + return ; +} + +export default function App() { return ; } diff --git a/frontend/src/components/NavBar.jsx b/frontend/src/components/NavBar.jsx index f71325f..780ed21 100644 --- a/frontend/src/components/NavBar.jsx +++ b/frontend/src/components/NavBar.jsx @@ -4,6 +4,7 @@ import { BookOpenText, CircleHelp, FlaskConical, GraduationCap, Home, Mail, Mess import { Sidebar, SidebarHeader, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '@/components/ui/sidebar'; import { Button } from '@/components/ui/button'; import { EditImage, EditText } from '@/components/education/Editable'; +import { useEditor } from '@/context/EditorContext'; const nav = [ { href: '/', label: 'Accueil', icon: Home, end: true }, @@ -25,17 +26,21 @@ const extra = [ export default function NavBar() { const { isMobile, setOpenMobile } = useSidebar(); + const editor = useEditor(); const { pathname } = useLocation(); const close = () => { if (isMobile) setOpenMobile(false); }; function group(items) { - return items.map(({ href, label, icon: Icon, end }) => ( + const key = (href) => href === '/' ? 'home' : href.slice(1); + return items.filter(({ href }) => editor.get(`nav.${key(href)}.visible`, 'true') !== 'false') + .sort((a, b) => Number(editor.get(`nav.${key(a.href)}.order`, '999')) - Number(editor.get(`nav.${key(b.href)}.order`, '999'))) + .map(({ href, label, icon: Icon, end }) => ( - {label} + {editor.get(`nav.${href.replaceAll('/', '.')}.label`, label)} } /> @@ -46,16 +51,16 @@ export default function NavBar() { - AccroCbioCours de biologie + AccroCbioCours de biologie - Découvrir{group(nav)} - Bibliothèque{group(topics)} - À propos{group(extra)} + Découvrir{group(nav)} + Bibliothèque{group(topics)} + À propos{group(extra)}{group(editor.pages.filter((p) => p.show_in_nav && p.published).map((p) => ({ href: `/${p.slug}`, label: p.nav_label || p.title, icon: BookOpenText })))} - } + : + {shown} + {editable && } + } + + {label || 'Modifier le contenu'}Les modifications seront visibles immédiatement. + {type === 'image' && } + {type === 'textarea' ?