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"}, + ), + ]