328 lines
14 KiB
Python
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')
|