Files
crochet/crochet/views.py
T
pablo 73109a268b
CI / test (push) Successful in 58s
CI / build (push) Successful in 29s
feat: improved UX
2026-07-20 11:21:29 +02:00

328 lines
14 KiB
Python

import json
import weasyprint
from django.contrib.auth import login, update_session_auth_hash
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied, SuspiciousFileOperation
from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse_lazy
from django.utils.text import get_valid_filename
from django.utils.translation import get_language, gettext_lazy as _
from django.views import View
from django.views.generic import CreateView, DetailView, TemplateView
from crochet.cover_image import InvalidCoverImage, build_cover_image_variants
from crochet.forms import EmailUpdateForm, StyledPasswordChangeForm, StyledUserCreationForm
from crochet.models import Pattern, PatternImage, StitchType
from crochet.pattern_render import render_pattern_html, sanitize_page_settings
# gettext_lazy (no gettext): se evalúa al construir el HTML de salida (dentro
# de render_pattern_html, vía format_html), no aquí al importar el módulo, así
# que respeta el idioma activo de cada petición aunque esto se cree una vez.
PATTERN_EMPTY_MESSAGE = _('Este patrón todavía no tiene contenido.')
def _stitch_types_data():
# Catálogo de puntos arrastrables (ver StitchType): es el mismo para
# cualquier patrón, no depende de ninguno en concreto.
return [
{'id': stitch_type.id, 'translations': stitch_type.translations}
for stitch_type in StitchType.objects.all()
]
def _get_owned_pattern_or_403(request, uuid):
# Compartida por todo lo que modifica un patrón (guardar, subir imagen,
# borrar) y por PatternEditView: created_by=None son patrones de antes de
# tener autenticación, sin dueño al que atribuírselos, así que se
# bloquea su edición para todo el mundo en vez de dejarlos abiertos o
# asignárselos a quien los toque primero.
pattern = get_object_or_404(Pattern, uuid=uuid)
if pattern.created_by_id is None or not request.user.is_authenticated \
or request.user.id != pattern.created_by_id:
raise PermissionDenied
return pattern
def _pattern_detail_context(pattern):
# Compartido por PatternDetailView (la pinta) y PatternPdfView (la
# convierte a PDF con WeasyPrint a partir del mismo HTML), para no
# reconstruir esto dos veces ni arriesgarse a que diverjan.
lang = get_language()
page_settings = sanitize_page_settings(pattern.page_settings)
return {
'pattern': pattern,
'lang': lang,
'page_settings': page_settings,
'rendered_pattern': render_pattern_html(
pattern.sections, page_settings, lang, _stitch_types_data(), PATTERN_EMPTY_MESSAGE,
cover_image_url=pattern.cover_image_large.url if pattern.cover_image_large else None,
),
}
class HomeView(TemplateView):
template_name = 'crochet/home.html'
class PatternEditView(LoginRequiredMixin, DetailView):
model = Pattern
template_name = 'crochet/pattern.html'
context_object_name = 'pattern'
slug_field = 'uuid'
slug_url_kwarg = 'uuid'
def get_object(self, queryset=None):
pattern = super().get_object(queryset)
# LoginRequiredMixin ya garantiza que request.user está autenticado
# aquí (si no, redirige a login antes de llegar a get_object).
if pattern.created_by_id is None or pattern.created_by_id != self.request.user.id:
raise PermissionDenied
return pattern
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Resuelto por LocaleMiddleware a partir del prefijo de idioma de la
# URL (/es/..., /en/...; ver i18n_patterns en config/urls.py).
context['lang'] = get_language()
# Se pasa tal cual a la plantilla con {{ pattern_data|json_script:"..." }}
# para que main.js reconstruya el editor con el contenido guardado
# en vez del esqueleto por defecto.
context['pattern_data'] = {
'sections': self.object.sections,
'pageSettings': self.object.page_settings,
}
context['stitch_types'] = _stitch_types_data()
return context
class PatternDetailView(DetailView):
# Plantilla propia, sin nada en común con la del editor: esta vista es
# de solo lectura y no debe cargar ningún <input>/<button> de edición ni
# el JS que los activa, ni siquiera oculto con CSS, porque cualquiera
# podría "revelarlo" quitando esa clase desde las herramientas de
# desarrollo del navegador y guardar cambios reales sobre el patrón (el
# enlace de solo lectura comparte el mismo uuid que el de edición). El
# HTML se genera entero en el servidor a partir de los datos guardados
# (ver pattern_render.py), así que aquí no hay nada que "revelar".
model = Pattern
template_name = 'crochet/pattern_detail.html'
context_object_name = 'pattern'
slug_field = 'uuid'
slug_url_kwarg = 'uuid'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(_pattern_detail_context(self.object))
return context
class PatternPdfView(DetailView):
# Genera el PDF con WeasyPrint a partir del mismo HTML que
# PatternDetailView (misma plantilla, mismo contexto), no del editor:
# así el PDF sale siempre de la última versión guardada. A diferencia de
# pattern.html (editor), pattern_detail.html no depende del CDN de
# Tailwind con compilador JIT en JS (WeasyPrint no ejecuta JavaScript,
# así que ese CDN no generaría ningún estilo); usa en su lugar el CSS ya
# compilado en static/css/pattern-detail.css (ver .tools/build/).
model = Pattern
slug_field = 'uuid'
slug_url_kwarg = 'uuid'
def get(self, request, *args, **kwargs):
self.object = self.get_object()
context = _pattern_detail_context(self.object)
html_string = render_to_string('crochet/pattern_detail.html', context, request=request)
# base_url para que WeasyPrint resuelva las rutas relativas de
# static (css/main.css, css/pattern-detail.css) haciendo una
# petición HTTP normal a este mismo servidor, igual que haría un
# navegador.
pdf_bytes = weasyprint.HTML(
string=html_string, base_url=request.build_absolute_uri('/'),
).write_pdf()
response = HttpResponse(pdf_bytes, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{self._pdf_filename()}"'
return response
def _pdf_filename(self):
# page_settings lo guarda pattern_save() sin validar su contenido
# (ver PatternSaveView), así que el título podría traer comillas o
# caracteres fuera de ASCII que no encajan bien en Content-Disposition.
# get_valid_filename() lanza SuspiciousFileOperation (no devuelve '')
# si no queda nada válido tras limpiarlo -pasa con un título vacío,
# solo espacios, o hecho solo de caracteres como "." o "?"-, así que
# hay que cubrir ese caso a mano en vez de fiarse de un resultado
# "falsy".
title = (self.object.page_settings or {}).get('title') or ''
try:
safe_title = get_valid_filename(title) if title.strip() else ''
except SuspiciousFileOperation:
safe_title = ''
return f'{safe_title or "patron"}.pdf'
class PatternSaveView(View):
# Sin LoginRequiredMixin a propósito: esto lo llama storage.js por
# fetch, y su redirección a login (una respuesta 200 con el HTML del
# formulario) haría que "response.ok" diera un falso "guardado" en vez
# de disparar el aviso de error. _get_owned_pattern_or_403 ya cubre
# tanto "sin sesión" como "de otro usuario" con un 403 real.
def post(self, request, uuid):
pattern = _get_owned_pattern_or_403(request, uuid)
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return HttpResponseBadRequest('JSON inválido')
pattern.sections = data.get('sections', [])
pattern.page_settings = data.get('pageSettings', {})
pattern.save(update_fields=['sections', 'page_settings', 'updated_at'])
return JsonResponse({'ok': True})
class PatternImageUploadView(View):
# Mismo motivo que en PatternSaveView: lo llama sections.js por fetch.
def post(self, request, uuid):
pattern = _get_owned_pattern_or_403(request, uuid)
image_file = request.FILES.get('image')
if not image_file:
return HttpResponseBadRequest('Falta el archivo "image"')
pattern_image = PatternImage.objects.create(pattern=pattern, image=image_file)
return JsonResponse({'id': pattern_image.id, 'url': pattern_image.image.url})
class PatternCoverImageUploadView(View):
# Mismo motivo que en PatternSaveView: lo llama cover-image.js por fetch.
def post(self, request, uuid):
pattern = _get_owned_pattern_or_403(request, uuid)
uploaded_file = request.FILES.get('cover_image')
if not uploaded_file:
return HttpResponseBadRequest('Falta el archivo "cover_image"')
try:
original, thumbnail, large = build_cover_image_variants(uploaded_file)
except InvalidCoverImage as error:
return HttpResponseBadRequest(str(error))
# Reemplaza los archivos anteriores en vez de acumularlos: cada
# patrón tiene una única portada vigente.
for field_name, content_file in (
('cover_image', original), ('cover_image_thumbnail', thumbnail), ('cover_image_large', large),
):
field_file = getattr(pattern, field_name)
if field_file:
field_file.delete(save=False)
field_file.save(content_file.name, content_file, save=False)
pattern.save(update_fields=['cover_image', 'cover_image_thumbnail', 'cover_image_large'])
return JsonResponse({
'thumbnailUrl': pattern.cover_image_thumbnail.url,
'largeUrl': pattern.cover_image_large.url,
})
class RegisterView(CreateView):
# UserCreationForm ya pide justo nombre de usuario + contraseña (dos
# veces, para confirmarla): no hace falta un formulario propio, solo la
# versión con clases de daisyUI en sus widgets (ver crochet/forms.py).
form_class = StyledUserCreationForm
template_name = 'registration/register.html'
success_url = reverse_lazy('crochet:account_home')
def form_valid(self, form):
response = super().form_valid(form)
# Registrarse ya cuenta como haber probado usuario/contraseña, así
# que se inicia sesión directamente en vez de mandar al usuario a
# loguearse otra vez con lo que acaba de escribir.
login(self.request, self.object)
return response
class AccountHomeView(LoginRequiredMixin, TemplateView):
template_name = 'crochet/account_home.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['patterns'] = self.request.user.patterns.order_by('-updated_at')
return context
class AccountSettingsView(LoginRequiredMixin, TemplateView):
template_name = 'crochet/account_settings.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['email_form'] = EmailUpdateForm(user=self.request.user, initial={'email': self.request.user.email})
context['password_form'] = StyledPasswordChangeForm(user=self.request.user)
return context
class AccountEmailUpdateView(LoginRequiredMixin, View):
# Fragmento HTMX (ver _account_email_form.html, hx-post con
# hx-target="this"/hx-swap="outerHTML" en el propio <form>): en
# cualquier caso (éxito o error de validación) se devuelve el mismo
# parcial con 200, para que htmx reemplace el formulario con la
# versión actualizada sin recargar la página. htmx, por defecto, NO
# sustituye el contenido si la respuesta no es 2xx (lo trata como un
# error de red, sin pintar nada), así que un 400 aquí dejaría los
# errores de validación escritos en la respuesta pero invisibles.
def post(self, request):
form = EmailUpdateForm(request.POST, user=request.user)
if form.is_valid():
form.save()
form = EmailUpdateForm(user=request.user, initial={'email': request.user.email})
email_updated = True
else:
email_updated = False
return render(request, 'crochet/_account_email_form.html', {'email_form': form, 'email_updated': email_updated})
class AccountPasswordChangeView(LoginRequiredMixin, View):
# Mismo patrón que AccountEmailUpdateView (200 siempre, ver el porqué
# ahí). update_session_auth_hash es imprescindible: cambiar la
# contraseña invalida el hash de sesión que AuthenticationMiddleware
# comprueba en cada petición, así que sin esto el usuario se quedaría
# deslogueado justo después de cambiarla.
def post(self, request):
form = StyledPasswordChangeForm(user=request.user, data=request.POST)
if form.is_valid():
form.save()
update_session_auth_hash(request, form.user)
form = StyledPasswordChangeForm(user=request.user)
password_updated = True
else:
password_updated = False
return render(
request, 'crochet/_account_password_form.html',
{'password_form': form, 'password_updated': password_updated},
)
class PatternCreateView(LoginRequiredMixin, View):
# Solo POST (ver el <form> en account_home.html): crear un patrón no
# debería poder dispararse desde un simple enlace GET sin token CSRF.
def post(self, request):
pattern = Pattern.objects.create(created_by=request.user)
return redirect('crochet:pattern_edit', uuid=pattern.uuid)
class PatternDeleteView(LoginRequiredMixin, View):
# Con LoginRequiredMixin (a diferencia de PatternSaveView/
# PatternImageUploadView): esto lo dispara un <form> normal (ver
# account_home.html y pattern.html), no fetch, así que a un anónimo le
# viene mejor la redirección a login que un 403 a secas.
def post(self, request, uuid):
pattern = _get_owned_pattern_or_403(request, uuid)
pattern.delete()
return redirect('crochet:account_home')