AccroCBio/backend/education/editor_views.py
Louis Mazin 7677667d0b nice
2026-10-13 18:32:32 +02:00

197 lines
8.2 KiB
Python

"""Edition du site public via une interface React protégée par JWT."""
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.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
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]
def post(self, request):
file = request.FILES.get('image')
if not file or file.size > 5 * 1024 * 1024:
return Response({'detail': 'Image absente ou supérieure à 5 Mo.'}, status=400)
content_type = file.content_type or ''
if content_type not in IMAGE_EXTENSIONS:
return Response({'detail': 'Formats acceptés : PNG, JPEG, WebP.'}, status=400)
header = file.read(16)
file.seek(0)
valid = {
'image/png': header.startswith(b'\x89PNG\r\n\x1a\n'),
'image/jpeg': header.startswith(b'\xff\xd8\xff'),
'image/webp': header.startswith(b'RIFF') and header[8:12] == b'WEBP',
}
if not valid[content_type]:
return Response({'detail': 'Le contenu du fichier ne correspond pas à son format.'}, status=400)
filename = f'{uuid.uuid4().hex}.{IMAGE_EXTENSIONS[content_type]}'
directory = Path(settings.MEDIA_ROOT) / 'editor'
directory.mkdir(parents=True, exist_ok=True)
with (directory / filename).open('wb') as target:
for chunk in file.chunks():
target.write(chunk)
return Response({'url': f'/api/education/editor-images/{filename}'}, status=201)
def serve_editor_image(request, filename):
# Noms générés aléatoirement uniquement ; aucune traversée de dossier possible.
if not re.fullmatch(r'[a-f0-9]{32}\.(png|jpg|webp)', filename):
raise Http404()
image_path = Path(settings.MEDIA_ROOT) / 'editor' / filename
if not image_path.is_file():
raise Http404()
ext = image_path.suffix.lower()
content_type = {'.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp'}[ext]
return FileResponse(image_path.open('rb'), content_type=content_type)
RESOURCE_FIELDS = ('title', 'description', 'category', 'kind', 'url', 'published', 'position')
REVIEW_FIELDS = ('author', 'description', 'quote', 'published', 'position')
ANNOUNCEMENT_FIELDS = ('title', 'body', 'cta_label', 'active', 'starts_at', 'ends_at')
class EditorResourceSerializer(serializers.ModelSerializer):
class Meta:
model = Resource
fields = ('id',) + RESOURCE_FIELDS
class EditorReviewSerializer(serializers.ModelSerializer):
class Meta:
model = Testimonial
fields = ('id',) + REVIEW_FIELDS
class EditorAnnouncementSerializer(serializers.ModelSerializer):
class Meta:
model = Announcement
fields = ('id',) + ANNOUNCEMENT_FIELDS
class EditorDataView(APIView):
permission_classes = [permissions.IsAdminUser]
MODELS = {
'resources': (Resource, EditorResourceSerializer),
'reviews': (Testimonial, EditorReviewSerializer),
'announcements': (Announcement, EditorAnnouncementSerializer),
}
def get(self, request, category):
if category not in (*self.MODELS.keys(), 'messages'):
raise Http404()
if category == 'messages':
return Response(list(ContactMessage.objects.values('id', 'name', 'email', 'phone', 'level', 'message', 'read', 'created_at').order_by('-created_at')[:300]))
model, serializer = self.MODELS[category]
return Response(serializer(model.objects.all(), many=True).data)
def post(self, request, category):
if category not in (*self.MODELS.keys(), 'messages'):
raise Http404()
if category == 'messages':
return Response({'detail': 'Action non autorisée.'}, status=405)
_, serializer_class = self.MODELS[category]
serializer = serializer_class(data=request.data)
serializer.is_valid(raise_exception=True)
instance = serializer.save()
return Response(serializer_class(instance).data, status=201)
class EditorDataDetailView(APIView):
permission_classes = [permissions.IsAdminUser]
MODELS = EditorDataView.MODELS
def _get(self, category, pk):
if category not in (*self.MODELS.keys(), 'messages'):
raise Http404()
from django.shortcuts import get_object_or_404
if category == 'messages':
return get_object_or_404(ContactMessage, pk=pk)
return get_object_or_404(self.MODELS[category][0], pk=pk)
def patch(self, request, category, pk):
instance = self._get(category, pk)
if category == 'messages':
if not isinstance(request.data.get('read'), bool):
return Response({'detail': 'Champ read invalide.'}, status=400)
instance.read = request.data['read']
instance.save(update_fields=['read'])
return Response({'id': instance.id, 'read': instance.read})
serializer = self.MODELS[category][1](instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
def delete(self, request, category, pk):
instance = self._get(category, pk)
instance.delete()
return Response(status=204)