diff --git a/backend/migrations/0013_alter_song_categories.py b/backend/migrations/0013_alter_song_categories.py
new file mode 100644
index 0000000..8361b60
--- /dev/null
+++ b/backend/migrations/0013_alter_song_categories.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.1.4 on 2024-12-20 18:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("backend", "0012_rename_prerendered_pdf_song_prerendered_and_more"),
+ ("category", "0013_category2"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="song",
+ name="categories",
+ field=models.ManyToManyField(to="category.category2", verbose_name="Categories"),
+ ),
+ ]
diff --git a/backend/models.py b/backend/models.py
index a323b6d..d4870cd 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -14,8 +14,8 @@
from django.utils.translation import gettext_lazy as _
from markdownx.models import MarkdownxField
-from chords.markdown import RENDERER
from category.models import Category
+from chords.markdown import RENDERER
class Song(Model):
diff --git a/category/migrations/0013_category2.py b/category/migrations/0013_category2.py
new file mode 100644
index 0000000..4bb8533
--- /dev/null
+++ b/category/migrations/0013_category2.py
@@ -0,0 +1,77 @@
+# Generated by Django 5.1.4 on 2024-12-20 18:29
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+def recreate_categories(apps, schema_editor):
+ Category = apps.get_model("category", "Category")
+ NewCategory = apps.get_model("category", "Category2")
+ ContentType = apps.get_model("contenttypes", "ContentType")
+ db_alias = schema_editor.connection.alias
+
+ for category in Category.objects.using(db_alias).all():
+ new_category = NewCategory()
+ new_category.polymorphic_ctype_id = ContentType.objects.get_for_model(Category).pk
+ for field in [
+ "pk",
+ "filename",
+ "public",
+ "locale",
+ "title",
+ "show_date",
+ "image",
+ "margin",
+ "link",
+ "generate_pdf",
+ "tenant_id",
+ "slug",
+ "name",
+ ]:
+ setattr(new_category, field, getattr(category, field))
+ new_category.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("category", "0012_alter_category_link"),
+ ("pdf", "0026_pdftemplate_pdffile_alter_pdfrequest_options_and_more"),
+ ("tenants", "0005_link_tenant_links"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Category2",
+ fields=[
+ (
+ "pdftemplate_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="pdf.pdftemplate",
+ ),
+ ),
+ ("name", models.CharField(max_length=100, verbose_name="Name")),
+ ("slug", models.SlugField(max_length=25, verbose_name="URL pattern")),
+ (
+ "generate_pdf",
+ models.BooleanField(
+ help_text="Should the PDF file be automatically generated when a song changes?",
+ verbose_name="PDF generation",
+ ),
+ ),
+ ("tenant", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="tenants.tenant")),
+ ],
+ options={
+ "verbose_name": "Category",
+ "verbose_name_plural": "Categories",
+ "unique_together": {("tenant", "name"), ("tenant", "slug")},
+ },
+ bases=("pdf.pdftemplate",),
+ ),
+ migrations.RunPython(recreate_categories),
+ ]
diff --git a/category/migrations/0014_delete_category.py b/category/migrations/0014_delete_category.py
new file mode 100644
index 0000000..2d91d53
--- /dev/null
+++ b/category/migrations/0014_delete_category.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.1.4 on 2024-12-20 18:51
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("backend", "0013_alter_song_categories"),
+ ("category", "0013_category2"),
+ ]
+
+ operations = [
+ migrations.DeleteModel(
+ name="Category",
+ ),
+ ]
diff --git a/category/migrations/0015_rename_category2_category.py b/category/migrations/0015_rename_category2_category.py
new file mode 100644
index 0000000..3f1a733
--- /dev/null
+++ b/category/migrations/0015_rename_category2_category.py
@@ -0,0 +1,20 @@
+# Generated by Django 5.1.4 on 2024-12-20 18:51
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("backend", "0013_alter_song_categories"),
+ ("category", "0014_delete_category"),
+ ("pdf", "0026_pdftemplate_pdffile_alter_pdfrequest_options_and_more"),
+ ("tenants", "0005_link_tenant_links"),
+ ]
+
+ operations = [
+ migrations.RenameModel(
+ old_name="Category2",
+ new_name="Category",
+ ),
+ ]
diff --git a/category/models.py b/category/models.py
index cdaba8d..45da9d1 100644
--- a/category/models.py
+++ b/category/models.py
@@ -1,14 +1,19 @@
"""Models"""
-from django.db.models import CharField, SlugField, BooleanField, ForeignKey, CASCADE
+from typing import Iterable, Tuple, TYPE_CHECKING
+
+from django.db.models import CharField, SlugField, BooleanField, ForeignKey, CASCADE, Model
from django.utils.translation import gettext_lazy as _
-from pdf.models import PDFOptions
+from pdf.models import PDFTemplate
from tenants.models import Tenant
+if TYPE_CHECKING:
+ from backend.models import Song
+
-class Category(PDFOptions):
- """Represents set of songs"""
+class Category(PDFTemplate):
+ """Song Category"""
tenant = ForeignKey(Tenant, on_delete=CASCADE)
name = CharField(verbose_name=_("Name"), max_length=100)
@@ -18,6 +23,9 @@ class Category(PDFOptions):
help_text=_("Should the PDF file be automatically generated when a song changes?"),
)
+ def get_songs(self) -> Iterable[Tuple[int, "Song"]]:
+ return list(enumerate(self.song_set.filter(archived=False)))
+
def __str__(self):
return self.name
diff --git a/docs/FILES.md b/docs/FILES.md
new file mode 100644
index 0000000..fff47d2
--- /dev/null
+++ b/docs/FILES.md
@@ -0,0 +1,19 @@
+# File handling in Song Book
+
+## Models
+
+### 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
+
+#### ManualPDFTemplate
+#### Category
+* Category is a subclass of PDFTemplate and provides songs
+
+### 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
+
+## Workflow
diff --git a/pdf/admin.py b/pdf/admin.py
index 0a1a966..e858ef8 100644
--- a/pdf/admin.py
+++ b/pdf/admin.py
@@ -11,22 +11,22 @@
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)
+# @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)
diff --git a/pdf/migrations/0026_pdftemplate_pdffile_alter_pdfrequest_options_and_more.py b/pdf/migrations/0026_pdftemplate_pdffile_alter_pdfrequest_options_and_more.py
new file mode 100644
index 0000000..fadae86
--- /dev/null
+++ b/pdf/migrations/0026_pdftemplate_pdffile_alter_pdfrequest_options_and_more.py
@@ -0,0 +1,280 @@
+# Generated by Django 5.1.4 on 2024-12-20 18:42
+from pathlib import Path
+
+import django.core.validators
+import django.db.models.deletion
+from django.db import migrations, models
+
+import pdf.storage
+from pdf.models.request import Status
+
+
+def migrate_pdffiles(apps, schema_editor):
+ PDFRequest = apps.get_model("pdf", "PDFRequest")
+ PDFFile = apps.get_model("pdf", "PDFFile")
+ db_alias = schema_editor.connection.alias
+
+ already_done = set()
+ for request in PDFRequest.objects.using(db_alias).filter(status=Status.DONE).order_by("-update_date").all():
+ if request.file and request.filename not in already_done:
+ already_done.add(request.filename)
+ pdf_file = PDFFile()
+ for field in [
+ "update_date",
+ "filename",
+ "status",
+ "time_elapsed",
+ "progress",
+ "file",
+ "scheduled_at",
+ "public",
+ "tenant",
+ ]:
+ setattr(pdf_file, field, getattr(request, field))
+ if not pdf_file.filename and pdf_file.file:
+ pdf_file.filename = Path(pdf_file.file.name).stem
+ pdf_file.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("backend", "0012_rename_prerendered_pdf_song_prerendered_and_more"),
+ ("contenttypes", "0002_remove_content_type_name"),
+ ("pdf", "0025_remove_pdfrequest_automated_category_present"),
+ ("category", "0012_alter_category_link"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="PDFTemplate",
+ fields=[
+ ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ (
+ "display_name",
+ models.CharField(
+ blank=True,
+ help_text="(Optional) Display name for the generated files. If empty filename will be used",
+ max_length=30,
+ null=True,
+ verbose_name="Display name",
+ ),
+ ),
+ (
+ "filename",
+ models.CharField(
+ help_text="File name of the generated PDF", max_length=30, verbose_name="File name"
+ ),
+ ),
+ (
+ "public",
+ models.BooleanField(
+ default=True,
+ help_text="True, if the file should be visible in the menu",
+ verbose_name="Public file",
+ ),
+ ),
+ (
+ "locale",
+ models.CharField(
+ choices=[("en", "English"), ("cs", "Česky")],
+ help_text="Language of the generated PDF",
+ max_length=5,
+ verbose_name="Language",
+ ),
+ ),
+ (
+ "title",
+ models.CharField(
+ blank=True,
+ help_text="Name to be used on the title page of the PDF, usually only this or Title image should be used",
+ max_length=100,
+ verbose_name="Title",
+ ),
+ ),
+ (
+ "show_date",
+ models.BooleanField(
+ default=True,
+ help_text="True, if the date should be included in the PDF",
+ verbose_name="Show date",
+ ),
+ ),
+ (
+ "image",
+ models.ImageField(
+ blank=True,
+ help_text="(Optional) title image for the PDF",
+ null=True,
+ upload_to="uploads/",
+ verbose_name="Title Image",
+ ),
+ ),
+ (
+ "margin",
+ models.FloatField(
+ default=0,
+ help_text="Margins for title image, might be needed for some printers",
+ verbose_name="Title Image margins",
+ ),
+ ),
+ (
+ "link",
+ models.CharField(
+ blank=True,
+ help_text="(Optional) URL Link to include in the PDF",
+ max_length=300,
+ verbose_name="Link",
+ ),
+ ),
+ (
+ "polymorphic_ctype",
+ models.ForeignKey(
+ editable=False,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="polymorphic_%(app_label)s.%(class)s_set+",
+ to="contenttypes.contenttype",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.CreateModel(
+ name="PDFFile",
+ fields=[
+ ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("update_date", models.DateTimeField(null=True)),
+ (
+ "status",
+ models.CharField(
+ choices=[
+ ("QU", "Queued"),
+ ("SC", "Scheduled"),
+ ("PR", "In progress"),
+ ("DO", "Done"),
+ ("FA", "Failed"),
+ ],
+ default="QU",
+ max_length=2,
+ ),
+ ),
+ ("time_elapsed", models.IntegerField(null=True)),
+ ("progress", models.IntegerField(default=0)),
+ (
+ "file",
+ models.FileField(null=True, upload_to=pdf.models.request.upload_path),
+ ),
+ ("scheduled_at", models.DateTimeField(null=True)),
+ (
+ "public",
+ models.BooleanField(
+ default=True, help_text="True, if the file should be public", verbose_name="Public file"
+ ),
+ ),
+ ("tenant", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="tenants.tenant")),
+ (
+ "filename",
+ models.CharField(
+ help_text="File name of the generated PDF", max_length=30, verbose_name="File name"
+ ),
+ ),
+ (
+ "template",
+ models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to="pdf.pdftemplate"),
+ ),
+ (
+ "display_name",
+ models.CharField(
+ blank=True,
+ help_text="Display name for the file, if empty filename will be used",
+ max_length=30,
+ null=True,
+ verbose_name="Display name",
+ ),
+ ),
+ ],
+ options={"ordering": ["-update_date"], "verbose_name": "File", "verbose_name_plural": "Files"},
+ ),
+ migrations.AlterModelOptions(
+ name="pdffile",
+ options={"verbose_name": "File", "verbose_name_plural": "Files"},
+ ),
+ migrations.RunPython(migrate_pdffiles),
+ migrations.AlterModelOptions(
+ name="pdfrequest",
+ options={"verbose_name": "PDFRequest", "verbose_name_plural": "PDFRequests"},
+ ),
+ migrations.RemoveField(
+ model_name="pdfrequest",
+ name="category",
+ ),
+ migrations.RemoveField(
+ model_name="pdfrequest",
+ name="songs",
+ ),
+ migrations.CreateModel(
+ name="ManualPDFTemplate",
+ fields=[
+ (
+ "pdftemplate_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="pdf.pdftemplate",
+ ),
+ ),
+ ("tenant", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="tenants.tenant")),
+ (
+ "name",
+ models.CharField(
+ help_text="Name of this template, only shown internally",
+ max_length=255,
+ verbose_name="Template Name",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ "base_manager_name": "objects",
+ "verbose_name": "File Template",
+ "verbose_name_plural": "File Templates",
+ },
+ bases=("pdf.pdftemplate",),
+ ),
+ migrations.CreateModel(
+ name="PDFSong2",
+ fields=[
+ ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ (
+ "song_number",
+ models.PositiveIntegerField(
+ validators=[django.core.validators.MinValueValidator(1)], verbose_name="Song number"
+ ),
+ ),
+ ("song", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="backend.song")),
+ ("request", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="pdf.manualpdftemplate")),
+ ],
+ options={
+ "unique_together": {("song_number", "request", "song")},
+ },
+ ),
+ migrations.AddField(
+ model_name="manualpdftemplate",
+ name="songs",
+ field=models.ManyToManyField(through="pdf.PDFSong2", to="backend.song"),
+ ),
+ migrations.AlterModelOptions(
+ name="manualpdftemplate",
+ options={"verbose_name": "File Template", "verbose_name_plural": "File Templates"},
+ ),
+ migrations.AlterModelOptions(
+ name="pdffile",
+ options={"ordering": ["-update_date"], "verbose_name": "File", "verbose_name_plural": "Files"},
+ ),
+ ]
diff --git a/pdf/migrations/0027_pdffile_template_delete_pdfsong.py b/pdf/migrations/0027_pdffile_template_delete_pdfsong.py
new file mode 100644
index 0000000..1ed5bc1
--- /dev/null
+++ b/pdf/migrations/0027_pdffile_template_delete_pdfsong.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.1.4 on 2024-12-20 19:16
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("pdf", "0026_pdftemplate_pdffile_alter_pdfrequest_options_and_more"),
+ ]
+
+ operations = [
+ migrations.DeleteModel(
+ name="PDFSong",
+ ),
+ ]
diff --git a/pdf/migrations/0028_delete_pdfrequest.py b/pdf/migrations/0028_delete_pdfrequest.py
new file mode 100644
index 0000000..aaa8266
--- /dev/null
+++ b/pdf/migrations/0028_delete_pdfrequest.py
@@ -0,0 +1,16 @@
+# Generated by Django 5.1.4 on 2024-12-20 19:54
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("pdf", "0027_pdffile_template_delete_pdfsong"),
+ ]
+
+ operations = [
+ migrations.DeleteModel(
+ name="PDFRequest",
+ ),
+ ]
diff --git a/pdf/migrations/0029_rename_pdfsong2_pdfsong.py b/pdf/migrations/0029_rename_pdfsong2_pdfsong.py
new file mode 100644
index 0000000..63f6176
--- /dev/null
+++ b/pdf/migrations/0029_rename_pdfsong2_pdfsong.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.1.4 on 2024-12-20 19:55
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("backend", "0013_alter_song_categories"),
+ ("pdf", "0028_delete_pdfrequest"),
+ ]
+
+ operations = [
+ migrations.RenameModel(
+ old_name="PDFSong2",
+ new_name="PDFSong",
+ ),
+ ]
diff --git a/pdf/models/__init__.py b/pdf/models/__init__.py
index 6cd127e..421e98d 100644
--- a/pdf/models/__init__.py
+++ b/pdf/models/__init__.py
@@ -1,44 +1,74 @@
"""Common classes for PDF"""
+from abc import abstractmethod
+from functools import lru_cache
+from typing import Iterable, Tuple, TYPE_CHECKING
+
from django.conf import settings
-from django.db.models import Model, BooleanField, CharField, ImageField, FloatField
+from django.db.models import Model, BooleanField, CharField, ImageField, FloatField, TextChoices
+from django.db.models.signals import post_delete
from django.utils.translation import gettext_lazy as _
+from polymorphic.models import PolymorphicModel
+from pdf.storage import file_cleanup
+from tenants.models import Tenant
-class PDFOptions(Model):
- """All options for PDF Generation in a form of a abstract model"""
+if TYPE_CHECKING:
+ from backend.models import Song
- filename = CharField(
+
+class Status(TextChoices):
+ """Status of PDF Request"""
+
+ QUEUED = "QU", _("Queued")
+ SCHEDULED = "SC", _("Scheduled")
+ IN_PROGRESS = "PR", _("In progress")
+ DONE = "DO", _("Done")
+ FAILED = "FA", _("Failed")
+
+
+class PDFTemplate(PolymorphicModel):
+ """All options for PDF Generation in a form of an abstract model"""
+
+ display_name = CharField(
max_length=30,
+ null=True,
blank=True,
- help_text=_("Filename of the generated PDF, please do not include .pdf"),
+ help_text=_("(Optional) Display name for the generated files. If empty filename will be used"),
+ verbose_name=_("Display name"),
+ )
+ filename = CharField(
+ max_length=30,
+ blank=False,
+ null=False,
+ help_text=_("File name of the generated PDF"),
verbose_name=_("File name"),
)
public = BooleanField(
default=True,
- help_text=_("True, if the file should be public"),
+ help_text=_("True, if the file should be visible in the menu"),
verbose_name=_("Public file"),
)
locale = CharField(
choices=settings.LANGUAGES,
verbose_name=_("Language"),
max_length=5,
- help_text=_("Language to be used in the generated PDF"),
+ help_text=_("Language of the generated PDF"),
)
title = CharField(
max_length=100,
blank=True,
- help_text=_("Name to be used on the title page of the PDF"),
+ help_text=_("Name to be used on the title page of the PDF, usually only this or Title image should be used"),
verbose_name=_("Title"),
)
show_date = BooleanField(
default=True,
verbose_name=_("Show date"),
- help_text=_("True, if the date should be included in the final PDF"),
+ help_text=_("True, if the date should be included in the PDF"),
)
image = ImageField(
verbose_name=_("Title Image"),
- help_text=_("Optional title image of the songbook"),
+ help_text=_("(Optional) title image for the PDF"),
null=True,
blank=True,
upload_to="uploads/",
@@ -51,11 +81,29 @@ class PDFOptions(Model):
link = CharField(
max_length=300,
blank=True,
- help_text=_("Link to include in the PDF"),
+ help_text=_("(Optional) URL Link to include in the PDF"),
verbose_name=_("Link"),
)
- def copy_options(self, options: "PDFOptions"):
+ @property
+ @lru_cache
+ def latest_file(self):
+ """Returns latest generated file for this template"""
+ return self.pdffile_set.first()
+
+ @lru_cache
+ def has_scheduled_file(self):
+ """True, if the latest file created from this template is scheduled to be generated"""
+ file = self.latest_file
+ return file and file.status not in [Status.DONE, Status.FAILED]
+
+ @abstractmethod
+ def get_songs(self) -> Iterable[Tuple[int, "Song"]]:
+ """
+ Returns iterable of Song objects and their song number, that should be included in the PDF
+ """
+
+ def copy_options(self, options: "PDFTemplate"):
"""Copy all options from another PDFOptions object"""
self.filename = options.filename
self.locale = options.locale
@@ -66,4 +114,7 @@ def copy_options(self, options: "PDFOptions"):
self.link = options.link
class Meta:
- abstract = True
+ abstract = False
+
+
+post_delete.connect(file_cleanup, sender=PDFTemplate, dispatch_uid="pdftemplate.file_cleanup")
diff --git a/pdf/models/request.py b/pdf/models/request.py
index ca9e8d2..4b72a7b 100644
--- a/pdf/models/request.py
+++ b/pdf/models/request.py
@@ -1,8 +1,7 @@
"""Models for PDF module"""
-import os
from datetime import datetime
-from typing import List
+from typing import Iterable, Tuple
from django.core.validators import MinValueValidator
from django.db.models import (
@@ -15,19 +14,18 @@
ManyToManyField,
ForeignKey,
CASCADE,
- SET_NULL,
PositiveIntegerField,
+ BooleanField,
+ SET_NULL,
)
+from django.db.models.signals import post_delete
from django.utils.translation import gettext_lazy as _
from backend.models import Song
-from category.models import Category
-from pdf.models import PDFOptions
-from pdf.storage import DateOverwriteStorage
+from pdf.models import PDFTemplate, Status
+from pdf.storage import file_cleanup
from tenants.models import Tenant
-fs = DateOverwriteStorage()
-
class RequestType(TextChoices):
"""Type of PDF Request"""
@@ -36,66 +34,80 @@ class RequestType(TextChoices):
MANUAL = "MA", _("Manual")
-class Status(TextChoices):
- """Status of PDF Request"""
-
- QUEUED = "QU", _("Queued")
- SCHEDULED = "SC", _("Scheduled")
- IN_PROGRESS = "PR", _("In progress")
- DONE = "DO", _("Done")
- FAILED = "FA", _("Failed")
-
-
def upload_path(instance, filename):
"""Returns upload path for this request"""
return datetime.now().strftime(f"pdfs/{instance.tenant_id}/%y%m%d/{filename}")
-class PDFRequest(PDFOptions):
- """Request for PDF generation"""
+class ManualPDFTemplate(PDFTemplate):
+ """Template for creating PDF files. Used by users"""
tenant = ForeignKey(Tenant, on_delete=CASCADE)
- update_date = DateTimeField(auto_now=True)
- type = CharField(
- max_length=2,
- choices=RequestType.choices,
- default=RequestType.EVENT,
+ songs = ManyToManyField(Song, through="PDFSong")
+ name = CharField(_("Template Name"), help_text=_("Name of this template, only shown internally"), max_length=255)
+
+ def get_songs(self) -> Iterable[Tuple[int, "Song"]]:
+ """Returns all songs for PDFTemplate"""
+ return [(pdf_song.song_number, pdf_song.song) for pdf_song in PDFSong.objects.filter(request=self)]
+
+ class Meta:
+ verbose_name = _("File Template")
+ verbose_name_plural = _("File Templates")
+
+
+class PDFFile(Model):
+ """PDF File, that is either already generated or await generation"""
+
+ template = ForeignKey(PDFTemplate, on_delete=SET_NULL, null=True)
+ tenant = ForeignKey(Tenant, on_delete=CASCADE)
+
+ filename = CharField(
+ max_length=30,
+ blank=False,
+ null=False,
+ help_text=_("File name of the generated PDF"),
+ verbose_name=_("File name"),
)
+ display_name = CharField(
+ max_length=30,
+ null=True,
+ blank=True,
+ help_text=_("Display name for the file, if empty filename will be used"),
+ verbose_name=_("Display name"),
+ )
+ update_date = DateTimeField(null=True)
status = CharField(max_length=2, choices=Status.choices, default=Status.QUEUED)
time_elapsed = IntegerField(null=True)
progress = IntegerField(default=0)
- file = FileField(null=True, storage=fs, upload_to=upload_path)
- songs = ManyToManyField(Song, through="PDFSong")
- category = ForeignKey(Category, null=True, on_delete=SET_NULL)
+ file = FileField(null=True, upload_to=upload_path)
scheduled_at = DateTimeField(null=True)
+ public = BooleanField(
+ default=True,
+ help_text=_("True, if the file should be public"),
+ verbose_name=_("Public file"),
+ )
@property
def name(self):
- """Returns displayable name"""
- return os.path.basename(self.file.name)
-
- def get_songs(self) -> List[Song]:
- """Returns all songs for request"""
- return [transform_song(pdf_song) for pdf_song in PDFSong.objects.filter(request=self)]
+ """Returns publicly visible name"""
+ if self.display_name:
+ return self.display_name
+ return f"{self.filename}.pdf"
class Meta:
- verbose_name = _("PDFRequest")
- verbose_name_plural = _("PDFRequests")
+ verbose_name = _("File")
+ verbose_name_plural = _("Files")
ordering = ["-update_date"]
-def transform_song(pdf_song: "PDFSong") -> Song:
- """Mapping function that maps PDFSong into Song"""
- song = pdf_song.song
- song.song_number = pdf_song.song_number
- return song
+post_delete.connect(file_cleanup, sender=PDFFile, dispatch_uid="pdffile.file_cleanup")
class PDFSong(Model):
"""Through table for PDFRequest and Song"""
song = ForeignKey(Song, on_delete=CASCADE)
- request = ForeignKey(PDFRequest, on_delete=CASCADE)
+ request = ForeignKey(ManualPDFTemplate, on_delete=CASCADE)
song_number = PositiveIntegerField(validators=[MinValueValidator(1)], verbose_name=_("Song number"))
class Meta:
diff --git a/pdf/storage.py b/pdf/storage.py
index d90f65d..d9d1a0c 100644
--- a/pdf/storage.py
+++ b/pdf/storage.py
@@ -1,8 +1,10 @@
"""Storages for the PDF application"""
+import os
from pathlib import Path
from django.core.files.storage import FileSystemStorage
+from django.db.models import FileField
class DateOverwriteStorage(FileSystemStorage):
@@ -15,3 +17,30 @@ def get_available_name(self, name, max_length=None):
path = Path(name)
self.delete(path)
return path
+
+
+# pylint: disable=protected-access
+def file_cleanup(sender, **kwargs):
+ """
+ File cleanup callback used to emulate the old delete
+ behavior using signals. Initially django deleted linked
+ files when an object containing a File/ImageField was deleted.
+
+ Usage:
+ >>> from django.db.models.signals import post_delete
+ >>> post_delete.connect(file_cleanup, sender=MyModel, dispatch_uid="mymodel.file_cleanup")
+ """
+ for field in sender._meta.get_fields():
+ if field and isinstance(field, FileField):
+ inst = kwargs["instance"]
+ f = getattr(inst, field.name)
+ m = inst.__class__._default_manager
+ if (
+ hasattr(f, "path")
+ and os.path.exists(f.path)
+ and not m.filter(**{f"{f}__exact": True}).exclude(pk=inst._get_pk_val())
+ ):
+ try:
+ field.storage.delete(f.path)
+ except: # pylint: disable=bare-except
+ pass
diff --git a/pyproject.toml b/pyproject.toml
index 061d63a..d23ed69 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,6 +24,7 @@ mistune = ">3"
netifaces = "*"
django-compressor = "*"
libsass = "*"
+django-polymorphic = {git = "https://github.com/jazzband/django-polymorphic.git", rev = "v4.0.0a"}
[tool.poetry.group.production]
optional = true
diff --git a/tenants/menus.py b/tenants/menus.py
index 5b45943..228abd5 100644
--- a/tenants/menus.py
+++ b/tenants/menus.py
@@ -1,6 +1,5 @@
"""Generates Tenant-specific menus"""
-import os
from functools import partial
from django.conf import settings
@@ -8,12 +7,10 @@
from django.dispatch import receiver
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
-
from simple_menu import MenuItem, Menu
from pdf.cachemenuitem import CacheMenuItem
-from pdf.models.request import PDFRequest, Status
-
+from pdf.models.request import Status, PDFFile
from tenants.models import Tenant
from tenants.utils import create_tenant_string
@@ -77,13 +74,13 @@ def distinct_requests(request):
"""
files = set()
data = []
- for entry in PDFRequest.objects.filter(file__isnull=False, status=Status.DONE, tenant=request.tenant).exclude(
- file__exact=""
- ):
- # pylint: disable=protected-access
- if entry.filename not in files and entry.public:
- data.append(MenuItem(os.path.basename(entry.file.name), entry.file.url))
- files.add(entry.file.name)
+ for entry in PDFFile.objects.filter(
+ file__isnull=False, status=Status.DONE, tenant=request.tenant, public=True
+ ).exclude(file__exact=""):
+ display_name = entry.name
+ if display_name not in files and entry.public:
+ data.append(MenuItem(display_name, entry.file.url))
+ files.add(display_name)
return data
diff --git a/tenants/migrations/0006_alter_tenant_options.py b/tenants/migrations/0006_alter_tenant_options.py
new file mode 100644
index 0000000..30a827c
--- /dev/null
+++ b/tenants/migrations/0006_alter_tenant_options.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.1.4 on 2024-12-25 19:56
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("tenants", "0005_link_tenant_links"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="tenant",
+ options={"verbose_name": "Tenant", "verbose_name_plural": "Tenants"},
+ ),
+ ]