diff --git a/Makefile b/Makefile index 19892de..5963a96 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ reformat: $(RUN) black . check-fuzzy: - @ for app in "backend" "pdf" "frontend" "category" "analytics" "tenants" ; do \ + @ for app in "backend" "pdf" "frontend" "category" "analytics" "tenants"; do \ if [ ! -z "$$(msgattrib $${app}/locale/cs/LC_MESSAGES/django.po --only-fuzzy)" ]; then echo "$${app} app contains fuzzy strings" && exit 1; fi \ done; diff --git a/backend/locale/cs/LC_MESSAGES/django.po b/backend/locale/cs/LC_MESSAGES/django.po index ab0cd3e..09518f0 100644 --- a/backend/locale/cs/LC_MESSAGES/django.po +++ b/backend/locale/cs/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-12-22 22:15+0000\n" +"POT-Creation-Date: 2024-12-23 15:17+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -44,43 +44,35 @@ msgstr "%(class)s %(identifier)s byla úspěšně vytvořena" msgid "Create %(class)s" msgstr "Nová %(class)s" -#: backend/menus.py:10 +#: backend/menus.py:12 msgid "Analytics" msgstr "Statistiky" -#: backend/menus.py:11 +#: backend/menus.py:13 msgid "Edit Songbook" msgstr "Upravit zpěvník" -#: backend/menus.py:12 +#: backend/menus.py:14 msgid "Django Admin" msgstr "" -#: backend/menus.py:18 +#: backend/menus.py:20 msgid "Admin" msgstr "Administrace" -#: backend/menus.py:26 +#: backend/menus.py:28 msgid "Add Song" msgstr "Přidat Písničku" -#: backend/menus.py:27 backend/models.py:29 -msgid "Categories" -msgstr "Kategorie" - -#: backend/menus.py:33 backend/models.py:70 -msgid "Songs" -msgstr "Písničky" - -#: backend/menus.py:41 +#: backend/menus.py:43 msgid "Logout" msgstr "Odhlásit se" -#: backend/menus.py:43 +#: backend/menus.py:45 msgid "Change password" msgstr "Změna hesla" -#: backend/menus.py:50 +#: backend/menus.py:52 msgid "Account" msgstr "Účet" @@ -100,6 +92,10 @@ msgstr "Autor" msgid "Youtube Link" msgstr "Odkaz na Youtube" +#: backend/models.py:29 +msgid "Categories" +msgstr "Kategorie" + #: backend/models.py:30 msgid "Archived" msgstr "Archivovat" @@ -112,6 +108,10 @@ msgstr "Text" msgid "Song" msgstr "Písnička" +#: backend/models.py:70 +msgid "Songs" +msgstr "Písničky" + #: backend/templates/songs/index.html:27 msgid "Hide chords" msgstr "Skrýt akordy" diff --git a/backend/menus.py b/backend/menus.py index 593d7a2..cf8d946 100644 --- a/backend/menus.py +++ b/backend/menus.py @@ -5,6 +5,8 @@ from menu import MenuItem, Menu from backend.auth import is_authenticated, is_localadmin, is_superadmin +from backend.models import Song +from category.models import Category admin_children = ( MenuItem(_("Analytics"), reverse("analytics:index")), @@ -24,13 +26,13 @@ songbook_children = ( MenuItem(_("Add Song"), reverse("backend:add")), - MenuItem(_("Categories"), reverse("category:list")), + MenuItem(Category._meta.verbose_name_plural, reverse("category:list")), ) Menu.add_item( "songbook-admin", MenuItem( - _("Songs"), + Song._meta.verbose_name_plural, reverse("backend:index"), children=songbook_children, check=is_localadmin, diff --git a/backend/utils.py b/backend/utils.py index 86bbfd1..f3771de 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -6,14 +6,13 @@ from django.conf import settings -from pdf.utils import request_pdf_regeneration +from category.utils import request_pdf_regeneration -def regenerate_pdf(song, update: bool = False): +def regenerate_pdf(song): """Regenerates PDFs for each category song is in""" for category in song.categories.all(): - if category.generate_pdf: - request_pdf_regeneration(category, update) + request_pdf_regeneration(category) def regenerate_prerender(song): diff --git a/backend/views.py b/backend/views.py index b0f7c0b..98d9ce7 100644 --- a/backend/views.py +++ b/backend/views.py @@ -119,7 +119,7 @@ class SongUpdateView( def post(self, request, *args, **kwargs): response = super().post(request, *args, **kwargs) if self.regenerate: - regenerate_pdf(self.object, True) + regenerate_pdf(self.object) regenerate_prerender(self.object) return response diff --git a/category/forms.py b/category/forms.py index d757ee7..801753b 100644 --- a/category/forms.py +++ b/category/forms.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _ from category.models import Category +from pdf.forms import PrependWidget from tenants.models import Tenant @@ -17,17 +18,18 @@ class Meta: fields = [ "name", "slug", - "generate_pdf", "tenant", + "generate_pdf", + "title", + "display_name", "filename", "public", "locale", - "title", "show_date", "image", - "margin", "link", ] + widgets = {"filename": PrependWidget(".pdf")} class NameForm(Form): diff --git a/category/locale/cs/LC_MESSAGES/django.po b/category/locale/cs/LC_MESSAGES/django.po index 004db5f..683de07 100644 --- a/category/locale/cs/LC_MESSAGES/django.po +++ b/category/locale/cs/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-12-25 17:43+0000\n" +"POT-Creation-Date: 2024-12-25 19:56+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -23,29 +23,29 @@ msgstr "" msgid "Move Category to a different Tenant" msgstr "Přesunout Kategorie do jiného Zpěvníku" -#: category/models.py:14 category/templates/category/list.html:11 +#: category/models.py:19 category/templates/category/list.html:12 msgid "Name" msgstr "Jméno" -#: category/models.py:15 category/templates/category/list.html:12 +#: category/models.py:20 category/templates/category/list.html:13 msgid "URL pattern" msgstr "URL vzor" -#: category/models.py:17 category/templates/category/list.html:13 +#: category/models.py:22 category/templates/category/list.html:16 msgid "PDF generation" msgstr "Automatické Generování PDF" -#: category/models.py:18 +#: category/models.py:23 msgid "Should the PDF file be automatically generated when a song changes?" msgstr "" "Má být vygenerován pdf soubor když se nějaká písnička z kategorie změní?" -#: category/models.py:25 +#: category/models.py:33 msgid "Category" msgstr "Kategorie" -#: category/models.py:26 category/templates/category/list.html:4 -#: category/templates/category/list.html:5 +#: category/models.py:34 category/templates/category/list.html:5 +#: category/templates/category/list.html:6 msgid "Categories" msgstr "Kategorie" @@ -54,47 +54,66 @@ msgid "Language" msgstr "Jazyk" #: category/templates/category/list.html:15 +msgid "Number of songs" +msgstr "Počet písniček" + +#: category/templates/category/list.html:17 +msgid "Number of files" +msgstr "Počet souborů" + +#: category/templates/category/list.html:18 +msgid "Last file" +msgstr "Poslední soubor" + +#: category/templates/category/list.html:19 +msgid "Status" +msgstr "" + +#: category/templates/category/list.html:20 +msgid "Last update" +msgstr "Poslední změna" + +#: category/templates/category/list.html:21 msgid "Actions" msgstr "Akce" -#: category/templates/category/list.html:23 -msgid "Yes" -msgstr "Ano" +#: category/templates/category/list.html:40 +msgid "None" +msgstr "Nic" -#: category/templates/category/list.html:23 -msgid "No" -msgstr "Ne" +#: category/templates/category/list.html:48 +msgid "No generated files" +msgstr "Žádné soubory" -#: category/templates/category/list.html:27 +#: category/templates/category/list.html:55 msgid "Edit" msgstr "Upravit" -#: category/templates/category/list.html:29 +#: category/templates/category/list.html:58 msgid "Toggle Dropdown" msgstr "Rozbalit" -#: category/templates/category/list.html:34 +#: category/templates/category/list.html:63 msgid "Already in queue" msgstr "Již ve frontě" -#: category/templates/category/list.html:36 -msgid "Regenerate" -msgstr "Přegenerovat" +#: category/templates/category/list.html:66 +msgid "Generate PDF" +msgstr "Vygenerovat PDF" -#: category/templates/category/list.html:39 +#: category/templates/category/list.html:70 msgid "Delete" msgstr "Smazat" -#: category/templates/category/list.html:48 +#: category/templates/category/list.html:79 msgid "Add Category" msgstr "Přidat Kategorii" -#: category/views.py:40 +#: category/views.py:38 #, python-format msgid "Category with url /%(slug)s does not exist" msgstr "Kategorie s url /%(slug)s neexistuje" -#: category/views.py:107 -#, python-format -msgid "Category %s was successfully staged for PDF generation" -msgstr "PDF pro Kategorii %s bylo přidáno do fronty" +#: category/views.py:72 +msgid "songbook" +msgstr "zpevnicek" diff --git a/category/models.py b/category/models.py index 45da9d1..bb62ee9 100644 --- a/category/models.py +++ b/category/models.py @@ -24,7 +24,7 @@ class Category(PDFTemplate): ) def get_songs(self) -> Iterable[Tuple[int, "Song"]]: - return list(enumerate(self.song_set.filter(archived=False))) + return list(enumerate(self.song_set.filter(archived=False), start=1)) def __str__(self): return self.name diff --git a/category/templates/category/list.html b/category/templates/category/list.html index 72ff30e..1937dc1 100644 --- a/category/templates/category/list.html +++ b/category/templates/category/list.html @@ -1,54 +1,81 @@ {% extends "base/frame.html" %} {% load i18n %} +{% load types %} {% block title %} {% trans "Categories" %} {% endblock %} {% block header %} {% trans "Categories" %} {% endblock %} {% block framed_body %} - - - - - - - - - - - - {% for category in categories %} - - - - - - - - {% endfor %} - -
{% trans "Name" %}{% trans "URL pattern" %}{% trans "PDF generation" %}{% trans "Language" %}{% trans "Actions" %}
{{ category.name }}{% url "category:index" slug=category.slug %}{% if category.generate_pdf %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}{{ category.locale }} -
- {% trans "Edit" %} - - -
-
- - + + + + + + + + + + + + + + + + + {% for category in categories %} + + + + + + + + {% with category.latest_file as pdf %} + {% if pdf %} + + + + {% else %} + + + + {% endif %} + {% endwith %} + + + {% endfor %} + +
{% trans "Name" %}{% trans "URL pattern" %}{% trans "Language" %}{% trans "Number of songs" %}{% trans "PDF generation" %}{% trans "Number of files" %}{% trans "Last file" %}{% trans "Status" %}{% trans "Last update" %}{% trans "Actions" %}
{{ category.name }}{% url "category:index" slug=category.slug %} + {{ category.locale }}{{ category.song_count }}{{ category.generate_pdf|yesno }}{{ category.file_count }} + {% if pdf.file %} + {{ pdf.name }} + {% else %} + {% trans "None" %} + {% endif %} + {{ pdf.get_status_display }} + {% if pdf.status == "PR" %}{{ pdf.progress }}/7 {% endif %} {% if pdf.status == "SC" %} + {{ pdf.scheduled_at }} {% endif %}{{ pdf.update_date }}{% trans "No generated files" %} +
+ {% trans "Edit" %} + + +
+
+ {% endblock %} \ No newline at end of file diff --git a/category/urls.py b/category/urls.py index f4e2851..499c105 100644 --- a/category/urls.py +++ b/category/urls.py @@ -8,7 +8,7 @@ CategoryCreateView, CategoryUpdateView, CategoryDeleteView, - CategoryRegeneratePDFView, + # CategoryRegeneratePDFView, CategoryMoveView, ) @@ -17,7 +17,6 @@ path("add", CategoryCreateView.as_view(), name="add"), path("edit/", CategoryUpdateView.as_view(), name="edit"), path("delete/", CategoryDeleteView.as_view(), name="delete"), - path("regenerate/", CategoryRegeneratePDFView.as_view(), name="regenerate"), path("move", CategoryMoveView.as_view(), name="move"), path("", CategorySongsListView.as_view(), name="index"), ] diff --git a/category/utils.py b/category/utils.py new file mode 100644 index 0000000..6d3b6df --- /dev/null +++ b/category/utils.py @@ -0,0 +1,11 @@ +"""Utility functions""" + +from django.conf import settings + +from pdf.generate import generate_pdf_file + + +def request_pdf_regeneration(category): + """Requests automatic PDF regeneration if none is pending""" + if category.generate_pdf and not category.has_scheduled_file(): + generate_pdf_file(category, settings.CATEGORY_PDF_DELAY) diff --git a/category/views.py b/category/views.py index c635bf4..d579b46 100644 --- a/category/views.py +++ b/category/views.py @@ -1,16 +1,14 @@ """Views for categories""" from django.conf import settings -from django.contrib import messages from django.core.cache import cache from django.db import transaction +from django.db.models import Count, Q from django.http import Http404 -from django.shortcuts import redirect from django.urls import reverse_lazy -from django.utils.translation import gettext_lazy as _ -from django.views import View +from django.utils import translation +from django.utils.translation import gettext_lazy as _, get_language, gettext from django.views.generic import ListView -from django.views.generic.detail import SingleObjectMixin from analytics.views import AnalyticsMixin from backend.generic import UniversalDeleteView, UniversalUpdateView, UniversalCreateView @@ -19,8 +17,8 @@ from backend.views import BaseSongListView from category.forms import CategoryForm, NameForm from category.models import Category -from pdf.models.request import PDFRequest, RequestType, Status -from pdf.utils import request_pdf_regeneration +from category.utils import request_pdf_regeneration +from pdf.models.request import PDFTemplate, PDFFile from tenants.views import AdminMoveView @@ -53,14 +51,13 @@ class CategoryListView(LocalAdminRequired, ListView): context_object_name = "categories" def get_queryset(self): - return super().get_queryset().filter(tenant=self.request.tenant) - - def get_context_data(self, *, object_list=None, **kwargs): - ctx = super().get_context_data(object_list=object_list, **kwargs) - ctx["already_staged"] = PDFRequest.objects.filter( - type=RequestType.EVENT, status__in=[Status.QUEUED, Status.SCHEDULED], tenant=self.request.tenant - ).values_list("category_id", flat=True) - return ctx + return ( + super() + .get_queryset() + .filter(tenant=self.request.tenant) + .annotate(song_count=Count("song", filter=Q(song__archived=False))) + .annotate(file_count=Count("pdffile")) + ) class CategoryCreateView(LocalAdminRequired, UniversalCreateView): @@ -71,7 +68,8 @@ class CategoryCreateView(LocalAdminRequired, UniversalCreateView): success_url = reverse_lazy("category:list") def get_initial(self): - return {"tenant": self.request.tenant} + with translation.override(get_language()): + return {"tenant": self.request.tenant, "filename": gettext("songbook")} def get_success_message(self, cleaned_data): cache.delete(settings.CATEGORY_CACHE_KEY) @@ -87,28 +85,12 @@ class CategoryUpdateView(LocalAdminRequired, RegenerateViewMixin, UniversalUpdat def post(self, request, *args, **kwargs): response = super().post(request, *args, **kwargs) - if self.regenerate: + if self.regenerate and self.object.generate_pdf: request_pdf_regeneration(self.object) cache.delete(settings.CATEGORY_CACHE_KEY) return response -class CategoryRegeneratePDFView(LocalAdminRequired, View, SingleObjectMixin): - """Creates PDF regeneration request for Category, if it doesn't already exist""" - - model = Category - - def get(self, request, *args, **kwargs): - """GET Request""" - category = self.get_object() - request_pdf_regeneration(category) - messages.success( - request, - _("Category %s was successfully staged for PDF generation") % category.name, - ) - return redirect("category:list") - - class CategoryDeleteView(LocalAdminRequired, UniversalDeleteView): """Removes category""" @@ -131,14 +113,17 @@ def action(self, target, ids): """What should happen on POST with data from forms""" categories = Category.objects.filter(id__in=ids) songs = Song.objects.filter(categories__id__in=ids).distinct() - requests = PDFRequest.objects.filter(category_id__in=ids).distinct() + requests = PDFTemplate.objects.filter(category_id__in=ids).distinct() with transaction.atomic(): for category in categories: category.tenant = target for request in requests: request.tenant = target + for file in request.files: + file.tenant = target + PDFFile.objects.bulk_update(request.files, ["tenant"]) Category.objects.bulk_update(categories, ["tenant"]) - PDFRequest.objects.bulk_update(requests, ["tenant"]) + PDFTemplate.objects.bulk_update(requests, ["tenant"]) for song in songs: to_keep = [] to_remove = [] diff --git a/chords/settings/base.py b/chords/settings/base.py index 65a1e3e..91bc148 100644 --- a/chords/settings/base.py +++ b/chords/settings/base.py @@ -220,3 +220,7 @@ # Default Tenant, only used on migration TENANT_NAME = "Default" TENANT_HOSTNAME = "localhost" + +# How many seconds to wait until starting PDF generation of a Category after a change, +# Delay helps with batching changes before creating PDF, resulting in less PDFs after editing spree +CATEGORY_PDF_DELAY = 30 * 60 diff --git a/docs/FILES.md b/docs/FILES.md index fff47d2..498188c 100644 --- a/docs/FILES.md +++ b/docs/FILES.md @@ -4,16 +4,28 @@ ### PDFTemplate * Represents set of configuration options that are used for generating a PDFFile -* No way of recreating exactly the same file, PDFTemplates can change and every regeneration will be based on the new configuration +* Can be regenerated #### ManualPDFTemplate +* User created template for creating PDFFiles +* Schedule manually +* Songs need to be configured manually + #### Category -* Category is a subclass of PDFTemplate and provides songs +* Category is a subclass of PDFTemplate. +* When generating PDF from Category, songs will be taken from the Category itself +* Scheduled automatically. but can be manually triggered ### PDFFile * Represents single file that is either generated or scheduled for generation - -### NumberedSong -* Song + fixed song number, used in PDFTemplate to change order of songs in PDF +* No way of recreating the exact configuration that led to this File ## Workflow + +### Automated +* Create Category -> Schedule PDF generation, if it is enabled +* Song updated -> [Schedule PDF generation for every Category the song is in, if it is enabled] + +### Manual +Create Template -> Generate File +Existing Template (Including Categories) -> Generate File diff --git a/frontend/templates/base/frame.html b/frontend/templates/base/frame.html index e172ce7..62f8e64 100644 --- a/frontend/templates/base/frame.html +++ b/frontend/templates/base/frame.html @@ -1,7 +1,10 @@ {% extends "base/index.html" %} {% block body %}
-

{% block header %} Cool header dude {% endblock %}

+
+

{% block header %} Cool header dude {% endblock %}

+ {% block extra_header %}{% endblock %} +

{% block framed_body %} Here should be your text diff --git a/pdf/admin.py b/pdf/admin.py index e858ef8..2a06e5b 100644 --- a/pdf/admin.py +++ b/pdf/admin.py @@ -8,25 +8,26 @@ from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ -from pdf.models.request import PDFRequest - - -# @admin.register(PDFRequest) -# class RequestAdmin(admin.ModelAdmin): -# """Request Model Admin""" -# -# list_display = ["id", "title", "update_date", "type", "file", "status", "tenant_name"] -# actions = ["move_tenant"] -# -# @admin.display(description=_("Tenant")) -# def tenant_name(self, obj): -# """Shows Tenant name""" -# link = reverse("admin:tenants_tenant_change", args=[obj.tenant.id]) -# return format_html('{}', link, obj.tenant.name) -# -# @admin.action(description=_("Move Request to a different Tenant")) -# def move_tenant(self, request, queryset): -# """Move Request to a different Tenant""" -# ids = queryset.values_list("id", flat=True) -# url = f"{reverse_lazy('pdf:move')}?{urlencode({'pk': list(ids)}, True)}" -# return HttpResponseRedirect(url) +from pdf.models.request import ManualPDFTemplate +from tenants.models import Tenant + + +@admin.register(ManualPDFTemplate) +class RequestAdmin(admin.ModelAdmin): + """Request Model Admin""" + + list_display = ["id", "title", "tenant_name"] + actions = ["move_tenant"] + + @admin.display(description=Tenant._meta.verbose_name) + def tenant_name(self, obj): + """Shows Tenant name""" + link = reverse("admin:tenants_tenant_change", args=[obj.tenant.id]) + return format_html('{}', link, obj.tenant.name) + + @admin.action(description=_("Move Request to a different Tenant")) + def move_tenant(self, request, queryset): + """Move Request to a different Tenant""" + ids = queryset.values_list("id", flat=True) + url = f"{reverse_lazy('pdf:templates:move')}?{urlencode({'pk': list(ids)}, True)}" + return HttpResponseRedirect(url) diff --git a/pdf/forms.py b/pdf/forms.py index 3290432..6aed9f1 100644 --- a/pdf/forms.py +++ b/pdf/forms.py @@ -1,18 +1,107 @@ """Forms for PDF application""" from django.core.exceptions import ValidationError -from django.forms import ModelForm, CharField, BaseFormSet, HiddenInput, Form, IntegerField +from django.forms import ( + ModelForm, + CharField, + BaseFormSet, + HiddenInput, + Form, + IntegerField, + ModelMultipleChoiceField, + Widget, + TextInput, +) +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ -from pdf.models.request import PDFRequest, PDFSong +from backend.models import Song +from pdf.models.request import PDFSong, ManualPDFTemplate, PDFFile -class RequestForm(ModelForm): - """Slimmed down model form for PDFRequest""" +class NoopWidget(Widget): + """ + Renders completely empty widget, but still retrieves data + Useful for situation when you handle submit specifically + """ + + def render(self, name, value, attrs=None, renderer=None): + return "" + + def value_from_datadict(self, data, files, name): + try: + getter = data.getlist + except AttributeError: + getter = data.get + return getter(name) + + @property + def is_hidden(self): + return True + + +class PrependWidget(TextInput): + """Widget that prepend boostrap-style span with data to specified base widget""" + + def __init__(self, data, *args, **kwargs): + """Initialise widget and get base instance""" + super().__init__(*args, **kwargs) + self.data = data + + def render(self, name, value, attrs=None, renderer=None): + """Render base widget and add bootstrap spans""" + context = self.get_context(name, value, attrs) + field = self._render(self.template_name, context, renderer) + return mark_safe( + ('
' " %(field)s" ' %(data)s' "
") + % {"field": field, "data": self.data} + ) + + +class ManualTemplateForm(ModelForm): + """Form for ManualPDFTemplate""" class Meta: - model = PDFRequest - fields = ["title", "filename", "locale", "show_date", "image", "margin"] + model = ManualPDFTemplate + fields = ["name", "display_name", "filename", "title", "image", "public", "locale", "show_date", "link"] + widgets = {"filename": PrependWidget(".pdf")} + + +class PDFFileEditForm(ModelForm): + """Edit form for PDFFile, allows changing only the display name""" + + filename = CharField(widget=PrependWidget(".pdf"), disabled=True) + + class Meta: + model = PDFFile + fields = ["display_name", "filename"] + # widgets = {"song": HiddenInput()} + + +class SongSelectionForm(Form): + """Song selection form""" + + songs = ModelMultipleChoiceField(required=True, queryset=Song.objects.all(), widget=NoopWidget) + + def __init__(self, *args, **kwargs): + self.request = kwargs.pop("request") + super().__init__(*args, **kwargs) + self.queryset = Song.objects.filter(categories__tenant=self.request.tenant).distinct() + self.fields["songs"].queryset = self.queryset + + def get_list_field_value(self, field_name, default=None): + """Return current value of a List field""" + if self.is_bound: + return self.data.getlist(self.add_prefix(field_name), default) + return self.initial.get(field_name, default) + + def clean(self): + data = super().clean() + if "songs" in self.cleaned_data: + songs = self.cleaned_data["songs"] + if len(songs) == 0: + raise ValidationError(_("No songs selected"), code="no_songs") + return data class PDFSongForm(ModelForm): diff --git a/pdf/generate.py b/pdf/generate.py index 86f747b..c451b81 100644 --- a/pdf/generate.py +++ b/pdf/generate.py @@ -2,93 +2,38 @@ import locale import logging -import mimetypes import os import re import tempfile +from datetime import timedelta from math import ceil -from pathlib import Path from time import time -from urllib.parse import urlparse import weasyprint from django.conf import settings -from django.contrib.staticfiles.finders import find -from django.contrib.staticfiles.storage import staticfiles_storage from django.core.files import File -from django.core.files.storage import default_storage from django.template.loader import render_to_string -from django.urls import get_script_prefix from django.urls import reverse from django.utils import translation +from django.utils.timezone import now +from django_weasyprint.utils import django_url_fetcher from huey.contrib.djhuey import task from weasyprint.logger import PROGRESS_LOGGER +from category.models import Category from pdf.locales import changed_locale, lang_to_locale -from pdf.models.request import PDFRequest, Status +from pdf.models.request import Status, PDFFile, ManualPDFTemplate logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) +AllowedTemplates = Category | ManualPDFTemplate -# pylint: disable=no-else-return, consider-using-with -def custom_django_url_fetcher(url, *args, **kwargs): - """Fix for django_url_fetcher not working correctly with Manifest storage""" - # attempt to load file:// paths to Django MEDIA or STATIC files directly from disk - if url.startswith("file:"): - logger.debug("Attempt to fetch from %s", url) - mime_type, encoding = mimetypes.guess_type(url) - url_path = urlparse(url).path - data = { - "mime_type": mime_type, - "encoding": encoding, - "filename": Path(url_path).name, - } - - default_media_url = settings.MEDIA_URL in ("", get_script_prefix()) - if not default_media_url and url_path.startswith(settings.MEDIA_URL): - logger.debug("URL contains MEDIA_URL (%s)", settings.MEDIA_URL) - cleaned_media_root = str(settings.MEDIA_ROOT) - if not cleaned_media_root.endswith("/"): - cleaned_media_root += "/" - path = url_path.replace(settings.MEDIA_URL, cleaned_media_root, 1) - logger.debug("Cleaned path: %s", path) - data["file_obj"] = default_storage.open(path, "rb") - return data - - # path looks like a static file based on configured STATIC_URL - elif settings.STATIC_URL and url_path.startswith(settings.STATIC_URL): - logger.debug("URL contains STATIC_URL (%s)", settings.STATIC_URL) - # strip the STATIC_URL prefix to get the relative filesystem path - relative_path = url_path.replace(settings.STATIC_URL, "", 1) - # detect hashed files storage and get path with un-hashed filename - if hasattr(staticfiles_storage, "hashed_files"): - logger.debug("Hashed static files storage detected") - if settings.DEBUG: - name = staticfiles_storage.stored_name(relative_path) - else: - name = relative_path - # relative_path = get_reversed_hashed_files()[relative_path] - data["filename"] = Path(name).name - logger.debug("Cleaned path: %s", relative_path) - # find the absolute path using the static file finders - absolute_path = find(relative_path) - logger.debug("Static file finder returned: %s", absolute_path) - if absolute_path: - logger.debug("Loading static file: %s", absolute_path) - data["file_obj"] = open(absolute_path, "rb") - return data - - # Fall back to weasyprint default fetcher for http/s: and file: paths - # that did not match MEDIA_URL or STATIC_URL. - logger.debug("Forwarding to weasyprint.default_url_fetcher: %s", url) - return weasyprint.default_url_fetcher(url, *args, **kwargs) - - -def update_status(request: PDFRequest, status: Status): + +def update_status(file: PDFFile, status: Status): """Updates status of the request if it is in DB""" - request.status = status - request.save() + file.status = status + file.save() def get_base_url(): @@ -99,66 +44,82 @@ def get_base_url(): return getattr(settings, "WEASYPRINT_BASEURL", reverse("chords:index")) -def generate_pdf(request: PDFRequest): +def generate_pdf(pdf_file: PDFFile, template: AllowedTemplates): """Generates PDF""" - songs = sorted(request.get_songs(), key=lambda song: song.song_number) - with changed_locale(lang_to_locale(request.locale)): - sorted_songs = sorted(songs, key=lambda song: locale.strxfrm(song.name)) - update_status(request, Status.IN_PROGRESS) + songs = sorted(template.get_songs(), key=lambda x: x[0]) + with changed_locale(lang_to_locale(template.locale)): + sorted_songs = sorted(songs, key=lambda x: locale.strxfrm(x[1].name)) + + update_status(pdf_file, Status.IN_PROGRESS) timer = Timer() try: - rel_path = f"{settings.PDF_FILE_DIR}/{request.filename}.pdf" - with tempfile.TemporaryFile(mode="a+b") as file: - with translation.override(request.locale), timer: + rel_path = f"{settings.PDF_FILE_DIR}/{template.filename}.pdf" + with tempfile.TemporaryFile(mode="a+b") as tmp_file: + with translation.override(template.locale), timer: name = os.path.basename(rel_path) logger.info("Generating %s", name) - logger.debug("from request %s", request) + logger.debug("from Template %s", template) string = render_to_string( template_name="pdf/index.html", context={ "songs": songs, "sorted_songs": sorted_songs, - "name": request.title or request.tenant.display_name, - "request": request, - "link": request.link, + "name": template.title or template.tenant.display_name, + "request": template, + "link": template.link, }, ) PROGRESS_LOGGER.setLevel(logging.INFO) - log_filter = ProgressFilter(request) + log_filter = ProgressFilter(pdf_file) PROGRESS_LOGGER.addFilter(log_filter) weasyprint.HTML( string=string, - url_fetcher=custom_django_url_fetcher, + url_fetcher=django_url_fetcher, base_url=get_base_url(), - ).write_pdf(file, optimize_size=("fonts", "images")) + ).write_pdf(tmp_file, optimize_images=True) PROGRESS_LOGGER.removeFilter(log_filter) - request.file.save(rel_path, File(file, name=rel_path)) - request.time_elapsed = ceil(timer.duration) + pdf_file.file.save(rel_path, File(tmp_file, name=rel_path)) + pdf_file.time_elapsed = ceil(timer.duration) + pdf_file.update_date = now() - update_status(request, Status.DONE) - logger.info("Done in %i seconds", request.time_elapsed) + update_status(pdf_file, Status.DONE) + logger.info("Done in %i seconds", pdf_file.time_elapsed) return True, timer.duration except Exception as exception: # pylint: disable=broad-except logger.error("Request failed: %s", str(exception)) - update_status(request, Status.FAILED) + update_status(pdf_file, Status.FAILED) return False, timer.duration @task() -def generate_pdf_job(request: PDFRequest): +def generate_pdf_job(file: PDFFile, template: AllowedTemplates): """Generates PDF from request in the background""" - generate_pdf(request) + generate_pdf(file, template) -def schedule_generation(request: PDFRequest, delay: int): +def generate_pdf_file(template: AllowedTemplates, delay: int = 0): """Schedules generation of a request at a specific time""" - job = generate_pdf_job.schedule(kwargs={"request": request}, delay=delay) + file = PDFFile.objects.create( + template=template, + status=Status.SCHEDULED, + tenant=template.tenant, + update_date=now(), + scheduled_at=now() + timedelta(seconds=delay), + public=template.public, + filename=template.filename, + ) + file.save() + generate_pdf_job.schedule(kwargs={"file": file, "template": template}, delay=delay) + # queue = get_queue("default") # created_job = queue.enqueue_at(schedule_time, generate_pdf, request, retry=Retry(max=5, interval=120)) - logger.info("Scheduled PDF generation of request %s in %s seconds", request.id, delay) - return job + if delay > 0: + logger.info("Scheduled PDF generation of file %s from template %s in %s seconds", file.id, template.name, delay) + else: + logger.info("Scheduled File (%s) generation from template %s", file.id, template.name) + return file class ProgressFilter(logging.Filter): @@ -166,15 +127,15 @@ class ProgressFilter(logging.Filter): STEP_NUMBER = re.compile(r"(?<=Step\s)\d") - def __init__(self, request): + def __init__(self, file): super().__init__() - self.request = request + self.file = file def filter(self, record): step = self.STEP_NUMBER.search(record.getMessage()).group(0) - if self.request.progress != step: - self.request.progress = step - self.request.save() + if self.file.progress != step: + self.file.progress = step + self.file.save() return True diff --git a/pdf/locale/cs/LC_MESSAGES/django.po b/pdf/locale/cs/LC_MESSAGES/django.po index 4de07da..6823a68 100644 --- a/pdf/locale/cs/LC_MESSAGES/django.po +++ b/pdf/locale/cs/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-12-25 17:41+0000\n" +"POT-Creation-Date: 2024-12-25 20:40+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,311 +18,378 @@ msgstr "" "Plural-Forms: nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n " "<= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3;\n" -#: pdf/admin.py:21 -msgid "Tenant" -msgstr "Zpěvník" - -#: pdf/admin.py:27 +#: pdf/admin.py:28 msgid "Move Request to a different Tenant" msgstr "Přesunout požadavky do jiného Tenanta" -#: pdf/forms.py:39 +#: pdf/forms.py:103 +msgid "No songs selected" +msgstr "Nevybrány žádné písničky" + +#: pdf/forms.py:128 msgid "Each song has to have distinct song number" msgstr "Každá písnička musí mít unikátní číslo" -#: pdf/menus.py:10 -msgid "Create PDF" -msgstr "Vytvořit PDF" - -#: pdf/menus.py:11 pdf/templates/pdf/requests/list.html:7 -#: pdf/templates/pdf/requests/list.html:8 -#: pdf/templates/pdf/requests/select.html:8 -#: pdf/templates/pdf/requests/select.html:9 -msgid "PDF Requests" -msgstr "PDF požadavky" +#: pdf/menus.py:12 +#, python-format +msgid "Generated %(files)s" +msgstr "Existující %(files)s" -#: pdf/menus.py:17 +#: pdf/menus.py:22 pdf/models/request.py:96 pdf/templates/pdf/files/list.html:7 +#: pdf/templates/pdf/files/list.html:8 msgid "Files" msgstr "Soubory" -#: pdf/models/__init__.py:14 -msgid "Filename of the generated PDF, please do not include .pdf" -msgstr "Název vygenerovaného PDF, prosím piště bez přípony .pdf" +#: pdf/models/__init__.py:23 +msgid "Queued" +msgstr "Ve frontě" + +#: pdf/models/__init__.py:24 +msgid "Scheduled" +msgstr "Naplánováno" + +#: pdf/models/__init__.py:25 +msgid "In progress" +msgstr "Generuje se" + +#: pdf/models/__init__.py:26 +msgid "Done" +msgstr "Hotovo" + +#: pdf/models/__init__.py:27 +msgid "Failed" +msgstr "Chyba" -#: pdf/models/__init__.py:15 +#: pdf/models/__init__.py:37 +msgid "" +"(Optional) Display name for the generated files. If empty filename will be " +"used" +msgstr "" +"(Nepovinné) Veřejné jméno pro vytvořené soubory. V případě že necháte pole " +"prázdné, veřejné jméno bude vytvořeno na zákaldě jména souboru" + +#: pdf/models/__init__.py:38 pdf/models/request.py:73 +msgid "Display name" +msgstr "Veřejné jméno" + +#: pdf/models/__init__.py:44 pdf/models/request.py:67 +msgid "File name of the generated PDF" +msgstr "Název vygenerovaného PDF" + +#: pdf/models/__init__.py:45 pdf/models/request.py:68 msgid "File name" msgstr "Název souboru" -#: pdf/models/__init__.py:19 -msgid "True, if the file should be public" -msgstr "Zaškrkněte, pokud chcete aby byl PDF soubor veřejně viditelný" +#: pdf/models/__init__.py:49 +msgid "True, if the file should be visible in the menu" +msgstr "Zaškrkněte, pokud chcete aby byl PDF soubor veřejně viditelný v menu" -#: pdf/models/__init__.py:20 +#: pdf/models/__init__.py:50 pdf/models/request.py:84 msgid "Public file" msgstr "Veřejný soubor" -#: pdf/models/__init__.py:24 +#: pdf/models/__init__.py:54 msgid "Language" msgstr "Jazyk" -#: pdf/models/__init__.py:26 -msgid "Language to be used in the generated PDF" -msgstr "Jazyk pro použití ve vygenerovaných PDF" +#: pdf/models/__init__.py:56 +msgid "Language of the generated PDF" +msgstr "Jazyk PDF souboru" -#: pdf/models/__init__.py:31 -msgid "Name to be used on the title page of the PDF" -msgstr "Jméno pro titulní stranu pdf souboru" +#: pdf/models/__init__.py:61 +msgid "" +"Name to be used on the title page of the PDF, usually only this or Title " +"image should be used" +msgstr "" +"Jméno pro titulní stranu pdf souboru. Obvykle stačí vyplnit pouze Nadpis " +"nebo Úvodné obrázek" -#: pdf/models/__init__.py:32 pdf/templates/pdf/requests/list.html:30 -#: pdf/templates/pdf/requests/wait.html:74 +#: pdf/models/__init__.py:62 pdf/templates/pdf/files/list.html:25 +#: pdf/templates/pdf/files/wait.html:73 msgid "Title" -msgstr "Titulek" +msgstr "Nadpis" -#: pdf/models/__init__.py:36 +#: pdf/models/__init__.py:66 msgid "Show date" msgstr "Vložit datum" -#: pdf/models/__init__.py:37 -msgid "True, if the date should be included in the final PDF" +#: pdf/models/__init__.py:67 +msgid "True, if the date should be included in the PDF" msgstr "Zaškrkněte, pokud chcete aby v PDF souboru bylo datum" -#: pdf/models/__init__.py:40 +#: pdf/models/__init__.py:70 msgid "Title Image" msgstr "Úvodní obrázek" -#: pdf/models/__init__.py:41 -msgid "Optional title image of the songbook" -msgstr "Volitelný obrázek na úvodní stránku " +#: pdf/models/__init__.py:71 +msgid "(Optional) title image for the PDF" +msgstr "(Nepovinný) Obrázek na úvodní stránku PDF souboru" -#: pdf/models/__init__.py:47 +#: pdf/models/__init__.py:77 msgid "Title Image margins" msgstr "Odsazení úvodního obrázku" -#: pdf/models/__init__.py:48 +#: pdf/models/__init__.py:78 msgid "Margins for title image, might be needed for some printers" msgstr "" -"Odsazení obrázku na titulní stránce, může být nutné pro některé tiskárny" +"Odsazení obrázku na úvodní stránce, může být nutné pro některé tiskárny" -#: pdf/models/__init__.py:54 -msgid "Link to include in the PDF" -msgstr "Odkaz, který bude v PDF" +#: pdf/models/__init__.py:84 +msgid "(Optional) URL Link to include in the PDF" +msgstr "(Nepovinné) URL adresa, zobrazená v PDF." -#: pdf/models/__init__.py:55 pdf/templates/pdf/requests/list.html:35 -#: pdf/templates/pdf/requests/wait.html:91 +#: pdf/models/__init__.py:85 pdf/templates/pdf/files/list.html:23 +#: pdf/templates/pdf/files/wait.html:93 msgid "Link" msgstr "Odkaz" -#: pdf/models/request.py:34 pdf/templates/pdf/requests/list.html:23 +#: pdf/models/request.py:33 msgid "Automated" msgstr "Automaticky" -#: pdf/models/request.py:35 pdf/templates/pdf/requests/list.html:20 +#: pdf/models/request.py:34 msgid "Manual" msgstr "Manuální" -#: pdf/models/request.py:41 -msgid "Queued" -msgstr "Ve frontě" +#: pdf/models/request.py:47 +msgid "Template Name" +msgstr "Název Šablony" -#: pdf/models/request.py:42 -msgid "Scheduled" -msgstr "Naplánováno" +#: pdf/models/request.py:47 +msgid "Name of this template, only shown internally" +msgstr "Název šablony, viditelný pouze interně" -#: pdf/models/request.py:43 -msgid "In progress" -msgstr "Generuje se" +#: pdf/models/request.py:54 +msgid "File Template" +msgstr "Šablona" -#: pdf/models/request.py:44 -msgid "Done" -msgstr "Hotovo" +#: pdf/models/request.py:55 pdf/templates/pdf/templates/list.html:7 +#: pdf/templates/pdf/templates/list.html:8 +msgid "File Templates" +msgstr "Šablony" -#: pdf/models/request.py:45 -msgid "Failed" -msgstr "Selhalo" +#: pdf/models/request.py:72 +msgid "Display name for the file, if empty filename will be used" +msgstr "" +"Veřejné jméno pro vytvořené soubory. V případě že necháte pole prázdné, " +"veřejné jméno bude vytvořeno na zákaldě jména souboru" -#: pdf/models/request.py:80 -msgid "PDFRequest" -msgstr "PDF požadavek" +#: pdf/models/request.py:83 +msgid "True, if the file should be public" +msgstr "Zaškrkněte, pokud chcete aby byl PDF soubor veřejně viditelný" -#: pdf/models/request.py:81 -msgid "PDFRequests" -msgstr "PDF požadavky" +#: pdf/models/request.py:95 +msgid "File" +msgstr "Soubor" -#: pdf/models/request.py:97 +#: pdf/models/request.py:108 msgid "Song number" msgstr "Číslo písničky" -#: pdf/templates/pdf/index.html:45 -msgid "Table of Contents" -msgstr "Obsah" - -#: pdf/templates/pdf/requests/assign.html:6 -#: pdf/templates/pdf/requests/assign.html:7 -msgid "PDF details" -msgstr "Informace o PDF souboru" - -#: pdf/templates/pdf/requests/assign.html:15 -msgid "" -"Please, now please fill all the details for the PDF and choose song ordering" -msgstr "Prosím vyplňte informace o PDF souboru a vyberte pořadí písniček" - -#: pdf/templates/pdf/requests/assign.html:21 -msgid "Song numbers" -msgstr "Čísla písniček" - -#: pdf/templates/pdf/requests/assign.html:35 -msgid "Sort by name" -msgstr "Seřadit podle jména" - -#: pdf/templates/pdf/requests/assign.html:36 -msgid "Finish" -msgstr "Dokončit" - -#: pdf/templates/pdf/requests/list.html:31 -msgid "Last updated" -msgstr "Naposledy změněno" +#: pdf/templates/pdf/files/list.html:22 +#: pdf/templates/pdf/templates/list.html:29 +msgid "Filename" +msgstr "Název souboru" -#: pdf/templates/pdf/requests/list.html:33 -msgid "Type" -msgstr "Typ" +#: pdf/templates/pdf/files/list.html:24 +#: pdf/templates/pdf/templates/list.html:30 +msgid "Public" +msgstr "Veřejný" -#: pdf/templates/pdf/requests/list.html:34 -#: pdf/templates/pdf/requests/wait.html:87 +#: pdf/templates/pdf/files/list.html:26 pdf/templates/pdf/files/wait.html:89 +#: pdf/templates/pdf/templates/list.html:34 msgid "Status" msgstr "Status" -#: pdf/templates/pdf/requests/list.html:36 -msgid "Time elapsed" -msgstr "Trvání" +#: pdf/templates/pdf/files/list.html:27 +#: pdf/templates/pdf/templates/list.html:35 +msgid "Last update" +msgstr "Poslední změna" -#: pdf/templates/pdf/requests/list.html:37 -msgid "Category" -msgstr "Kategorie" +#: pdf/templates/pdf/files/list.html:28 +msgid "PDF Template" +msgstr "Šablona" -#: pdf/templates/pdf/requests/list.html:38 +#: pdf/templates/pdf/files/list.html:29 +#: pdf/templates/pdf/templates/list.html:36 msgid "Actions" msgstr "Akce" -#: pdf/templates/pdf/requests/list.html:53 -#: pdf/templates/pdf/requests/list.html:61 +#: pdf/templates/pdf/files/list.html:39 pdf/templates/pdf/files/list.html:50 +#: pdf/templates/pdf/templates/list.html:53 msgid "None" msgstr "Nic" -#: pdf/templates/pdf/requests/list.html:67 -msgid "Regenerate" -msgstr "Přegenerovat" +#: pdf/templates/pdf/files/list.html:54 +#: pdf/templates/pdf/templates/list.html:73 +msgid "Edit" +msgstr "Upravit" -#: pdf/templates/pdf/requests/list.html:69 -msgid "Already in queue" -msgstr "Již ve frontě" - -#: pdf/templates/pdf/requests/list.html:72 +#: pdf/templates/pdf/files/list.html:57 +#: pdf/templates/pdf/templates/list.html:76 msgid "Toggle Dropdown" msgstr "Otevřít menu" -#: pdf/templates/pdf/requests/list.html:77 -msgid "Delete File" -msgstr "Odstranit soubor" - -#: pdf/templates/pdf/requests/list.html:79 -msgid "No file present" -msgstr "Nemá soubor" - -#: pdf/templates/pdf/requests/select.html:18 -msgid "Please select which songs would you like to include in the pdf" -msgstr "Prosím vyberte písničky které byste chtěli mít v PDF souboru" - -#: pdf/templates/pdf/requests/select.html:85 -msgid "Select All" -msgstr "Vybrat všechny" - -#: pdf/templates/pdf/requests/select.html:92 -msgid "Deselect all" -msgstr "Zrušit výběr" +#: pdf/templates/pdf/files/list.html:63 +msgid "Generate Another" +msgstr "Vygenerovat další" -#: pdf/templates/pdf/requests/select.html:97 -msgid "Finish selecting" -msgstr "Dokončit výběr" +#: pdf/templates/pdf/files/list.html:66 +#: pdf/templates/pdf/templates/list.html:83 +msgid "Delete" +msgstr "Smazat" -#: pdf/templates/pdf/requests/wait.html:6 -#: pdf/templates/pdf/requests/wait.html:7 +#: pdf/templates/pdf/files/wait.html:6 pdf/templates/pdf/files/wait.html:7 #, python-format msgid "Waiting for render of %(filename)s.pdf" msgstr "Čekání na vygenerování %(filename)s.pdf" -#: pdf/templates/pdf/requests/wait.html:12 +#: pdf/templates/pdf/files/wait.html:12 msgid "HTML Generation" msgstr "Tvorba HTML" -#: pdf/templates/pdf/requests/wait.html:13 +#: pdf/templates/pdf/files/wait.html:13 msgid "Parsing HTML" msgstr "Zpracovávání HTML" -#: pdf/templates/pdf/requests/wait.html:14 +#: pdf/templates/pdf/files/wait.html:14 msgid "Parsing CSS" msgstr "Zpracovávání CSS" -#: pdf/templates/pdf/requests/wait.html:15 +#: pdf/templates/pdf/files/wait.html:15 msgid "Applying CSS" msgstr "Aplikace CSS pravidel" -#: pdf/templates/pdf/requests/wait.html:16 +#: pdf/templates/pdf/files/wait.html:16 msgid "Creating formatting structure" msgstr "Vytváření formátovací struktury" -#: pdf/templates/pdf/requests/wait.html:17 +#: pdf/templates/pdf/files/wait.html:17 msgid "Generating layout" msgstr "Generování rozložení" -#: pdf/templates/pdf/requests/wait.html:18 +#: pdf/templates/pdf/files/wait.html:18 msgid "Creating PDF" msgstr "Vytvořit PDF" -#: pdf/templates/pdf/requests/wait.html:19 +#: pdf/templates/pdf/files/wait.html:19 msgid "Adding PDF metadata" msgstr "Přidávání metadat do PDF" -#: pdf/templates/pdf/requests/wait.html:73 +#: pdf/templates/pdf/files/wait.html:35 +msgid "Completed" +msgstr "" + +#: pdf/templates/pdf/files/wait.html:77 msgid "No title" msgstr "Bez nadpisu" -#: pdf/templates/pdf/requests/wait.html:78 +#: pdf/templates/pdf/files/wait.html:81 msgid "Progress" msgstr "Postup" -#: pdf/utils.py:67 -msgid "songbook" -msgstr "zpevnik" +#: pdf/templates/pdf/files/wait.html:98 +msgid "Back to all files" +msgstr "Zpátky na seznam souborů" -#: pdf/views.py:47 -#, python-format -msgid "Request %(id)s is already in queue" -msgstr "Požadavek %(id)s je již ve frontě" +#: pdf/templates/pdf/index.html:45 +msgid "Table of Contents" +msgstr "Obsah" -#: pdf/views.py:53 -#, python-format -msgid "Request %(id)s was scheduled for regeneration" -msgstr "Požadavek %(id)s byl označen k přegenerování" +#: pdf/templates/pdf/templates/assign.html:6 +#: pdf/templates/pdf/templates/assign.html:7 +msgid "Song order" +msgstr "Pořadí písniček" -#: pdf/views.py:69 -#, python-format -msgid "Unable to remove file from request %(id)s that doesn't have one" -msgstr "Nepodařilo se odstranit soubor z požadavku %(id)s, protože žádný nemá" +#: pdf/templates/pdf/templates/assign.html:15 +msgid "Please, choose song order and their numbers" +msgstr "Zvolte prosím pořadí písniček a jejich čísla" -#: pdf/views.py:76 +#: pdf/templates/pdf/templates/assign.html:23 +msgid "Song numbers" +msgstr "Čísla písniček" + +#: pdf/templates/pdf/templates/assign.html:37 +msgid "Sort by name" +msgstr "Seřadit podle jména" + +#: pdf/templates/pdf/templates/assign.html:38 +msgid "Finish" +msgstr "Dokončit" + +#: pdf/templates/pdf/templates/list.html:11 +msgid "Add Template" +msgstr "Přidat Šablonu" + +#: pdf/templates/pdf/templates/list.html:28 +msgid "Name" +msgstr "Jméno" + +#: pdf/templates/pdf/templates/list.html:31 +msgid "Number of songs" +msgstr "Počet písníček" + +#: pdf/templates/pdf/templates/list.html:32 +msgid "Number of files" +msgstr "Počet souborů" + +#: pdf/templates/pdf/templates/list.html:33 +msgid "Last file" +msgstr "Poslední soubor" + +#: pdf/templates/pdf/templates/list.html:59 +msgid "No generated files" +msgstr "Žádné soubory" + +#: pdf/templates/pdf/templates/list.html:67 +msgid "Already in queue" +msgstr "Již ve frontě" + +#: pdf/templates/pdf/templates/list.html:70 +msgid "Generate" +msgstr "Generovat" + +#: pdf/templates/pdf/templates/select.html:9 +#: pdf/templates/pdf/templates/select.html:10 +msgid "Create new File Template" +msgstr "Vytvořit novou Šablonu" + +#: pdf/templates/pdf/templates/select.html:26 +msgid "" +"Please enter file configuration option and select which songs you would like " +"to include" +msgstr "" +"Prosím vyplňte parametry PDF souboru a vyberte písničky, které má soubor " +"obsahovat" + +#: pdf/templates/pdf/templates/select.html:93 +msgid "Select All" +msgstr "Vybrat všechny" + +#: pdf/templates/pdf/templates/select.html:100 +msgid "Deselect all" +msgstr "Zrušit výběr" + +#: pdf/templates/pdf/templates/select.html:105 +msgid "Finish selecting" +msgstr "Dokončit výběr" + +#: pdf/views/templates.py:61 #, python-format -msgid "File %(name)s was successfully deleted" -msgstr "Soubor %(name)s byl úspěšně odstraněn" +msgid "Another file %(id)s for %(name)s is already in queue" +msgstr "Jiný Soubor (%(id)s) for %(name)s je již ve frontě" -#: pdf/views.py:113 -msgid "Required parameters not found" -msgstr "Požadované parametry nenalezeny" +#: pdf/views/templates.py:67 +#, python-format +msgid "Scheduled File (%(id)s) generation from template %(template)s" +msgstr "Generování souboru (%(id)s) ze šablony %(template)s bylo naplánováno" -#: pdf/views.py:118 -msgid "You need to select at least one song" -msgstr "Musíte vybrat alespoň jednu písničku" +#: pdf/views/templates.py:169 +#, python-format +msgid "File Template '%(name)s' was successfully updated" +msgstr "Šablona %(name)s byla úspěšně upravena" -#: pdf/views.py:143 +#: pdf/views/templates.py:174 #, python-format -msgid "PDF Request with id %(id)s was successfully created" -msgstr "PDF požadavek se id %(id)s byl úspěšně vytvořen" +msgid "File Template '%(name)s' was successfully created" +msgstr "Šablona %(name)s byl úspěšně vytvořena" diff --git a/pdf/management/commands/generate_pdf.py b/pdf/management/commands/generate_pdf.py index 6fcdcb3..a8aea42 100644 --- a/pdf/management/commands/generate_pdf.py +++ b/pdf/management/commands/generate_pdf.py @@ -9,8 +9,8 @@ from django.core.management import BaseCommand from category.models import Category -from pdf.models.request import PDFRequest, Status -from pdf.utils import generate_new_pdf_request +from pdf.generate import generate_pdf_file +from tenants.models import Tenant logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) @@ -19,40 +19,36 @@ class Command(BaseCommand): """Generates PDF according to the PDF requests""" - help = "Generates PDFs that were requested" + help = "Generates PDFs for all categories" def add_arguments(self, parser: ArgumentParser): parser.add_argument( - "requests", - metavar="Requests", + "tenants", + metavar="Tenants", type=int, - nargs="?", - default="0", - help="Number of requests to process, will process all requests if not value is specified", + nargs="+", + default=[], + help="Tenants IDs for which generate pdfs, if none specified, all tenants are considered", ) - parser.add_argument("--all", action="store_true", help="Regenerates PDF for all categories") # pylint: disable=too-many-locals def handle(self, *args, **options): Path(f"{settings.MEDIA_ROOT}/{settings.PDF_FILE_DIR}").mkdir(parents=True, exist_ok=True) - all_requests = options["all"] - requests = options["requests"] - - if all_requests: - objects = [ - generate_new_pdf_request(category, force_now=True) - for category in Category.objects.filter(generate_pdf=True) - ] - else: - objects = PDFRequest.objects.filter(status__in={Status.QUEUED, Status.SCHEDULED}) - if requests: - objects = objects[:requests] - - num = len(objects) - if num == 0: - logger.info("No requests, skipping") + tenants = set(options["tenants"]) + ids = set(Tenant.objects.filter(id__in=tenants).values_list("id", flat=True)) + diff = tenants - ids + if diff: + logger.error("Tenants with ids %s don't exists", diff) return + queryset = Category.objects.filter(generate_pdf=True) + if len(tenants) > 0: + queryset = queryset.filter(tenant__id__in=tenants) + + for category in queryset: + logger.info("Scheduling generation for category %s from Tenant %s", category, category.tenant.name) + objects = [generate_pdf_file(category) for category in queryset] + cache.delete(settings.PDF_CACHE_KEY) - logger.info("Scheduled %i requests", num) + logger.info("Scheduled %i requests", len(objects)) return diff --git a/pdf/menus.py b/pdf/menus.py index 7bcfe57..e91053b 100644 --- a/pdf/menus.py +++ b/pdf/menus.py @@ -5,10 +5,15 @@ from menu import Menu, MenuItem from backend.auth import is_localadmin +from pdf.models.request import ManualPDFTemplate, PDFFile pdf_children = ( - MenuItem(_("Create PDF"), reverse("pdf:new")), - MenuItem(_("PDF Requests"), reverse("pdf:list")), + MenuItem( + _("Generated %(files)s") % {"files": PDFFile._meta.verbose_name_plural}, + reverse("pdf:files:list"), + skip_translate=True, + ), + MenuItem(ManualPDFTemplate._meta.verbose_name_plural, reverse("pdf:templates:list"), skip_translate=True), ) Menu.add_item( diff --git a/pdf/migrations/0030_pdffile_filename_alter_pdftemplate_filename.py b/pdf/migrations/0030_pdffile_filename_alter_pdftemplate_filename.py new file mode 100644 index 0000000..9221c92 --- /dev/null +++ b/pdf/migrations/0030_pdffile_filename_alter_pdftemplate_filename.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.4 on 2024-12-22 17:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pdf", "0029_rename_pdfsong2_pdfsong"), + ] + + operations = [ + migrations.AddField( + model_name="pdffile", + name="filename", + field=models.CharField(default="pdffile", max_length=30), + preserve_default=False, + ), + migrations.AlterField( + model_name="pdftemplate", + name="filename", + field=models.CharField( + help_text="Filename of the generated PDF, please do not include .pdf", + max_length=30, + verbose_name="File name", + ), + ), + ] diff --git a/pdf/templates/pdf/files/list.html b/pdf/templates/pdf/files/list.html new file mode 100644 index 0000000..91594e3 --- /dev/null +++ b/pdf/templates/pdf/files/list.html @@ -0,0 +1,92 @@ +{% extends "base/frame.html" %} +{% load utils %} +{% load i18n %} +{% load types %} +{% load static %} + +{% block title %}{% trans "Files" %}{% endblock %} +{% block header %}{% trans "Files" %}{% endblock %} + +{% block extra_head %} + + + + +{% endblock %} + +{% block framed_body %} +
+ + + + + + + + + + + + + + + {% for pdf in files %} + + + {% if pdf.file %} + + {% else %} + + {% endif %} + + + + + {% if pdffile.template %} + + {% else %} + + {% endif %} + + + {% endfor %} + +
{% trans "Filename" %}{% trans "Link" %}{% trans "Public" %}{% trans "Title" %}{% trans "Status" %}{% trans "Last update" %}{% trans "PDF Template" %}{% trans "Actions" %}
{{ pdf.filename }}.pdf{{ pdf.file.url }}{% trans "None" %}{{ pdf.public|yesno }}{{ pdf.title }}{{ pdf.get_status_display }} + {% if pdf.status == "PR" %}{{ pdf.progress }}/7 {% endif %} {% if pdf.status == "SC" %} + {{ pdf.scheduled_at }} {% endif %}{{ pdf.update_date }}{{ pdf.template.name }}{% trans "None" %} +
+ {% trans "Edit" %} + + +
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/pdf/templates/pdf/requests/wait.html b/pdf/templates/pdf/files/wait.html similarity index 73% rename from pdf/templates/pdf/requests/wait.html rename to pdf/templates/pdf/files/wait.html index 5d7a1fc..15f8f10 100644 --- a/pdf/templates/pdf/requests/wait.html +++ b/pdf/templates/pdf/files/wait.html @@ -19,26 +19,26 @@ '{% trans "Adding PDF metadata" %}', ] let stop = false - const status = $("#status") - const progress = $("#progress") + const status = document.getElementById("status") + const progress = document.getElementById("progress") + const link = document.getElementById("link") function setStatusCode(code) { const value = Math.ceil(code / 7 * 100) - progress.text(code + "/7") - progress.attr("aria-valuenow", value) - progress.attr("style", "width: " + value + "%") + progress.innerText = code + "/7" + progress.setAttribute("aria-valuenow", value) + progress.setAttribute("style", "width: " + value + "%") } function process_response(data) { console.log(data); setStatusCode(data["progress"]) if (data["ready"]) { - status.text("Completed") + status.innerText ="{% trans "Completed" %}" console.log("Ready for download"); - progress.removeClass("progress-bar-striped") - progress.addClass("bg-success") - const link = $("#link") - link.text(data["link"]) - link.attr("href", data["link"]) - link.click() + progress.classList.remove("progress-bar-striped") + progress.classList.add("bg-success") + link.innerText = data["link"] + link.setAttribute("href", data["link"]) + {#link.click()#} return true } status.text(statuses[data["progress"]]) @@ -48,7 +48,7 @@ const poll = (promiseFn, duration) => promiseFn().then( (stop) => !stop ? sleep(duration).then(() => poll(promiseFn, duration)): false); - poll(() => fetch('{% url "pdf:info" pk=pdf.id %}') + poll(() => fetch('{% url "pdf:files:info" pk=pdf.id %}') .then(response => response.json()) .then(process_response) .catch(err => { @@ -70,9 +70,12 @@ - {% trans "No title" as no_title %} {% trans "Title" %} - {{ pdf.title|default:no_title }} + {% if pdf.template and pdf.template.title %} + {{ pdf.template.title }} + {% else %} + {% trans "No title" %} + {% endif %} {% trans "Progress" %} @@ -80,7 +83,6 @@
- @@ -93,7 +95,6 @@ + {% trans "Back to all files" %}
- - {% endblock %} \ No newline at end of file diff --git a/pdf/templates/pdf/index.html b/pdf/templates/pdf/index.html index ef5f197..70654a8 100644 --- a/pdf/templates/pdf/index.html +++ b/pdf/templates/pdf/index.html @@ -44,14 +44,14 @@

{% if request.title %}{{ name }}{% endif %}

{% endif %}

{% trans "Table of Contents" %}

    - {% for song in sorted_songs %} + {% for _,song in sorted_songs %}
  • {% endfor %}
- {% for song in songs %} + {% for song_number, song in songs %}
-

{{ song.song_number }}. {{ song.name }}

+

{{ song_number }}. {{ song.name }}

{{ song.author }} {% if song.capo > 0 %}Capo {{ song.capo }} {% endif %}

diff --git a/pdf/templates/pdf/requests/list.html b/pdf/templates/pdf/requests/list.html deleted file mode 100644 index a071c4a..0000000 --- a/pdf/templates/pdf/requests/list.html +++ /dev/null @@ -1,123 +0,0 @@ -{% extends "base/frame.html" %} -{% load utils %} -{% load i18n %} -{% load types %} -{% load static %} - -{% block title %} {% trans "PDF Requests" %} {% endblock %} -{% block header %} {% trans "PDF Requests" %} {% endblock %} - -{% block extra_head %} - - - - -{% endblock %} - -{% block framed_body %} -
- - - - - -
- -
- - - - - - - - - - - - - - - - {% for request in requests %} - - - - - - - - - - - - {% endfor %} - -
{% trans "Title" %}{% trans "Last updated" %}HIDDEN{% trans "Type" %}{% trans "Status" %}{% trans "Link" %}{% trans "Time elapsed" %}{% trans "Category" %}{% trans "Actions" %}
{{ request.title }}{{ request.update_date }}{{ request.type }}{{ request.get_type_display }}{{ request.get_status_display }} {% if request.status == "PR" %}{{ request.progress }}/7 {% endif %} {% if request.status == "SC" %} {{ request.scheduled_at }} {% endif %} - {% if request.file %} - {{ request.file|filename }} - {% else %} - {% trans "None" %} - {% endif %} - {{ request.time_elapsed|default_if_none:0 }} s - {% if request.category %} - {{ request.category.name }} - {% else %} - {% trans "None" %} - {% endif %} - -
- {% if request.status != "QU" and requests.status != "SC" %} - {% trans "Regenerate" %} - {% else %} - - {% endif %} - - -
-
-
- -{% endblock %} \ No newline at end of file diff --git a/pdf/templates/pdf/requests/assign.html b/pdf/templates/pdf/templates/assign.html similarity index 86% rename from pdf/templates/pdf/requests/assign.html rename to pdf/templates/pdf/templates/assign.html index 8a86ba3..12db5a1 100644 --- a/pdf/templates/pdf/requests/assign.html +++ b/pdf/templates/pdf/templates/assign.html @@ -3,8 +3,8 @@ {% load django_bootstrap5 %} {% load static %} -{% block header %} {% trans "PDF details" %} {% endblock %} -{% block title %} {% trans "PDF details" %} {% endblock %} +{% block header %} {% trans "Song order" %} {% endblock %} +{% block title %} {% trans "Song order" %} {% endblock %} {% block extra_head %} @@ -12,10 +12,12 @@ {% endblock %} {% block framed_body %} -

{% trans "Please, now please fill all the details for the PDF and choose song ordering" %}

-
+

{% trans "Please, choose song order and their numbers" %}

+ {% csrf_token %} +
{% bootstrap_form form %} +
{{ formset.management_form }} {{ formset.non_form_errors.as_ul }}

{% trans "Song numbers" %}

@@ -69,8 +71,5 @@

{% trans "Song numbers" %}

element.attr("style", "max-width: 70px;"); {#element.attr("disabled", "");#} } - window.addEventListener("load", function(){ - - }); {% endblock %} \ No newline at end of file diff --git a/pdf/templates/pdf/templates/list.html b/pdf/templates/pdf/templates/list.html new file mode 100644 index 0000000..fc29976 --- /dev/null +++ b/pdf/templates/pdf/templates/list.html @@ -0,0 +1,109 @@ +{% extends "base/frame.html" %} +{% load utils %} +{% load i18n %} +{% load types %} +{% load static %} + +{% block title %}{% trans "File Templates" %}{% endblock %} +{% block header %}{% trans "File Templates" %}{% endblock %} +{% block extra_header %} + +{% endblock %} + +{% block extra_head %} + + + + +{% endblock %} + +{% block framed_body %} + +
+ + + + + + + + + + + + + + + + {% for template in templates %} + + + + + + + {% with template.latest_file as pdf %} + {% if pdf %} + + + + {% else %} + + + + {% endif %} + {% endwith %} + + + {% endfor %} + +
{% trans "Name" %}{% trans "Filename" %}{% trans "Public" %}{% trans "Number of songs" %}{% trans "Number of files" %}{% trans "Last file" %}{% trans "Status" %}{% trans "Last update" %}{% trans "Actions" %}
{{ template.name }}{{ template.filename }}.pdf{{ template.public|yesno }}{{ template.song_count }}{{ template.file_count }} + {% if pdf.file %} + {{ pdf.name }} + {% else %} + {% trans "None" %} + {% endif %} + {{ pdf.get_status_display }} {% if pdf.status == "PR" %}{{ pdf.progress }}/7 {% endif %} {% if pdf.status == "SC" %} {{ pdf.scheduled_at }} {% endif %}{{ pdf.update_date }}{% trans "No generated files" %} +
+ {% if template.has_scheduled_file %} + + {% else %} + {% trans "Generate" %} + {% endif %} + {% trans "Edit" %} + + +
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/pdf/templates/pdf/requests/select.html b/pdf/templates/pdf/templates/select.html similarity index 66% rename from pdf/templates/pdf/requests/select.html rename to pdf/templates/pdf/templates/select.html index f0a2b7c..42d1f68 100644 --- a/pdf/templates/pdf/requests/select.html +++ b/pdf/templates/pdf/templates/select.html @@ -3,10 +3,11 @@ {% load utils %} {% load i18n %} {% load types %} +{% load django_bootstrap5 %} {% load sass_tags %} -{% block title %} {% trans "PDF Requests" %} {% endblock %} -{% block header %} {% trans "PDF Requests" %} {% endblock %} +{% block title %} {% trans "Create new File Template" %} {% endblock %} +{% block header %} {% trans "Create new File Template" %} {% endblock %} {% block extra_head %} @@ -15,11 +16,18 @@ {% endblock %} {% block framed_body %} -

{% trans "Please select which songs would you like to include in the pdf" %}

+{#

{% trans "Please, now please fill all the details for the PDF and choose song ordering" %}

#} + + {% csrf_token %} + {% bootstrap_form songs_form %} + {% bootstrap_form template_form %} + +
+

{% trans "Please enter file configuration option and select which songs you would like to include" %}

{% for category in categories %}
- +
{% endfor %} @@ -48,19 +56,19 @@ {% endfor %} - {% endblock %} \ No newline at end of file diff --git a/pdf/templatetags/types.py b/pdf/templatetags/types.py index 3029fee..0d6c9c3 100644 --- a/pdf/templatetags/types.py +++ b/pdf/templatetags/types.py @@ -1,7 +1,5 @@ """Template tags for transforming PDFRequest column types""" -import os - from django import template from pdf.models.request import Status @@ -19,9 +17,3 @@ def get_status_color(status): if status == str(Status.DONE): return "green" return "red" - - -@register.filter -def filename(file): - """Returns filename of the absolute path""" - return os.path.basename(file.name) diff --git a/pdf/urls.py b/pdf/urls.py index 67d239f..3a03070 100644 --- a/pdf/urls.py +++ b/pdf/urls.py @@ -1,25 +1,43 @@ """Url configuration for PDF app""" -from django.urls import path +from django.urls import path, include -from pdf.views import ( - RequestListView, - RequestSongSelectorView, - RequestNumberSelectView, - RequestRemoveFileView, - RequestRegenerateView, - WaitForPDFView, +from pdf.views.files import ( RenderInfoView, - RequestMoveView, + FileDeleteView, + FileUpdateView, + FileListView, + MovePDFTemplatesView, + WaitForFileView, +) +from pdf.views.templates import ( + UpdateTemplateView, + TemplateListView, + TemplateDeleteView, + TemplateNumberSelectView, + GenerateFromTemplateView, ) -urlpatterns = [ - path("list", RequestListView.as_view(), name="list"), - path("new", RequestSongSelectorView.as_view(), name="new"), - path("assign", RequestNumberSelectView.as_view(), name="assign"), - path("wait/", WaitForPDFView.as_view(), name="wait"), +template_patterns = [ + path("list", TemplateListView.as_view(), name="list"), + path("new", UpdateTemplateView.as_view(), name="new"), + path("edit/", UpdateTemplateView.as_view(), name="edit"), + path("delete/", TemplateDeleteView.as_view(), name="delete"), + path("assign", TemplateNumberSelectView.as_view(), name="assign"), + path("assign/", TemplateNumberSelectView.as_view(), name="assign"), + path("/generate", GenerateFromTemplateView.as_view(), name="generate"), + path("move", MovePDFTemplatesView.as_view(), name="move"), +] + +file_patterns = [ + path("wait/", WaitForFileView.as_view(), name="wait"), path("info/", RenderInfoView.as_view(), name="info"), - path("remove_file/", RequestRemoveFileView.as_view(), name="remove_file"), - path("regenerate/", RequestRegenerateView.as_view(), name="regenerate"), - path("move", RequestMoveView.as_view(), name="move"), + path("delete/", FileDeleteView.as_view(), name="delete"), + path("edit/", FileUpdateView.as_view(), name="edit"), + path("list", FileListView.as_view(), name="list"), +] + +urlpatterns = [ + path("templates/", include((template_patterns, "pdf"), namespace="templates")), + path("files/", include((file_patterns, "pdf"), namespace="files")), ] diff --git a/pdf/utils.py b/pdf/utils.py deleted file mode 100644 index 0d7e5c3..0000000 --- a/pdf/utils.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Utility functions""" - -import logging -from datetime import timedelta - -from django.db import transaction -from django.utils import translation -from django.utils.timezone import now -from django.utils.translation import gettext - -from pdf.generate import schedule_generation -from pdf.models.request import PDFRequest, RequestType, Status, PDFSong - -log = logging.getLogger(__name__) - - -def request_pdf_regeneration(category, update: bool = False): - """Requests automatic PDF regeneration if none is pending""" - objects = PDFRequest.objects.filter(type=RequestType.EVENT, status=Status.SCHEDULED, category=category) - if not objects.exists(): - generate_new_pdf_request(category) - elif not update: - regenerate_pdf_request(objects.first(), category) - - -def regenerate_pdf_request(request, category): - """Regenerates the PDF request with the newest info""" - with transaction.atomic(): - PDFSong.objects.filter(request=request).delete() - PDFSong.objects.bulk_create( - [ - PDFSong(request=request, song=song, song_number=song_number + 1) - for song_number, song in enumerate(category.song_set.filter(archived=False).all()) - ] - ) - return request - - -def generate_new_pdf_request(category, force_now=False): - """Returns PDFRequest for a category""" - with transaction.atomic(): - request = PDFRequest(type=RequestType.EVENT, status=Status.SCHEDULED, category=category, tenant=category.tenant) - request.copy_options(category) - request.filename = request.filename or get_filename(category) - request.save() - PDFSong.objects.bulk_create( - [ - PDFSong(request=request, song=song, song_number=song_number + 1) - for song_number, song in enumerate(category.song_set.filter(archived=False).all()) - ] - ) - - if force_now: - job = schedule_generation(request, 0) - request.scheduled_at = now() - else: - job = schedule_generation(request, 30 * 60) - request.scheduled_at = now() + timedelta(minutes=30) - request.save() - - return request, job - - -def get_filename(category): - """Returns filename for category based on its locale""" - with translation.override(category.locale): - text = gettext("songbook") - return f"{text}-{category.name}" diff --git a/pdf/views.py b/pdf/views.py deleted file mode 100644 index a53f85c..0000000 --- a/pdf/views.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Views for PDF app""" - -from django.conf import settings -from django.contrib import messages -from django.core.cache import cache -from django.db import transaction -from django.forms import formset_factory -from django.http import HttpResponseBadRequest, JsonResponse -from django.shortcuts import redirect -from django.utils.translation import gettext_lazy as _ -from django.views.generic import ListView -from django.views.generic.base import TemplateResponseMixin, View -from django.views.generic.detail import SingleObjectMixin, DetailView - -from backend.mixins import LocalAdminRequired -from backend.models import Song -from category.forms import NameForm -from category.models import Category -from pdf.forms import RequestForm, PDFSongForm, BasePDFSongFormset -from pdf.generate import generate_pdf_job -from pdf.models.request import PDFRequest, RequestType, Status -from tenants.views import AdminMoveView - - -class RequestListView(LocalAdminRequired, ListView): - """Lists all the requests""" - - model = PDFRequest - template_name = "pdf/requests/list.html" - context_object_name = "requests" - - def get_queryset(self): - return super().get_queryset().filter(tenant=self.request.tenant) - - -class RequestRegenerateView(LocalAdminRequired, View, SingleObjectMixin): - """Regenerates the PDF request""" - - model = PDFRequest - - # pylint: disable=invalid-name, unused-argument - def get(self, request, pk): - """Processes the request""" - obj = self.get_object() - if obj.status == Status.QUEUED: - messages.error(request, _("Request %(id)s is already in queue") % {"id": obj.id}) - return redirect("pdf:list") - obj.status = Status.QUEUED - obj.save() - generate_pdf_job(obj) - - messages.success(request, _("Request %(id)s was scheduled for regeneration") % {"id": obj.id}) - return redirect("pdf:list") - - -class RequestRemoveFileView(LocalAdminRequired, View, SingleObjectMixin): - """Removes file from request""" - - model = PDFRequest - - # pylint: disable=invalid-name, unused-argument - def get(self, request, pk): - """Processes the request""" - obj = self.get_object() - if not obj.file: - messages.error( - request, - _("Unable to remove file from request %(id)s that doesn't have one") % {"id": obj.id}, - ) - return redirect("pdf:list") - name = obj.file.name - obj.file.delete() - obj.save() - - messages.success(request, _("File %(name)s was successfully deleted") % {"name": name}) - cache.delete(settings.PDF_CACHE_KEY) - return redirect("pdf:list") - - -class RequestSongSelectorView(LocalAdminRequired, ListView): - """Starts process of creating new PDFRequest by selecting songs for the request""" - - model = Song - context_object_name = "songs" - template_name = "pdf/requests/select.html" - - def get_queryset(self): - return super().get_queryset().filter(categories__tenant=self.request.tenant) - - def get_context_data(self, *, object_list=None, **kwargs): - ctx = super().get_context_data(object_list=object_list, **kwargs) - categories = list(Category.objects.filter(tenant=self.request.tenant).all()) - ctx["categories"] = categories - ctx["slugs"] = list(map(lambda c: c.slug, categories)) - return ctx - - -class RequestNumberSelectView(LocalAdminRequired, TemplateResponseMixin, View): - """Assign song numbers for PDF request""" - - template_name = "pdf/requests/assign.html" - PDFSongFormset = formset_factory(PDFSongForm, formset=BasePDFSongFormset, min_num=1, validate_min=True, extra=0) - - def render_to_response(self, context, **response_kwargs): - context.update() - return super().render_to_response(context, **response_kwargs) - - # pylint: disable=unused-argument - def get(self, request, *args, **kwargs): - """GET request method handler""" - if "songs" not in request.GET: - return HttpResponseBadRequest(_("Required parameters not found")) - - songs_ids = request.GET.getlist("songs") - songs = Song.objects.filter(id__in=songs_ids) - if songs.count() == 0: - return HttpResponseBadRequest(_("You need to select at least one song")) - - form = RequestForm(instance=PDFRequest(type=RequestType.MANUAL, tenant=self.request.tenant), prefix="request") - formset = self.PDFSongFormset( - prefix="songs", - initial=[{"name": song.name, "song_number": number + 1, "song": song} for number, song in enumerate(songs)], - ) - return self.render_to_response({"form": form, "formset": formset}) - - # pylint: disable=unused-argument - def post(self, request, *args, **kwargs): - """POST request method handler""" - form = RequestForm(self.request.POST, request.FILES, prefix="request") - formset = self.PDFSongFormset(self.request.POST, prefix="songs") - if form.is_valid() and formset.is_valid(): - request = form.instance - request.type = RequestType.MANUAL - request.tenant = self.request.tenant - with transaction.atomic(): - request.save() - for form in formset: - form.instance.request = request - form.instance.save() - messages.success( - self.request, - _("PDF Request with id %(id)s was successfully created") % {"id": request.id}, - ) - generate_pdf_job(request) - return redirect("pdf:wait", request.id) - return self.form_invalid(form, formset) - - def form_invalid(self, form, formset): - """If the form is invalid, render the invalid form.""" - return self.render_to_response({"form": form, "formset": formset}) - - -class WaitForPDFView(DetailView): - """Shows wait page for PDF generation""" - - model = PDFRequest - context_object_name = "pdf" - template_name = "pdf/requests/wait.html" - - -class RenderInfoView(View, SingleObjectMixin): - """Returns JSON response containing info about PDFRequest""" - - model = PDFRequest - - def get(self, request, *args, **kwargs): - """Process GET request""" - request = self.get_object() - ready = request.status == Status.DONE - return JsonResponse( - { - "ready": request.status == Status.DONE, - "progress": request.progress, - "link": request.file.url if ready else None, - } - ) - - -class RequestMoveView(AdminMoveView): - """Moves Requests to a different Tenant""" - - formset_form = NameForm - model = PDFRequest - - def action(self, target, ids): - requests = PDFRequest.objects.filter(id__in=ids).distinct() - with transaction.atomic(): - for request in requests: - request.tenant = target - PDFRequest.objects.bulk_update(requests, ["tenant"]) diff --git a/pdf/views/__init__.py b/pdf/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pdf/views/files.py b/pdf/views/files.py new file mode 100644 index 0000000..5f963e6 --- /dev/null +++ b/pdf/views/files.py @@ -0,0 +1,94 @@ +"""PDFFile related views""" + +from django.conf import settings +from django.core.cache import cache +from django.db import transaction +from django.http import JsonResponse +from django.urls import reverse_lazy +from django.views import View +from django.views.generic import ListView, DetailView +from django.views.generic.detail import SingleObjectMixin + +from backend.generic import UniversalDeleteView, UniversalUpdateView +from backend.mixins import LocalAdminRequired +from category.forms import NameForm +from pdf.forms import PDFFileEditForm +from pdf.models import Status +from pdf.models.request import PDFFile, ManualPDFTemplate +from tenants.views import AdminMoveView + + +class FileListView(LocalAdminRequired, ListView): + """Lists all the files""" + + model = PDFFile + template_name = "pdf/files/list.html" + context_object_name = "files" + + def get_queryset(self): + return super().get_queryset().filter(tenant=self.request.tenant) + + +class FileDeleteView(LocalAdminRequired, UniversalDeleteView): + """Deletes File""" + + model = PDFFile + success_url = reverse_lazy("pdf:file_list") + + def post(self, request, *args, **kwargs): + response = super().post(request, *args, **kwargs) + cache.delete(settings.PDF_CACHE_KEY) + return response + + +class FileUpdateView(LocalAdminRequired, UniversalUpdateView): + """Updates File""" + + form_class = PDFFileEditForm + model = PDFFile + success_url = reverse_lazy("pdf:file_list") + + def post(self, request, *args, **kwargs): + response = super().post(request, *args, **kwargs) + cache.delete(settings.CATEGORY_CACHE_KEY) + return response + + +class WaitForFileView(DetailView): + """Shows wait page for PDF generation""" + + model = PDFFile + context_object_name = "pdf" + template_name = "pdf/files/wait.html" + + +class RenderInfoView(View, SingleObjectMixin): + """Returns JSON response containing info about PDFFile""" + + model = PDFFile + + def get(self, request, *args, **kwargs): + """Process GET request""" + pdf_file = self.get_object() + ready = pdf_file.status == Status.DONE + return JsonResponse( + { + "ready": ready, + "progress": pdf_file.progress, + "link": pdf_file.file.url if ready else None, + } + ) + + +class MovePDFTemplatesView(AdminMoveView): + """Moves Templates to a different Tenant""" + + formset_form = NameForm + model = ManualPDFTemplate + + def action(self, target, ids): + requests = ManualPDFTemplate.objects.filter(id__in=ids).distinct() + with transaction.atomic(): + for request in requests: + request.tenant = target + ManualPDFTemplate.objects.bulk_update(requests, ["tenant"]) diff --git a/pdf/views/templates.py b/pdf/views/templates.py new file mode 100644 index 0000000..4388a44 --- /dev/null +++ b/pdf/views/templates.py @@ -0,0 +1,183 @@ +"""(Manual)PDFTemplate related views""" + +from typing import Iterable + +from django.contrib import messages +from django.db import transaction +from django.db.models import Count +from django.forms import formset_factory +from django.shortcuts import redirect, render +from django.urls import reverse_lazy, reverse +from django.utils.translation import gettext_lazy as _ +from django.views import View +from django.views.generic import ListView, TemplateView +from django.views.generic.detail import SingleObjectMixin + +from backend.generic import UniversalDeleteView +from backend.mixins import LocalAdminRequired +from backend.models import Song +from category.models import Category +from pdf.forms import PDFSongForm, BasePDFSongFormset, ManualTemplateForm, SongSelectionForm +from pdf.generate import AllowedTemplates, generate_pdf_file +from pdf.models import PDFTemplate +from pdf.models.request import ManualPDFTemplate + + +class TemplateListView(LocalAdminRequired, ListView): + """Lists all the requests""" + + model = ManualPDFTemplate + template_name = "pdf/templates/list.html" + context_object_name = "templates" + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(tenant=self.request.tenant) + .annotate(song_count=Count("songs")) + .annotate(file_count=Count("pdffile")) + ) + + +class TemplateDeleteView(LocalAdminRequired, UniversalDeleteView): + """Removes Template""" + + model = ManualPDFTemplate + success_url = reverse_lazy("pdf:templates:list") + + +class GenerateFromTemplateView(LocalAdminRequired, View, SingleObjectMixin): + """Regenerates the PDF request""" + + model = PDFTemplate + + # pylint: disable=unused-argument + def get(self, request, pk): + """Processes the request""" + obj: AllowedTemplates = self.get_object() + latest_file = obj.latest_file + if obj.has_scheduled_file(): + messages.error( + request, + _("Another file %(id)s for %(name)s is already in queue") % {"id": latest_file.id, "name": obj.name}, + ) + return redirect("pdf:list") + file = generate_pdf_file(obj) + messages.success( + request, + _("Scheduled File (%(id)s) generation from template %(template)s") % {"id": file.id, "template": obj.name}, + ) + return redirect("pdf:files:wait", pk=file.id) + + +class TemplateNumberingMixin: + """Enables rendering of assign template with all required parameters""" + + PDFSongFormset = formset_factory(PDFSongForm, formset=BasePDFSongFormset, min_num=1, validate_min=True, extra=0) + + def render_assign_template(self, form, formset=None, songs: Iterable[Song] | None = None): + """Renders assign template""" + formset = formset or self.PDFSongFormset( + prefix="songs", + initial=[ + {"name": song.name, "song_number": number, "song": song} for number, song in enumerate(songs, start=1) + ], + ) + if "pk" in self.kwargs: + assign_url = reverse("pdf:templates:assign", kwargs={"pk": self.kwargs.get("pk")}) + else: + assign_url = reverse("pdf:templates:assign") + return render( + self.request, "pdf/templates/assign.html", {"form": form, "formset": formset, "action": assign_url} + ) + + +class UpdateTemplateView(LocalAdminRequired, TemplateNumberingMixin, TemplateView): + """Starts process of creating new PDFRequest by selecting songs for the request""" + + PREFIX = "template" + context_object_name = "songs" + template_name = "pdf/templates/select.html" + + def get_context_data(self, *, object_list=None, template_form=None, songs_form=None, **kwargs): + ctx = super().get_context_data(object_list=object_list, **kwargs) + + songs = [] + if not template_form: + if "pk" in self.kwargs: + template = ManualPDFTemplate.objects.get(pk=self.kwargs["pk"], tenant=self.request.tenant) + songs = list(template.songs.values_list("id", flat=True).distinct()) + template_form = ManualTemplateForm( + instance=ManualPDFTemplate.objects.get(pk=self.kwargs["pk"], tenant=self.request.tenant), + prefix=self.PREFIX, + ) + else: + template_form = ManualTemplateForm( + initial={"tenant": self.request.tenant, "locale": self.request.LANGUAGE_CODE}, + prefix=self.PREFIX, + ) + ctx["template_form"] = template_form + + if not songs_form: + songs_form = SongSelectionForm(initial={"songs": songs}, request=self.request) + ctx["songs_form"] = songs_form + ctx["songs"] = songs_form.queryset + ctx["initial_songs"] = songs_form.get_list_field_value("songs", []) + + queryset = Category.objects.filter(tenant=self.request.tenant).all() + ctx["categories"] = queryset + ctx["slugs"] = list(queryset.values_list("slug", flat=True)) + return ctx + + def post(self, request, *args, **kwargs): + """Handles post request, shows assign.html to user to assign numbers to requested songs""" + template_form = ManualTemplateForm(self.request.POST, request.FILES, prefix=self.PREFIX) + songs_form = SongSelectionForm(self.request.POST, request.FILES, request=self.request) + if template_form.is_valid() and songs_form.is_valid(): + return self.render_assign_template(template_form, songs=songs_form.cleaned_data["songs"]) + return self.render_to_response(self.get_context_data(template_form=template_form, songs_form=songs_form)) + + +class TemplateNumberSelectView(LocalAdminRequired, TemplateNumberingMixin, View): + """Assign song numbers for PDF request""" + + PDFSongFormset = formset_factory(PDFSongForm, formset=BasePDFSongFormset, min_num=1, validate_min=True, extra=0) + + def get(self, *args, **kwargs): + """Redirect back to the start of the process""" + if "pk" in self.kwargs: + return redirect(reverse("pdf:templates:edit", kwargs={"pk": self.kwargs.get("pk")})) + return redirect(reverse("pdf:templates:new")) + + # pylint: disable=unused-argument + def post(self, template, *args, **kwargs): + """POST request method handler""" + form = ManualTemplateForm(self.request.POST, template.FILES, prefix="template") + formset = self.PDFSongFormset(self.request.POST, prefix="songs") + if form.is_valid() and formset.is_valid(): + template = form.instance + template.tenant = self.request.tenant + template.pdftemplate_ptr_id = self.kwargs.get("pk") + with transaction.atomic(): + form.save() + template.songs.clear() + for form in formset: + form.instance.request = template + form.save() + if "pk" in self.kwargs: + messages.success( + self.request, + _("File Template '%(name)s' was successfully updated") % {"name": template.name}, + ) + else: + messages.success( + self.request, + _("File Template '%(name)s' was successfully created") % {"name": template.name}, + ) + return redirect("pdf:templates:list") + return self.form_invalid(form, formset) + + def form_invalid(self, form, formset): + """If the form is invalid, render the invalid form.""" + return self.render_assign_template(form, formset) diff --git a/tenants/locale/cs/LC_MESSAGES/django.po b/tenants/locale/cs/LC_MESSAGES/django.po index ae5223d..97b0bba 100644 --- a/tenants/locale/cs/LC_MESSAGES/django.po +++ b/tenants/locale/cs/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-12-25 17:34+0000\n" +"POT-Creation-Date: 2024-12-25 19:28+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -23,19 +23,19 @@ msgstr "" msgid "Tenant" msgstr "Zpěvník" -#: tenants/menus.py:27 +#: tenants/menus.py:24 msgid "PDF" msgstr "" -#: tenants/menus.py:37 +#: tenants/menus.py:34 msgid "Categories" msgstr "Kategorie" -#: tenants/menus.py:50 +#: tenants/menus.py:47 msgid "See Also" msgstr "Jiné stránky" -#: tenants/menus.py:68 tenants/models.py:40 +#: tenants/menus.py:65 tenants/models.py:40 msgid "All Songs" msgstr "Všechno"