197 lines
8.2 KiB
Python
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)
|