From 4164d56deaed67631b5043c4925b70668a2ba90b Mon Sep 17 00:00:00 2001 From: Tris Forster Date: Tue, 12 May 2026 11:03:11 +1000 Subject: [PATCH] Library cleanup --- app/library/admin.py | 45 ++- app/library/apps.py | 2 +- app/library/forms.py | 33 +- .../management/commands/import_works.py | 15 +- app/library/models.py | 298 +++++++++++------- app/library/music_tags.py | 35 +- app/library/pdf_utils.py | 41 ++- app/library/tests.py | 227 +++++++------ app/library/urls.py | 150 ++++++--- app/library/views/api.py | 67 ++-- pyproject.toml | 1 + 11 files changed, 588 insertions(+), 326 deletions(-) diff --git a/app/library/admin.py b/app/library/admin.py index cc14770..918f736 100644 --- a/app/library/admin.py +++ b/app/library/admin.py @@ -2,60 +2,77 @@ from django.contrib import admin from . import models + class EnsembleAccessInline(admin.StackedInline): model = models.EnsembleAccess extra = 0 + class CollectionAdmin(admin.ModelAdmin): - list_display = ['name', 'location', 'storage', 'prefix'] + list_display = ["name", "location", "storage", "prefix"] inlines = [EnsembleAccessInline] + admin.site.register(models.Collection, CollectionAdmin) + class ItemInline(admin.TabularInline): model = models.ProjectItem extra = 0 + class DocInline(admin.TabularInline): model = models.Document extra = 0 + class MetaInline(admin.TabularInline): model = models.WorkMeta extra = 0 + class WorkAdmin(admin.ModelAdmin): - list_display = ['name', 'composer', 'edition', 'identifier', 'running_time'] - list_filter = ['collection'] - search_fields = ['name', 'composer'] + list_display = ["name", "composer", "edition", "identifier", "running_time"] + list_filter = ["collection"] + search_fields = ["name", "composer"] inlines = [MetaInline, DocInline, ItemInline] + admin.site.register(models.Work, WorkAdmin) + class SectionInline(admin.TabularInline): model = models.Section - fields = ['tag', 'start', 'end', 'page'] + fields = ["tag", "start", "end", "page"] + class DocumentAdmin(admin.ModelAdmin): - list_display = ['work', '__str__'] - list_filter = ['work__collection'] + list_display = ["work", "__str__"] + list_filter = ["work__collection"] inlines = [SectionInline] + admin.site.register(models.Document, DocumentAdmin) + class ItemAdmin(admin.ModelAdmin): - list_display = ['project', 'work', 'order'] - list_filter = ['project'] + list_display = ["project", "work", "order"] + list_filter = ["project"] + admin.site.register(models.ProjectItem, ItemAdmin) + class EnsembleAccessAdmin(admin.ModelAdmin): - list_display = ['ensemble', 'collection', 'access_type'] - list_filter = ['ensemble'] + list_display = ["ensemble", "collection", "access_type"] + list_filter = ["ensemble"] + admin.site.register(models.EnsembleAccess, EnsembleAccessAdmin) -class OrchestrationAdmin(admin.ModelAdmin): - list_display = ['name', 'instruments'] -admin.site.register(models.Orchestration, OrchestrationAdmin) \ No newline at end of file +class OrchestrationAdmin(admin.ModelAdmin): + list_display = ["name", "instruments"] + + +admin.site.register(models.Orchestration, OrchestrationAdmin) diff --git a/app/library/apps.py b/app/library/apps.py index e01db0a..5e69348 100644 --- a/app/library/apps.py +++ b/app/library/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class LibraryConfig(AppConfig): - name = 'library' + name = "library" diff --git a/app/library/forms.py b/app/library/forms.py index 76bd627..cd6eb85 100644 --- a/app/library/forms.py +++ b/app/library/forms.py @@ -5,29 +5,42 @@ from interface.forms import BaseForm class WorkCreateForm(forms.ModelForm, BaseForm): - class Meta: model = Work - fields = ['name', 'composer', 'edition', 'code', 'orchestration', 'licence', 'running_time', 'notes'] + fields = [ + "name", + "composer", + "edition", + "code", + "orchestration", + "licence", + "running_time", + "notes", + ] + class PlaylistAddForm(forms.Form): work = forms.ModelChoiceField(queryset=Work.objects.all()) def __init__(self, instance, *args, **kwargs): - super(PlaylistAddForm, self).__init__(*args, **kwargs) + super(PlaylistAddForm, self).__init__(*args, **kwargs) - existing = [ x[0] for x in instance.works.values_list('pk') ] + existing = [x[0] for x in instance.works.values_list("pk")] + + qs = Work.objects.filter(ensemble_id=instance.ensemble_id).exclude( + id__in=existing + ) + self.fields["work"].queryset = qs + self.instance = instance - qs = Work.objects.filter(ensemble_id=instance.ensemble_id).exclude(id__in=existing) - self.fields['work'].queryset = qs - self.instance = instance - def save(self): - self.instance.works.add(self.cleaned_data['work']) + self.instance.works.add(self.cleaned_data["work"]) + class ProjectEnsembleChoiceField(forms.ModelChoiceField): def label_from_instance(self, obj): return f"{obj.ensemble.name} - {obj.name}" + class ProjectSelectForm(BaseForm): - project = ProjectEnsembleChoiceField(queryset=Project.objects.all()) \ No newline at end of file + project = ProjectEnsembleChoiceField(queryset=Project.objects.all()) diff --git a/app/library/management/commands/import_works.py b/app/library/management/commands/import_works.py index 2ac748c..1a9ba17 100644 --- a/app/library/management/commands/import_works.py +++ b/app/library/management/commands/import_works.py @@ -4,17 +4,20 @@ import csv from library import models + class Command(BaseCommand): - help = 'Imports works from a csv file' + help = "Imports works from a csv file" def add_arguments(self, parser): - parser.add_argument('collection', type=int, help="Collection ID") - parser.add_argument('source', type=argparse.FileType('r'), help="Source CSV") + parser.add_argument("collection", type=int, help="Collection ID") + parser.add_argument("source", type=argparse.FileType("r"), help="Source CSV") def handle(self, *args, **options): - collection = models.Collection.objects.get(pk=options['collection']) + collection = models.Collection.objects.get(pk=options["collection"]) - reader = csv.DictReader(options['source']) + reader = csv.DictReader(options["source"]) for row in reader: - collection.works.create(name=row['Piece'], composer=row['Composer'], notes=row['Notes']) \ No newline at end of file + collection.works.create( + name=row["Piece"], composer=row["Composer"], notes=row["Notes"] + ) diff --git a/app/library/models.py b/app/library/models.py index d7b9c28..a819dfa 100644 --- a/app/library/models.py +++ b/app/library/models.py @@ -18,44 +18,54 @@ from interface.utils import sign_data import logging -#from polyphonic.settings import LIBRARY_STORAGE +# from polyphonic.settings import LIBRARY_STORAGE logger = logging.getLogger(__name__) -#try: +# try: # library_storage = get_storage_class(settings.LIBRARY_STORAGE)() -#except (ImportError, AttributeError): +# except (ImportError, AttributeError): # logger.exception("Failed to load library storage") # library_storage = get_storage_class()() -#logger.info("Library storage: %s", library_storage.__class__.__name__) +# logger.info("Library storage: %s", library_storage.__class__.__name__) # FIXME: move back to settings library_storage = CachedStorage(BYOStorage()) + class Orchestration(models.Model): """ Stores a list of instrument codes as a single entry (space delimited). Can be global or ensemble specific """ - collection = models.ForeignKey('Collection', on_delete=models.CASCADE, related_name="custom_orchestrations", null=True, blank=True) + + collection = models.ForeignKey( + "Collection", + on_delete=models.CASCADE, + related_name="custom_orchestrations", + null=True, + blank=True, + ) name = models.CharField(max_length=100) instruments = models.TextField() def as_list(self): - tags = [ t.strip() for t in self.instruments.split(' ') ] - return [ (t, MusicTag.from_tag(t)) for t in tags if t ] + tags = [t.strip() for t in self.instruments.split(" ")] + return [(t, MusicTag.from_tag(t)) for t in tags if t] def tag_order(self): - tags = [ t.strip() for t in self.instruments.split(' ') if t ] - order = {'score': 0} + tags = [t.strip() for t in self.instruments.split(" ") if t] + order = {"score": 0} for i, t in enumerate(tags): - order.setdefault(t.strip('-0123456789'), i*2+1) + order.setdefault(t.strip("-0123456789"), i * 2 + 1) return order def sorter(self): tag_order = self.tag_order() + def f(x): - return (tag_order.get(x[0].strip('-0123456789'), 1000), x[0]) + return (tag_order.get(x[0].strip("-0123456789"), 1000), x[0]) + return f def save(self): @@ -65,75 +75,101 @@ class Orchestration(models.Model): def __str__(self): return self.name + class ProjectItem(models.Model): """ ProjectItem represents a Work attached to a Project e.g. item in set list or programme It also allows works to be shared from one ensemble to another on a per-project basis. """ - project = models.ForeignKey('interface.Project', on_delete=models.CASCADE, related_name='items') - work = models.ForeignKey('Work', on_delete=models.CASCADE, related_name='project_items') + + project = models.ForeignKey( + "interface.Project", on_delete=models.CASCADE, related_name="items" + ) + work = models.ForeignKey( + "Work", on_delete=models.CASCADE, related_name="project_items" + ) checkout = models.DateTimeField() due = models.DateTimeField(null=True, blank=True) returned = models.DateTimeField(null=True, blank=True) - approved_by = models.ForeignKey('auth.User', on_delete=models.CASCADE) + approved_by = models.ForeignKey("auth.User", on_delete=models.CASCADE) order = models.SmallIntegerField(default=0) section = models.CharField(max_length=100, blank=True) class Meta: - ordering = ['order', 'work'] + ordering = ["order", "work"] def __str__(self): return f"<{self.project_id}:{slugify(self.work.name)}>" + class Collection(models.Model): """ A logical collection of works, typically owned by an organisation or person (physical or virtual) """ - name = models.CharField(max_length=255, - help_text="Name of the collection") - prefix = models.CharField(max_length=255, default="default", - help_text="Folder to store works in") - administrators = models.ManyToManyField('auth.User', related_name="collections", - help_text="Administrators for this collection") - location = models.CharField(max_length=100, - help_text="Physical location (institution, town...)", blank=True) - storage = models.ForeignKey('byostorage.UserStorage', on_delete=models.CASCADE, null=True, blank=True, - help_text="User storage for documents") - notes = models.TextField(blank=True, - help_text="Publicly visible notes about collection and loans policy (markdown format)") - settings = models.JSONField(default=dict, blank=True, - help_text="Storage specific settings") - nonce = models.SmallIntegerField(default=1, - help_text="Increment this to reset the authentication links") + + name = models.CharField(max_length=255, help_text="Name of the collection") + prefix = models.CharField( + max_length=255, default="default", help_text="Folder to store works in" + ) + administrators = models.ManyToManyField( + "auth.User", + related_name="collections", + help_text="Administrators for this collection", + ) + location = models.CharField( + max_length=100, help_text="Physical location (institution, town...)", blank=True + ) + storage = models.ForeignKey( + "byostorage.UserStorage", + on_delete=models.CASCADE, + null=True, + blank=True, + help_text="User storage for documents", + ) + notes = models.TextField( + blank=True, + help_text="Publicly visible notes about collection and loans policy (markdown format)", + ) + settings = models.JSONField( + default=dict, blank=True, help_text="Storage specific settings" + ) + nonce = models.SmallIntegerField( + default=1, help_text="Increment this to reset the authentication links" + ) def meta(self, name): - items = WorkMeta.objects.filter(work__collection=self.pk, name=name).values_list('value', flat=True).distinct() + items = ( + WorkMeta.objects.filter(work__collection=self.pk, name=name) + .values_list("value", flat=True) + .distinct() + ) return items @property def tags(self): - return self.meta('tag') + return self.meta("tag") @property def genres(self): - return self.meta('genre') + return self.meta("genre") def has_administrator(self, user): if not user.is_authenticated: return False if user.is_superuser: return True - return user.pk in self.administrators.values_list('pk', flat=True) + return user.pk in self.administrators.values_list("pk", flat=True) def get_absolute_url(self): - return resolve_url('collection_work_list', self.pk) + return resolve_url("collection_work_list", self.pk) def auth(self): - return sign_data(f'{self.pk}-{self.nonce}', 12) + return sign_data(f"{self.pk}-{self.nonce}", 12) def __str__(self): return self.name + class EnsembleAccess(models.Model): """ Can have different access levels to a collection @@ -143,12 +179,18 @@ class EnsembleAccess(models.Model): ACCESS_APPROVED = 2 ACCESS_TYPES = ( - (ACCESS_UNLIMITED, 'Unlimited'), - (ACCESS_APPROVED, 'Approval required'), + (ACCESS_UNLIMITED, "Unlimited"), + (ACCESS_APPROVED, "Approval required"), ) - ensemble = models.ForeignKey('interface.Ensemble', on_delete=models.CASCADE, related_name="allowed_collections") - collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name="allowed_ensembles") + ensemble = models.ForeignKey( + "interface.Ensemble", + on_delete=models.CASCADE, + related_name="allowed_collections", + ) + collection = models.ForeignKey( + Collection, on_delete=models.CASCADE, related_name="allowed_ensembles" + ) access_type = models.PositiveSmallIntegerField(choices=ACCESS_TYPES, default=2) class Meta: @@ -159,6 +201,7 @@ class Work(models.Model): """ A musical work 'owned' by a collection from a licencing perspective. """ + LICENCE_PUBLIC = 2 LICENCE_EXPIRED = 4 LICENCE_RECORDING = 5 @@ -167,37 +210,73 @@ class Work(models.Model): LICENCE_NONE = 10 LICENCE_TYPES = ( - (LICENCE_PUBLIC, 'Public Domain'), - (LICENCE_EXPIRED, 'Copyright Expired'), - (LICENCE_RECORDING, 'Recording Licence'), - (LICENCE_PERFORMANCE, 'Performance Licence'), - (LICENCE_PERUSAL, 'Perusal Licence'), - (LICENCE_NONE, 'Internal use only'), + (LICENCE_PUBLIC, "Public Domain"), + (LICENCE_EXPIRED, "Copyright Expired"), + (LICENCE_RECORDING, "Recording Licence"), + (LICENCE_PERFORMANCE, "Performance Licence"), + (LICENCE_PERUSAL, "Perusal Licence"), + (LICENCE_NONE, "Internal use only"), ) name = models.CharField(max_length=255, help_text="Original name of the work") - edition = models.CharField(max_length=255, blank=True, - help_text="Edition details to distinguish multiple versions") - parent = models.ForeignKey('Work', null=True, blank=True, on_delete=models.SET_NULL, related_name="related_works", - help_text="Arrangement of another work or part of an anthology") - composer = models.CharField(max_length=255, default='Anon', - help_text="Composer or compilation editor. Use Surname, Initial for easy searching") + edition = models.CharField( + max_length=255, + blank=True, + help_text="Edition details to distinguish multiple versions", + ) + parent = models.ForeignKey( + "Work", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="related_works", + help_text="Arrangement of another work or part of an anthology", + ) + composer = models.CharField( + max_length=255, + default="Anon", + help_text="Composer or compilation editor. Use Surname, Initial for easy searching", + ) - orchestration = models.ForeignKey(Orchestration, on_delete=models.SET_DEFAULT, default=1, help_text="Orchestration for the work") - original_parts = models.JSONField(default=dict, blank=True, help_text="Original printed parts (IMSLP format)") + orchestration = models.ForeignKey( + Orchestration, + on_delete=models.SET_DEFAULT, + default=1, + help_text="Orchestration for the work", + ) + original_parts = models.JSONField( + default=dict, blank=True, help_text="Original printed parts (IMSLP format)" + ) # Collection details - collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name="works") - code = models.CharField(max_length=100, blank=True, help_text="Collection specific code or number. Will be auto-generated if not supplied") - licence = models.PositiveSmallIntegerField(choices=LICENCE_TYPES, default=6, help_text="Copyright status") - max_projects = models.IntegerField(default=1, help_text="How many active projects can this work be attached to") + collection = models.ForeignKey( + Collection, on_delete=models.CASCADE, related_name="works" + ) + code = models.CharField( + max_length=100, + blank=True, + help_text="Collection specific code or number. Will be auto-generated if not supplied", + ) + licence = models.PositiveSmallIntegerField( + choices=LICENCE_TYPES, default=6, help_text="Copyright status" + ) + max_projects = models.IntegerField( + default=1, help_text="How many active projects can this work be attached to" + ) # Extra info - running_time = models.DurationField(null=True, blank=True, help_text="Running time in mm:ss format") + running_time = models.DurationField( + null=True, blank=True, help_text="Running time in mm:ss format" + ) notes = models.TextField(blank=True) - # Allocation to projects - projects = models.ManyToManyField('interface.Project', through='ProjectItem', related_name="works", help_text="Current usage") + # Allocation to projects + projects = models.ManyToManyField( + "interface.Project", + through="ProjectItem", + related_name="works", + help_text="Current usage", + ) @property def folder(self): @@ -205,19 +284,20 @@ class Work(models.Model): def tagged_sections(self, *tags): qs = self.docs.filter(sections__tag__in=tags) - qs = qs.annotate(Count('sections'), end=Min('sections__end'), start=Max('sections__start')) \ - .filter(sections__count=len(tags)) + qs = qs.annotate( + Count("sections"), end=Min("sections__end"), start=Max("sections__start") + ).filter(sections__count=len(tags)) return qs def list_sections(self, *tags): - return list(self.tagged_sections(*tags).values_list('upload', 'start', 'end')) + return list(self.tagged_sections(*tags).values_list("upload", "start", "end")) @property def digital_parts(self): - sections = [ (s.tag, s.name) for s in Section.objects.filter(doc__work=self.pk) ] + sections = [(s.tag, s.name) for s in Section.objects.filter(doc__work=self.pk)] sections.sort(key=self.orchestration.sorter()) - #return [ s[1] for s in sections ] - sections = list(dict(sections).items()) # primitive unique() + # return [ s[1] for s in sections ] + sections = list(dict(sections).items()) # primitive unique() return sections def pdfs(self): @@ -229,19 +309,21 @@ class Work(models.Model): return [] parts = list(self.original_parts.items()) parts.sort(key=self.orchestration.sorter()) - return [ (MusicTag.from_tag(x[0]), x[1]) for x in parts ] + return [(MusicTag.from_tag(x[0]), x[1]) for x in parts] @property def tags(self): - return self.meta_info.filter(name='tag').values_list('value', flat=True) + return self.meta_info.filter(name="tag").values_list("value", flat=True) @property def meta(self): - return self.meta_info.exclude(name='tag') + return self.meta_info.exclude(name="tag") @property def current_loans(self): - return self.project_items.filter(checkout__lte=now(), returned=None).select_related('project') + return self.project_items.filter( + checkout__lte=now(), returned=None + ).select_related("project") @cached_property def loans(self): @@ -259,29 +341,31 @@ class Work(models.Model): @property def available(self): if self.max_projects < 0: - return 'Unlimited' + return "Unlimited" a = self.max_projects - self.loans - return '{0} of {1}'.format(max(a, 0), self.max_projects) + return "{0} of {1}".format(max(a, 0), self.max_projects) @property def identifier(self): if self.code: - return self.code; + return self.code composer = self.composer or "Anon" - composer = re.sub('[^\w]', '', composer) + composer = re.sub("[^\w]", "", composer) words = self.name.split() work = words[0][:3] return f"{composer[:4]}-{work}-{self.pk:05d}".upper() def assigned_instruments(self): - return Section.objects.filter(doc__work_id=self.pk).values_list('tag', flat=True) + return Section.objects.filter(doc__work_id=self.pk).values_list( + "tag", flat=True + ) def unassigned_instruments(self): assigned = set(self.assigned_instruments()) - return [ x for x in self.orchestration.as_list() if not x[0] in assigned ] + return [x for x in self.orchestration.as_list() if not x[0] in assigned] def music_tags(self): tags = dict(self.orchestration.as_list()) @@ -290,31 +374,32 @@ class Work(models.Model): return tags.items() - def __str__(self): return f"{self.name} ({self.composer})" -class WorkMeta(models.Model): +class WorkMeta(models.Model): META_CHOICES = ( - ('tag', 'Tag'), - ('arr', 'Arranger'), - ('lyrics', 'Lyracist'), - ('genre', 'Genre'), - ('style', 'Style'), - ('orchestration', 'Orchestration'), + ("tag", "Tag"), + ("arr", "Arranger"), + ("lyrics", "Lyracist"), + ("genre", "Genre"), + ("style", "Style"), + ("orchestration", "Orchestration"), ) - work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name='meta_info') + work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name="meta_info") name = models.SlugField(max_length=20, choices=META_CHOICES) value = models.CharField(max_length=255) + def doc_upload_filename(doc, filename): collection = doc.work.collection storage = collection.storage if not storage: raise RuntimeError("Collection has no storage attached") - return f'{storage}:library/{collection.prefix}/{doc.work.folder}/{filename}' + return f"{storage}:library/{collection.prefix}/{doc.work.folder}/{filename}" + class Document(models.Model): """ @@ -324,22 +409,22 @@ class Document(models.Model): DOCTYPE_PDF = 1 DOCTYPE_AUDIO = 2 DOCTYPE_VIDEO = 3 - DOCTYPE_MISC= 4 + DOCTYPE_MISC = 4 DOCTYPES = ( - (DOCTYPE_PDF, 'PDF'), - (DOCTYPE_AUDIO, 'Audio'), - (DOCTYPE_VIDEO, 'Video'), - (DOCTYPE_MISC, 'Misc'), + (DOCTYPE_PDF, "PDF"), + (DOCTYPE_AUDIO, "Audio"), + (DOCTYPE_VIDEO, "Video"), + (DOCTYPE_MISC, "Misc"), ) DOCTYPE_MAP = { - '.pdf': DOCTYPE_PDF, - '.mp3': DOCTYPE_AUDIO, - '.mp4': DOCTYPE_VIDEO, + ".pdf": DOCTYPE_PDF, + ".mp3": DOCTYPE_AUDIO, + ".mp4": DOCTYPE_VIDEO, } - work = models.ForeignKey('Work', on_delete=models.CASCADE, related_name="docs") + work = models.ForeignKey("Work", on_delete=models.CASCADE, related_name="docs") doctype = models.PositiveSmallIntegerField(choices=DOCTYPES, default=DOCTYPE_PDF) upload = models.FileField(upload_to=doc_upload_filename, storage=library_storage) created = models.DateTimeField(auto_now_add=True) @@ -355,6 +440,7 @@ class Document(models.Model): def __str__(self): return self.upload.name + class Section(models.Model): """ Section is a tagged portion of a Document @@ -365,20 +451,21 @@ class Section(models.Model): PAGE_RIGHT = 2 PAGE_PREFERENCE = ( - (PAGE_AUTO, 'auto'), - (PAGE_LEFT, 'left'), - (PAGE_RIGHT, 'right'), + (PAGE_AUTO, "auto"), + (PAGE_LEFT, "left"), + (PAGE_RIGHT, "right"), ) - doc = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="sections") tag = models.CharField(max_length=50, blank=True) start = models.SmallIntegerField(null=True, blank=True) end = models.SmallIntegerField(null=True, blank=True) - page = models.SmallIntegerField(default=PAGE_AUTO, choices=PAGE_PREFERENCE) # NOT CURRENTLY USED + page = models.SmallIntegerField( + default=PAGE_AUTO, choices=PAGE_PREFERENCE + ) # NOT CURRENTLY USED class Meta: - ordering = ['doc', 'start', 'pk'] + ordering = ["doc", "start", "pk"] @property def music_tag(self): @@ -390,11 +477,11 @@ class Section(models.Model): @property def bulma_class(self): - return "success" if self.music_tag.is_general else 'info' + return "success" if self.music_tag.is_general else "info" @property def filename(self): - return slugify(f'{self.doc.work.name} - {self.name}').title() + '.pdf' + return slugify(f"{self.doc.work.name} - {self.name}").title() + ".pdf" @property def pagerange(self): @@ -406,4 +493,3 @@ class Section(models.Model): def __str__(self): return self.name - diff --git a/app/library/music_tags.py b/app/library/music_tags.py index f89433c..3afded2 100644 --- a/app/library/music_tags.py +++ b/app/library/music_tags.py @@ -1,4 +1,3 @@ - from collections import namedtuple GENERAL = """ @@ -158,20 +157,21 @@ zith Zither MUSIC_TAGS = [] GENERAL_TAGS = set() for i, abbreviations in enumerate((GENERAL, INSTRUMENTS)): - for line in abbreviations.split('\n'): + for line in abbreviations.split("\n"): parts = line.strip().split(maxsplit=1) - if len(parts) < 2: continue - name, _, _ = parts[1].partition('(') + if len(parts) < 2: + continue + name, _, _ = parts[1].partition("(") MUSIC_TAGS.append((parts[0], name)) if i == 0: GENERAL_TAGS.add(parts[0]) MUSIC_NAME_BY_TAG = dict(MUSIC_TAGS) -MUSIC_TAG_BY_NAME = dict( ( (x[1].lower(), x[0]) for x in MUSIC_TAGS ) ) +MUSIC_TAG_BY_NAME = dict(((x[1].lower(), x[0]) for x in MUSIC_TAGS)) -class MusicTag(namedtuple('MusicTag', ('name', 'variant'), defaults=[None])): +class MusicTag(namedtuple("MusicTag", ("name", "variant"), defaults=[None])): @classmethod def from_tag(cls, tag): """ @@ -186,13 +186,13 @@ class MusicTag(namedtuple('MusicTag', ('name', 'variant'), defaults=[None])): >>> MusicTag.from_tag('pce-A2') MusicTag(name='Piece', variant='A2') """ - abbr, _, variant = tag.partition('-') + abbr, _, variant = tag.partition("-") name = MUSIC_NAME_BY_TAG.get(abbr.lower(), abbr) if variant: return cls(name, variant) return cls(name, None) - + @property def tag(self): l = self.name.lower() @@ -206,7 +206,7 @@ class MusicTag(namedtuple('MusicTag', ('name', 'variant'), defaults=[None])): >>> MusicTag('Violin', 2).is_general False """ - return self.tag in GENERAL_TAGS + return self.tag in GENERAL_TAGS def abbreviate(self): """ @@ -231,12 +231,15 @@ class MusicTag(namedtuple('MusicTag', ('name', 'variant'), defaults=[None])): return f"{self.name} {self.variant}" return self.name + import re -PATTERNS = [re.compile('([A-Za-z]+)[_\- ]*(\d+)'), re.compile('([A-Za-z]+)()')] + +PATTERNS = [re.compile("([A-Za-z]+)[_\- ]*(\d+)"), re.compile("([A-Za-z]+)()")] + def auto_tag(filename): - ''' - + """ + >>> auto_tag('Ode to Joy - Violin 1.pdf') MusicTag(name='Violin', variant=1) >>> auto_tag('Ode to Joy_Cello.pdf') @@ -247,7 +250,7 @@ def auto_tag(filename): MusicTag(name='Viola', variant=None) >>> auto_tag('Ode to Joy - fl-2 (piccolo).pdf') MusicTag(name='Flute', variant=2) - ''' + """ for pattern in PATTERNS: for inst, ordinal in pattern.findall(filename): @@ -257,9 +260,9 @@ def auto_tag(filename): return MusicTag(inst.title(), ordinal) if inst in MUSIC_NAME_BY_TAG: return MusicTag(MUSIC_NAME_BY_TAG[inst], ordinal) - - + if __name__ == "__main__": import doctest - print(doctest.testmod()) \ No newline at end of file + + print(doctest.testmod()) diff --git a/app/library/pdf_utils.py b/app/library/pdf_utils.py index a9eec64..54432c7 100644 --- a/app/library/pdf_utils.py +++ b/app/library/pdf_utils.py @@ -5,22 +5,23 @@ import string SAFECHARS = string.ascii_letters + string.digits + " _-" + def extract_pages(source, bookmark, start=None, end=None, count=1): return extract_and_concat([(source, bookmark, start, end, count)]) + def extract_and_concat(items): - + # create a temporary directory for our sections d = tempfile.TemporaryDirectory(prefix="polyphonic_") - pdfmarks = os.path.join(d.name, 'pdfmarks.txt') - marks = open(pdfmarks, 'w') + pdfmarks = os.path.join(d.name, "pdfmarks.txt") + marks = open(pdfmarks, "w") sections = [] current_page = 1 for i, (source, bookmark, start, end, count) in enumerate(items): - if count == 0: continue @@ -28,23 +29,34 @@ def extract_and_concat(items): sections.append(source) else: - if not end: end = start - dest = os.path.join(d.name, f'section_{i}.pdf') + dest = os.path.join(d.name, f"section_{i}.pdf") - cmd = ['gs', '-sDEVICE=pdfwrite', '-dBATCH', '-dNOPAUSE', - f'-dFirstPage={start}', f'-dLastPage={end}', - f'-sOutputFile={dest}', - source] + cmd = [ + "gs", + "-sDEVICE=pdfwrite", + "-dBATCH", + "-dNOPAUSE", + f"-dFirstPage={start}", + f"-dLastPage={end}", + f"-sOutputFile={dest}", + source, + ] bookmark = "".join(filter(lambda c: c in SAFECHARS, bookmark)) - marks.write(f'[/Title ({bookmark}) /Page {current_page} /OUT pdfmark\n') + marks.write(f"[/Title ({bookmark}) /Page {current_page} /OUT pdfmark\n") p = subprocess.run(cmd, check=True, capture_output=True) - pages = len([ x for x in p.stdout.splitlines() if x.decode('utf8').startswith('Page ')]) + pages = len( + [ + x + for x in p.stdout.splitlines() + if x.decode("utf8").startswith("Page ") + ] + ) for j in range(count): sections.append(dest) current_page += pages @@ -52,10 +64,9 @@ def extract_and_concat(items): marks.close() # concat the items - output = tempfile.NamedTemporaryFile(prefix="polyphonic_", suffix='.pdf') + output = tempfile.NamedTemporaryFile(prefix="polyphonic_", suffix=".pdf") - cmd = ['gs', '-sDEVICE=pdfwrite', '-q', '-dBATCH', '-dNOPAUSE', - '-sOutputFile=-'] + cmd = ["gs", "-sDEVICE=pdfwrite", "-q", "-dBATCH", "-dNOPAUSE", "-sOutputFile=-"] cmd.extend(sections) cmd.append(pdfmarks) diff --git a/app/library/tests.py b/app/library/tests.py index 3063ca0..0b8246c 100644 --- a/app/library/tests.py +++ b/app/library/tests.py @@ -1,4 +1,4 @@ -from interface.tests import AccessTestCase +from interface.tests import AccessTestCase from byostorage.user import UserStorage from . import models @@ -7,87 +7,101 @@ from .views.api import WorkSerializer import tempfile import json -class LibraryTestCase(AccessTestCase): +class LibraryTestCase(AccessTestCase): USERS = ( - {'username': 'admin', 'password': 'secret', 'is_superuser': True, 'is_staff': True}, - {'username': 'homer', 'password': 'maggie'}, + { + "username": "admin", + "password": "secret", + "is_superuser": True, + "is_staff": True, + }, + {"username": "homer", "password": "maggie"}, ) ENSEMBLES = ( - {'name': 'The Be Sharps', 'slug': 'be-sharps', 'admins': ['homer']}, - {'name': 'Lisa & the Bleeding Gums', 'slug': 'bleeding-gums'}, - {'name': 'Party Posse'}, + {"name": "The Be Sharps", "slug": "be-sharps", "admins": ["homer"]}, + {"name": "Lisa & the Bleeding Gums", "slug": "bleeding-gums"}, + {"name": "Party Posse"}, ) PROJECTS = ( - {'name': 'Baker St', 'ensemble': 'bleeding-gums', 'when': -12}, - {'name': 'Navy Recruitment Day', 'ensemble': 'party-posse', 'when': 6}, - {'name': 'Barbershop Contest', 'ensemble': 'be-sharps', 'when': 28}, - {'name': 'Open Mic Night', 'ensemble': 'bleeding-gums', 'when': 1 }, + {"name": "Baker St", "ensemble": "bleeding-gums", "when": -12}, + {"name": "Navy Recruitment Day", "ensemble": "party-posse", "when": 6}, + {"name": "Barbershop Contest", "ensemble": "be-sharps", "when": 28}, + {"name": "Open Mic Night", "ensemble": "bleeding-gums", "when": 1}, ) COLLECTIONS = ( - {'name': 'Springfield Elementary Library', 'prefix': 'sel'}, - {'name': 'Neds Library', 'prefix': 'ned', 'admins': ['homer']}, + {"name": "Springfield Elementary Library", "prefix": "sel"}, + {"name": "Neds Library", "prefix": "ned", "admins": ["homer"]}, ) WORKS = ( - {'name': 'Baby on Board', 'collection': 'ned', 'docs': [{'upload': 'local:baby_on_board.pdf'}]}, - {'name': 'Star Spangled Banner', 'collection': 'sel'}, + { + "name": "Baby on Board", + "collection": "ned", + "docs": [{"upload": "local:baby_on_board.pdf"}], + }, + {"name": "Star Spangled Banner", "collection": "sel"}, ) PROTECTED_URLS = ( - '/collections/1', - '/collections/1/add', - '/collections/2/works/1', - '/collections/2/works/1/edit', - '/collections/2/works/1/partset', - '/collections/2/works/1/add_to_project', - '/collections/2/works/1/upload', - '/collections/2/docs/1/annotate', - + "/collections/1", + "/collections/1/add", + "/collections/2/works/1", + "/collections/2/works/1/edit", + "/collections/2/works/1/partset", + "/collections/2/works/1/add_to_project", + "/collections/2/works/1/upload", + "/collections/2/docs/1/annotate", # Need to add storage before we can test these - '/api/collections/2', - '/api/collections/2/works/1', - - '/admin/library/collection/', - '/admin/library/document/', - '/admin/library/ensembleaccess/', - '/admin/library/orchestration/', - '/admin/library/projectitem/', - '/admin/library/work/', + "/api/collections/2", + "/api/collections/2/works/1", + "/admin/library/collection/", + "/admin/library/document/", + "/admin/library/ensembleaccess/", + "/admin/library/orchestration/", + "/admin/library/projectitem/", + "/admin/library/work/", ) @classmethod def setUpTestData(cls): super().setUpTestData() - + cls.temp_dir = tempfile.TemporaryDirectory() - cls.storage = UserStorage.objects.create(name='local', storage='django.core.files.storage.FileSystemStorage', - settings_data=json.dumps({'location': cls.temp_dir.name, 'base_url': 'file://' + cls.temp_dir.name})) + cls.storage = UserStorage.objects.create( + name="local", + storage="django.core.files.storage.FileSystemStorage", + settings_data=json.dumps( + { + "location": cls.temp_dir.name, + "base_url": "file://" + cls.temp_dir.name, + } + ), + ) cls.collections = {} for details in cls.COLLECTIONS: - admins = details.pop('admins', []) + admins = details.pop("admins", []) obj = models.Collection.objects.create(storage=cls.storage, **details) for admin in admins: obj.administrators.add(cls.users[admin]) - cls.collections[details['prefix']] = obj + cls.collections[details["prefix"]] = obj cls.works = {} for details in cls.WORKS: - collection = cls.collections[details.pop('collection')] - #details.setdefault('docs', []) - #details.setdefault('meta_info', []) - #s = WorkSerializer(data=details) - #assert s.is_valid(), s.errors - #s.save(collection_id=collection.pk) - docs = details.pop('docs', []) + collection = cls.collections[details.pop("collection")] + # details.setdefault('docs', []) + # details.setdefault('meta_info', []) + # s = WorkSerializer(data=details) + # assert s.is_valid(), s.errors + # s.save(collection_id=collection.pk) + docs = details.pop("docs", []) obj = models.Work.objects.create(collection=collection, **details) for doc in docs: obj.docs.create(**doc) - cls.works[details['name']] = obj - + cls.works[details["name"]] = obj def setUp(self): pass @@ -95,82 +109,105 @@ class LibraryTestCase(AccessTestCase): @classmethod def tearDownClass(cls): cls.temp_dir.cleanup() - def test_integration(self): pass def test_superuser_access(self): - self.login('admin', 'secret') - self.assertAccess({ - '/collections': True, - '/collections/1': True, - '/collections/2/works/1': True, - }) + self.login("admin", "secret") + self.assertAccess( + { + "/collections": True, + "/collections/1": True, + "/collections/2/works/1": True, + } + ) def test_administrator_access(self): - self.login('homer', 'maggie') - self.assertAccess({ - '/collections': True, - '/collections/1': False, - '/collections/2': True, - '/collections/2/works/1': True, - }) + self.login("homer", "maggie") + self.assertAccess( + { + "/collections": True, + "/collections/1": False, + "/collections/2": True, + "/collections/2/works/1": True, + } + ) def test_link_access(self): - self.assertAccess({ - '/collections': True, - '/collections/1': False, - '/collections/2': False, - '/collections/2/works/1': False, - }) + self.assertAccess( + { + "/collections": True, + "/collections/1": False, + "/collections/2": False, + "/collections/2/works/1": False, + } + ) self.authorize(models.Collection, pk=2) - self.assertAccess({ - '/collections': True, - '/collections/1': False, - '/collections/2': True, - '/collections/2/works/1': True, - }) + self.assertAccess( + { + "/collections": True, + "/collections/1": False, + "/collections/2": True, + "/collections/2/works/1": True, + } + ) def test_anon_access(self): - self.assertAccess({ - '/collections': True, - '/collections/1': False, - '/collections/2': False, - '/collections/2/works/1': False, - }) + self.assertAccess( + { + "/collections": True, + "/collections/1": False, + "/collections/2": False, + "/collections/2/works/1": False, + } + ) def test_export_and_import(self): - self.login('admin', 'secret') - data = self.client.get('/api/collections/1/works/2', HTTP_ACCEPT="application/json").json() - response = self.client.post('/api/collections/2/import', data, "application/json") - self.assertEqual(response.status_code, 201) + self.login("admin", "secret") + data = self.client.get( + "/api/collections/1/works/2", HTTP_ACCEPT="application/json" + ).json() + response = self.client.post( + "/api/collections/2/import", data, "application/json" + ) + self.assertEqual(response.status_code, 201) def test_movement_from_large_work(self): - ''' + """ Will be common to store a work which has several movements, but the project is only going to play one. This also should give us the ability to store an anthology as one Work have Project reference 'no:23' - ''' + """ - work = self.collections['sel'].works.create(name="Some Quartet", composer="Beethoven") - for g in ('vl-1', 'vl-2', 'vla', 'vc'): - doc = work.docs.create(upload=f'sel/beethoven/some_quartet/some_quartet_{g}.pdf') - doc.sections.create(tag='mvmt-1', start=1, end=3) - doc.sections.create(tag='mvmt-2', start=4, end=8) - doc.sections.create(tag='mvmt-3', start=9, end=12) + work = self.collections["sel"].works.create( + name="Some Quartet", composer="Beethoven" + ) + for g in ("vl-1", "vl-2", "vla", "vc"): + doc = work.docs.create( + upload=f"sel/beethoven/some_quartet/some_quartet_{g}.pdf" + ) + doc.sections.create(tag="mvmt-1", start=1, end=3) + doc.sections.create(tag="mvmt-2", start=4, end=8) + doc.sections.create(tag="mvmt-3", start=9, end=12) doc.sections.create(tag=g) # no tags - get nothing (should it be everything?) self.assertEqual(work.list_sections(), []) # single tag - should get just that range - self.assertEqual(work.list_sections('vl-1'), [('sel/beethoven/some_quartet/some_quartet_vl-1.pdf', None, None)]) + self.assertEqual( + work.list_sections("vl-1"), + [("sel/beethoven/some_quartet/some_quartet_vl-1.pdf", None, None)], + ) # single tag - returns all documents with that range - result = work.list_sections('mvmt-2') + result = work.list_sections("mvmt-2") self.assertEqual(len(result), 4) # multiple tags - returns the overlapping portion of all documents that have all tags - self.assertEqual(work.list_sections('vl-1', 'mvmt-2'), [('sel/beethoven/some_quartet/some_quartet_vl-1.pdf', 4, 8)]) - self.assertEqual(work.list_sections('vl-1', 'vl-2'), []) \ No newline at end of file + self.assertEqual( + work.list_sections("vl-1", "mvmt-2"), + [("sel/beethoven/some_quartet/some_quartet_vl-1.pdf", 4, 8)], + ) + self.assertEqual(work.list_sections("vl-1", "vl-2"), []) diff --git a/app/library/urls.py b/app/library/urls.py index 34f5051..3f8ee47 100644 --- a/app/library/urls.py +++ b/app/library/urls.py @@ -6,42 +6,120 @@ from . import views from library.views import api -#router = routers.DefaultRouter() -#router.register(r'collection', external.CollectionViewSet, basename="collection") -#router.register(r'work', external.WorkViewSet, basename="work") +# router = routers.DefaultRouter() +# router.register(r'collection', external.CollectionViewSet, basename="collection") +# router.register(r'work', external.WorkViewSet, basename="work") urlpatterns = [ - - path('projects//items', views.ProjectItemListView.as_view(), name="item_list"), - path('projects//items/manage', views.ProjectItemManageView.as_view(), name="item_list_manage"), - path('projects//items/append', views.ProjectItemAddView.as_view(), name="item_list_append"), - - path('library', views.LibraryWorkListView.as_view(), name="work_list"), - - path('collections', views.CollectionListView.as_view(), name="collection_list"), - path('collections/', views.CollectionWorkListView.as_view(), name="collection_work_list"), - path('collections//add', views.WorkAddView.as_view(), name="work_add"), - - path('collections//works/', views.WorkDetailView.as_view(), name="work_detail"), - path('collections//works//edit', views.WorkUpdateView.as_view(), name="work_edit"), - path('collections//works//partset', views.WorkPartSetView.as_view(), name="work_partset"), - path('collections//works//parts', views.WorkPartsView.as_view(), name="work_parts"), - path('collections//works//add_to_project', views.WorkAddToProject.as_view(), name="work_add_to_project"), - path('collections//works//upload', views.WorkAddDocumentView.as_view(), name="document_add"), - path('collections//works//download', views.WorkDownloadView.as_view(), name="work_download"), - - path('collections//docs//delete', views.DocumentDeleteView.as_view(), name="document_delete"), - path('collections//docs//download', views.DocumentDownloadView.as_view(), name="document_download"), - path('collections//docs//annotate', views.DocumentAnnotateView.as_view(), name="document_annotate"), - - path('collections//download//', views.PartDownloadView.as_view(), name="part_download"), - path('collections//browse', views.StorageBrowserView.as_view(), name="storage_browser"), - path('collections//browse/', views.StorageBrowserView.as_view(), name="storage_browser_folder"), - - #path('api/', include(router.urls)) - path('api/collections/', api.CollectionExportView.as_view(), name="collection_export"), - path('api/collections//works/', api.WorkExportView.as_view(), name="work_export"), - path('api/collections//import', api.WorkImportView.as_view(), name="work_import"), - path('api/collections//bulk_import', api.CollectionImportView.as_view(), name="collection_import"), - + path( + "projects//items", + views.ProjectItemListView.as_view(), + name="item_list", + ), + path( + "projects//items/manage", + views.ProjectItemManageView.as_view(), + name="item_list_manage", + ), + path( + "projects//items/append", + views.ProjectItemAddView.as_view(), + name="item_list_append", + ), + path("library", views.LibraryWorkListView.as_view(), name="work_list"), + path("collections", views.CollectionListView.as_view(), name="collection_list"), + path( + "collections/", + views.CollectionWorkListView.as_view(), + name="collection_work_list", + ), + path( + "collections//add", views.WorkAddView.as_view(), name="work_add" + ), + path( + "collections//works/", + views.WorkDetailView.as_view(), + name="work_detail", + ), + path( + "collections//works//edit", + views.WorkUpdateView.as_view(), + name="work_edit", + ), + path( + "collections//works//partset", + views.WorkPartSetView.as_view(), + name="work_partset", + ), + path( + "collections//works//parts", + views.WorkPartsView.as_view(), + name="work_parts", + ), + path( + "collections//works//add_to_project", + views.WorkAddToProject.as_view(), + name="work_add_to_project", + ), + path( + "collections//works//upload", + views.WorkAddDocumentView.as_view(), + name="document_add", + ), + path( + "collections//works//download", + views.WorkDownloadView.as_view(), + name="work_download", + ), + path( + "collections//docs//delete", + views.DocumentDeleteView.as_view(), + name="document_delete", + ), + path( + "collections//docs//download", + views.DocumentDownloadView.as_view(), + name="document_download", + ), + path( + "collections//docs//annotate", + views.DocumentAnnotateView.as_view(), + name="document_annotate", + ), + path( + "collections//download//", + views.PartDownloadView.as_view(), + name="part_download", + ), + path( + "collections//browse", + views.StorageBrowserView.as_view(), + name="storage_browser", + ), + path( + "collections//browse/", + views.StorageBrowserView.as_view(), + name="storage_browser_folder", + ), + # path('api/', include(router.urls)) + path( + "api/collections/", + api.CollectionExportView.as_view(), + name="collection_export", + ), + path( + "api/collections//works/", + api.WorkExportView.as_view(), + name="work_export", + ), + path( + "api/collections//import", + api.WorkImportView.as_view(), + name="work_import", + ), + path( + "api/collections//bulk_import", + api.CollectionImportView.as_view(), + name="collection_import", + ), ] diff --git a/app/library/views/api.py b/app/library/views/api.py index 596a2d1..84805c8 100644 --- a/app/library/views/api.py +++ b/app/library/views/api.py @@ -1,6 +1,7 @@ """ Views relating to importing and exporting collection items """ + """ from interface.views import EnsembleMixin from library.views import WorkMixin @@ -49,22 +50,24 @@ import os.path from django.db import transaction from django.core.files.uploadedfile import TemporaryUploadedFile + class WorkMetaSerializer(serializers.ModelSerializer): class Meta: model = WorkMeta - exclude = ['id', 'work'] + exclude = ["id", "work"] def to_representation(self, instance): return f"{instance.name}:{instance.value}" - + def to_internal_value(self, data): - name, _, value = data.partition(':') - return super().to_internal_value({'name': name, 'value': value}) + name, _, value = data.partition(":") + return super().to_internal_value({"name": name, "value": value}) + class SectionSerializer(serializers.ModelSerializer): class Meta: model = Section - exclude = ['id', 'doc'] + exclude = ["id", "doc"] def to_representation(self, instance): start = instance.start or 0 @@ -79,14 +82,14 @@ class SectionSerializer(serializers.ModelSerializer): start = None if end < 1: end = None - return super().to_internal_value({'tag': tag, 'start': start, 'end': end}) + return super().to_internal_value({"tag": tag, "start": start, "end": end}) + class DocumentSerializer(serializers.ModelSerializer): - upload = serializers.URLField() sections = SectionSerializer(many=True) - #def to_internal_value(self, data): + # def to_internal_value(self, data): # r = requests.get(data['upload'], stream=True) # with tempfile.NamedTemporaryFile('wb') as f: # shutil.copyfileobj(r.raw, f) @@ -96,7 +99,7 @@ class DocumentSerializer(serializers.ModelSerializer): def to_representation(self, instance): data = super().to_representation(instance) - data['upload'] = instance.upload.url + data["upload"] = instance.upload.url return data def create(self, validated_data): @@ -115,35 +118,40 @@ class DocumentSerializer(serializers.ModelSerializer): model = Document exclude = ["id", "work", "version", "created"] + # Serializers define the API representation. class WorkSerializer(serializers.ModelSerializer): - docs = DocumentSerializer(many=True) meta_info = WorkMetaSerializer(many=True) - + class Meta: model = Work - exclude = ['id', 'collection', 'projects', 'parent'] + exclude = ["id", "collection", "projects", "parent"] def create(self, validated): with transaction.atomic(): - docs = validated.pop('docs', []) - meta = validated.pop('meta_info', []) + docs = validated.pop("docs", []) + meta = validated.pop("meta_info", []) work = Work.objects.create(**validated) for d in docs: - sections = d.pop('sections', []) + sections = d.pop("sections", []) - url = urllib.parse.urlparse(d['upload']) + url = urllib.parse.urlparse(d["upload"]) filename = os.path.basename(url.path) - r = requests.get(d['upload'], stream=True) + r = requests.get(d["upload"], stream=True) if r.status_code != 200: raise APIException("Failed to download file") - f = TemporaryUploadedFile(filename, r.headers['content-type'], r.headers.get('content-length'), r.encoding) + f = TemporaryUploadedFile( + filename, + r.headers["content-type"], + r.headers.get("content-length"), + r.encoding, + ) shutil.copyfileobj(r.raw, f.file) r.close() - d['upload'] = f + d["upload"] = f doc = Document.objects.create(work_id=work.pk, **d) for s in sections: @@ -154,22 +162,24 @@ class WorkSerializer(serializers.ModelSerializer): return work + class CollectionSerializer(serializers.Serializer): works = WorkSerializer(many=True) def create(self, validated): s = WorkSerializer() print(validated) - collection = validated['collection_id'] + collection = validated["collection_id"] with transaction.atomic(): - for work in validated['works']: - work['collection_id'] = collection + for work in validated["works"]: + work["collection_id"] = collection s.create(work) return Collection.objects.get(pk=collection) - + from rest_framework import generics + class CollectionExportView(AuthorizedResourceMixin, generics.RetrieveAPIView): serializer_class = CollectionSerializer @@ -178,23 +188,26 @@ class CollectionExportView(AuthorizedResourceMixin, generics.RetrieveAPIView): return Collection.objects.all() return Collection.objects.filter(administrators=self.request.user) + class WorkExportView(AuthorizedResourceMixin, generics.RetrieveAPIView): serializer_class = WorkSerializer def get_queryset(self): - works = Work.objects.filter(collection=self.kwargs['collection']) + works = Work.objects.filter(collection=self.kwargs["collection"]) if self.request.user.is_superuser: - return works + return works return works.filter(collection__administrators=self.request.user) + class WorkImportView(AuthorizedResourceMixin, generics.CreateAPIView): serializer_class = WorkSerializer def perform_create(self, serializer): - serializer.save(collection_id=self.kwargs['collection']) + serializer.save(collection_id=self.kwargs["collection"]) + class CollectionImportView(AuthorizedResourceMixin, generics.CreateAPIView): serializer_class = CollectionSerializer def perform_create(self, serializer): - serializer.save(collection_id=self.kwargs['pk']) \ No newline at end of file + serializer.save(collection_id=self.kwargs["pk"]) diff --git a/pyproject.toml b/pyproject.toml index 6a55049..57d8b09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ packages = [{include = "*", from="app"}] [tool.poetry.group.dev.dependencies] django-debug-toolbar = "5.2" +ruff = "^0.15.12" [tool.poetry.scripts] manage = "manage:main"