Files
crochet/crochet/views.py
T
pablo b28293d5b2
CI / test (push) Failing after 20s
CI / build (push) Has been skipped
feat: added tests
2026-07-16 15:08:04 +02:00

163 lines
6.8 KiB
Python

import json
import weasyprint
from django.core.exceptions import SuspiciousFileOperation
from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
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 DetailView
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 _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,
),
}
class PatternEditView(DetailView):
model = Pattern
template_name = 'crochet/pattern.html'
context_object_name = 'pattern'
slug_field = 'uuid'
slug_url_kwarg = 'uuid'
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):
def post(self, request, uuid):
pattern = get_object_or_404(Pattern, uuid=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):
def post(self, request, uuid):
pattern = get_object_or_404(Pattern, uuid=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})