Library cleanup

This commit is contained in:
Tris Forster 2026-05-12 11:03:11 +10:00
parent 75dced77b8
commit 4164d56dea
11 changed files with 588 additions and 326 deletions

View File

@ -2,60 +2,77 @@ from django.contrib import admin
from . import models from . import models
class EnsembleAccessInline(admin.StackedInline): class EnsembleAccessInline(admin.StackedInline):
model = models.EnsembleAccess model = models.EnsembleAccess
extra = 0 extra = 0
class CollectionAdmin(admin.ModelAdmin): class CollectionAdmin(admin.ModelAdmin):
list_display = ['name', 'location', 'storage', 'prefix'] list_display = ["name", "location", "storage", "prefix"]
inlines = [EnsembleAccessInline] inlines = [EnsembleAccessInline]
admin.site.register(models.Collection, CollectionAdmin) admin.site.register(models.Collection, CollectionAdmin)
class ItemInline(admin.TabularInline): class ItemInline(admin.TabularInline):
model = models.ProjectItem model = models.ProjectItem
extra = 0 extra = 0
class DocInline(admin.TabularInline): class DocInline(admin.TabularInline):
model = models.Document model = models.Document
extra = 0 extra = 0
class MetaInline(admin.TabularInline): class MetaInline(admin.TabularInline):
model = models.WorkMeta model = models.WorkMeta
extra = 0 extra = 0
class WorkAdmin(admin.ModelAdmin): class WorkAdmin(admin.ModelAdmin):
list_display = ['name', 'composer', 'edition', 'identifier', 'running_time'] list_display = ["name", "composer", "edition", "identifier", "running_time"]
list_filter = ['collection'] list_filter = ["collection"]
search_fields = ['name', 'composer'] search_fields = ["name", "composer"]
inlines = [MetaInline, DocInline, ItemInline] inlines = [MetaInline, DocInline, ItemInline]
admin.site.register(models.Work, WorkAdmin) admin.site.register(models.Work, WorkAdmin)
class SectionInline(admin.TabularInline): class SectionInline(admin.TabularInline):
model = models.Section model = models.Section
fields = ['tag', 'start', 'end', 'page'] fields = ["tag", "start", "end", "page"]
class DocumentAdmin(admin.ModelAdmin): class DocumentAdmin(admin.ModelAdmin):
list_display = ['work', '__str__'] list_display = ["work", "__str__"]
list_filter = ['work__collection'] list_filter = ["work__collection"]
inlines = [SectionInline] inlines = [SectionInline]
admin.site.register(models.Document, DocumentAdmin) admin.site.register(models.Document, DocumentAdmin)
class ItemAdmin(admin.ModelAdmin): class ItemAdmin(admin.ModelAdmin):
list_display = ['project', 'work', 'order'] list_display = ["project", "work", "order"]
list_filter = ['project'] list_filter = ["project"]
admin.site.register(models.ProjectItem, ItemAdmin) admin.site.register(models.ProjectItem, ItemAdmin)
class EnsembleAccessAdmin(admin.ModelAdmin): class EnsembleAccessAdmin(admin.ModelAdmin):
list_display = ['ensemble', 'collection', 'access_type'] list_display = ["ensemble", "collection", "access_type"]
list_filter = ['ensemble'] list_filter = ["ensemble"]
admin.site.register(models.EnsembleAccess, EnsembleAccessAdmin) admin.site.register(models.EnsembleAccess, EnsembleAccessAdmin)
class OrchestrationAdmin(admin.ModelAdmin):
list_display = ['name', 'instruments']
admin.site.register(models.Orchestration, OrchestrationAdmin) class OrchestrationAdmin(admin.ModelAdmin):
list_display = ["name", "instruments"]
admin.site.register(models.Orchestration, OrchestrationAdmin)

View File

@ -2,4 +2,4 @@ from django.apps import AppConfig
class LibraryConfig(AppConfig): class LibraryConfig(AppConfig):
name = 'library' name = "library"

View File

@ -5,29 +5,42 @@ from interface.forms import BaseForm
class WorkCreateForm(forms.ModelForm, BaseForm): class WorkCreateForm(forms.ModelForm, BaseForm):
class Meta: class Meta:
model = Work 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): class PlaylistAddForm(forms.Form):
work = forms.ModelChoiceField(queryset=Work.objects.all()) work = forms.ModelChoiceField(queryset=Work.objects.all())
def __init__(self, instance, *args, **kwargs): 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): def save(self):
self.instance.works.add(self.cleaned_data['work']) self.instance.works.add(self.cleaned_data["work"])
class ProjectEnsembleChoiceField(forms.ModelChoiceField): class ProjectEnsembleChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj): def label_from_instance(self, obj):
return f"{obj.ensemble.name} - {obj.name}" return f"{obj.ensemble.name} - {obj.name}"
class ProjectSelectForm(BaseForm): class ProjectSelectForm(BaseForm):
project = ProjectEnsembleChoiceField(queryset=Project.objects.all()) project = ProjectEnsembleChoiceField(queryset=Project.objects.all())

View File

@ -4,17 +4,20 @@ import csv
from library import models from library import models
class Command(BaseCommand): class Command(BaseCommand):
help = 'Imports works from a csv file' help = "Imports works from a csv file"
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument('collection', type=int, help="Collection ID") parser.add_argument("collection", type=int, help="Collection ID")
parser.add_argument('source', type=argparse.FileType('r'), help="Source CSV") parser.add_argument("source", type=argparse.FileType("r"), help="Source CSV")
def handle(self, *args, **options): 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: for row in reader:
collection.works.create(name=row['Piece'], composer=row['Composer'], notes=row['Notes']) collection.works.create(
name=row["Piece"], composer=row["Composer"], notes=row["Notes"]
)

View File

@ -18,44 +18,54 @@ from interface.utils import sign_data
import logging import logging
#from polyphonic.settings import LIBRARY_STORAGE # from polyphonic.settings import LIBRARY_STORAGE
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
#try: # try:
# library_storage = get_storage_class(settings.LIBRARY_STORAGE)() # library_storage = get_storage_class(settings.LIBRARY_STORAGE)()
#except (ImportError, AttributeError): # except (ImportError, AttributeError):
# logger.exception("Failed to load library storage") # logger.exception("Failed to load library storage")
# library_storage = get_storage_class()() # 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 # FIXME: move back to settings
library_storage = CachedStorage(BYOStorage()) library_storage = CachedStorage(BYOStorage())
class Orchestration(models.Model): class Orchestration(models.Model):
""" """
Stores a list of instrument codes as a single entry (space delimited). Stores a list of instrument codes as a single entry (space delimited).
Can be global or ensemble specific 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) name = models.CharField(max_length=100)
instruments = models.TextField() instruments = models.TextField()
def as_list(self): def as_list(self):
tags = [ t.strip() for t in self.instruments.split(' ') ] tags = [t.strip() for t in self.instruments.split(" ")]
return [ (t, MusicTag.from_tag(t)) for t in tags if t ] return [(t, MusicTag.from_tag(t)) for t in tags if t]
def tag_order(self): def tag_order(self):
tags = [ t.strip() for t in self.instruments.split(' ') if t ] tags = [t.strip() for t in self.instruments.split(" ") if t]
order = {'score': 0} order = {"score": 0}
for i, t in enumerate(tags): for i, t in enumerate(tags):
order.setdefault(t.strip('-0123456789'), i*2+1) order.setdefault(t.strip("-0123456789"), i * 2 + 1)
return order return order
def sorter(self): def sorter(self):
tag_order = self.tag_order() tag_order = self.tag_order()
def f(x): 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 return f
def save(self): def save(self):
@ -65,75 +75,101 @@ class Orchestration(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
class ProjectItem(models.Model): class ProjectItem(models.Model):
""" """
ProjectItem represents a Work attached to a Project e.g. item in set list or programme 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. 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() checkout = models.DateTimeField()
due = models.DateTimeField(null=True, blank=True) due = models.DateTimeField(null=True, blank=True)
returned = 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) order = models.SmallIntegerField(default=0)
section = models.CharField(max_length=100, blank=True) section = models.CharField(max_length=100, blank=True)
class Meta: class Meta:
ordering = ['order', 'work'] ordering = ["order", "work"]
def __str__(self): def __str__(self):
return f"<{self.project_id}:{slugify(self.work.name)}>" return f"<{self.project_id}:{slugify(self.work.name)}>"
class Collection(models.Model): class Collection(models.Model):
""" """
A logical collection of works, typically owned by an organisation or person (physical or virtual) 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") name = models.CharField(max_length=255, help_text="Name of the collection")
prefix = models.CharField(max_length=255, default="default", prefix = models.CharField(
help_text="Folder to store works in") 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") administrators = models.ManyToManyField(
location = models.CharField(max_length=100, "auth.User",
help_text="Physical location (institution, town...)", blank=True) related_name="collections",
storage = models.ForeignKey('byostorage.UserStorage', on_delete=models.CASCADE, null=True, blank=True, help_text="Administrators for this collection",
help_text="User storage for documents") )
notes = models.TextField(blank=True, location = models.CharField(
help_text="Publicly visible notes about collection and loans policy (markdown format)") max_length=100, help_text="Physical location (institution, town...)", blank=True
settings = models.JSONField(default=dict, blank=True, )
help_text="Storage specific settings") storage = models.ForeignKey(
nonce = models.SmallIntegerField(default=1, "byostorage.UserStorage",
help_text="Increment this to reset the authentication links") 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): 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 return items
@property @property
def tags(self): def tags(self):
return self.meta('tag') return self.meta("tag")
@property @property
def genres(self): def genres(self):
return self.meta('genre') return self.meta("genre")
def has_administrator(self, user): def has_administrator(self, user):
if not user.is_authenticated: if not user.is_authenticated:
return False return False
if user.is_superuser: if user.is_superuser:
return True 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): def get_absolute_url(self):
return resolve_url('collection_work_list', self.pk) return resolve_url("collection_work_list", self.pk)
def auth(self): def auth(self):
return sign_data(f'{self.pk}-{self.nonce}', 12) return sign_data(f"{self.pk}-{self.nonce}", 12)
def __str__(self): def __str__(self):
return self.name return self.name
class EnsembleAccess(models.Model): class EnsembleAccess(models.Model):
""" """
Can have different access levels to a collection Can have different access levels to a collection
@ -143,12 +179,18 @@ class EnsembleAccess(models.Model):
ACCESS_APPROVED = 2 ACCESS_APPROVED = 2
ACCESS_TYPES = ( ACCESS_TYPES = (
(ACCESS_UNLIMITED, 'Unlimited'), (ACCESS_UNLIMITED, "Unlimited"),
(ACCESS_APPROVED, 'Approval required'), (ACCESS_APPROVED, "Approval required"),
) )
ensemble = models.ForeignKey('interface.Ensemble', on_delete=models.CASCADE, related_name="allowed_collections") ensemble = models.ForeignKey(
collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name="allowed_ensembles") "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) access_type = models.PositiveSmallIntegerField(choices=ACCESS_TYPES, default=2)
class Meta: class Meta:
@ -159,6 +201,7 @@ class Work(models.Model):
""" """
A musical work 'owned' by a collection from a licencing perspective. A musical work 'owned' by a collection from a licencing perspective.
""" """
LICENCE_PUBLIC = 2 LICENCE_PUBLIC = 2
LICENCE_EXPIRED = 4 LICENCE_EXPIRED = 4
LICENCE_RECORDING = 5 LICENCE_RECORDING = 5
@ -167,37 +210,73 @@ class Work(models.Model):
LICENCE_NONE = 10 LICENCE_NONE = 10
LICENCE_TYPES = ( LICENCE_TYPES = (
(LICENCE_PUBLIC, 'Public Domain'), (LICENCE_PUBLIC, "Public Domain"),
(LICENCE_EXPIRED, 'Copyright Expired'), (LICENCE_EXPIRED, "Copyright Expired"),
(LICENCE_RECORDING, 'Recording Licence'), (LICENCE_RECORDING, "Recording Licence"),
(LICENCE_PERFORMANCE, 'Performance Licence'), (LICENCE_PERFORMANCE, "Performance Licence"),
(LICENCE_PERUSAL, 'Perusal Licence'), (LICENCE_PERUSAL, "Perusal Licence"),
(LICENCE_NONE, 'Internal use only'), (LICENCE_NONE, "Internal use only"),
) )
name = models.CharField(max_length=255, help_text="Original name of the work") name = models.CharField(max_length=255, help_text="Original name of the work")
edition = models.CharField(max_length=255, blank=True, edition = models.CharField(
help_text="Edition details to distinguish multiple versions") max_length=255,
parent = models.ForeignKey('Work', null=True, blank=True, on_delete=models.SET_NULL, related_name="related_works", blank=True,
help_text="Arrangement of another work or part of an anthology") help_text="Edition details to distinguish multiple versions",
composer = models.CharField(max_length=255, default='Anon', )
help_text="Composer or compilation editor. Use <b>Surname, Initial</b> for easy searching") 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 <b>Surname, Initial</b> for easy searching",
)
orchestration = models.ForeignKey(Orchestration, on_delete=models.SET_DEFAULT, default=1, help_text="Orchestration for the work") orchestration = models.ForeignKey(
original_parts = models.JSONField(default=dict, blank=True, help_text="Original printed parts (IMSLP format)") 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 details
collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name="works") collection = models.ForeignKey(
code = models.CharField(max_length=100, blank=True, help_text="Collection specific code or number. Will be auto-generated if not supplied") Collection, on_delete=models.CASCADE, related_name="works"
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") 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 # 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) notes = models.TextField(blank=True)
# Allocation to projects # Allocation to projects
projects = models.ManyToManyField('interface.Project', through='ProjectItem', related_name="works", help_text="Current usage") projects = models.ManyToManyField(
"interface.Project",
through="ProjectItem",
related_name="works",
help_text="Current usage",
)
@property @property
def folder(self): def folder(self):
@ -205,19 +284,20 @@ class Work(models.Model):
def tagged_sections(self, *tags): def tagged_sections(self, *tags):
qs = self.docs.filter(sections__tag__in=tags) qs = self.docs.filter(sections__tag__in=tags)
qs = qs.annotate(Count('sections'), end=Min('sections__end'), start=Max('sections__start')) \ qs = qs.annotate(
.filter(sections__count=len(tags)) Count("sections"), end=Min("sections__end"), start=Max("sections__start")
).filter(sections__count=len(tags))
return qs return qs
def list_sections(self, *tags): 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 @property
def digital_parts(self): 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()) sections.sort(key=self.orchestration.sorter())
#return [ s[1] for s in sections ] # return [ s[1] for s in sections ]
sections = list(dict(sections).items()) # primitive unique() sections = list(dict(sections).items()) # primitive unique()
return sections return sections
def pdfs(self): def pdfs(self):
@ -229,19 +309,21 @@ class Work(models.Model):
return [] return []
parts = list(self.original_parts.items()) parts = list(self.original_parts.items())
parts.sort(key=self.orchestration.sorter()) 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 @property
def tags(self): 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 @property
def meta(self): def meta(self):
return self.meta_info.exclude(name='tag') return self.meta_info.exclude(name="tag")
@property @property
def current_loans(self): 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 @cached_property
def loans(self): def loans(self):
@ -259,29 +341,31 @@ class Work(models.Model):
@property @property
def available(self): def available(self):
if self.max_projects < 0: if self.max_projects < 0:
return 'Unlimited' return "Unlimited"
a = self.max_projects - self.loans 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 @property
def identifier(self): def identifier(self):
if self.code: if self.code:
return self.code; return self.code
composer = self.composer or "Anon" composer = self.composer or "Anon"
composer = re.sub('[^\w]', '', composer) composer = re.sub("[^\w]", "", composer)
words = self.name.split() words = self.name.split()
work = words[0][:3] work = words[0][:3]
return f"{composer[:4]}-{work}-{self.pk:05d}".upper() return f"{composer[:4]}-{work}-{self.pk:05d}".upper()
def assigned_instruments(self): 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): def unassigned_instruments(self):
assigned = set(self.assigned_instruments()) 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): def music_tags(self):
tags = dict(self.orchestration.as_list()) tags = dict(self.orchestration.as_list())
@ -290,31 +374,32 @@ class Work(models.Model):
return tags.items() return tags.items()
def __str__(self): def __str__(self):
return f"{self.name} ({self.composer})" return f"{self.name} ({self.composer})"
class WorkMeta(models.Model):
class WorkMeta(models.Model):
META_CHOICES = ( META_CHOICES = (
('tag', 'Tag'), ("tag", "Tag"),
('arr', 'Arranger'), ("arr", "Arranger"),
('lyrics', 'Lyracist'), ("lyrics", "Lyracist"),
('genre', 'Genre'), ("genre", "Genre"),
('style', 'Style'), ("style", "Style"),
('orchestration', 'Orchestration'), ("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) name = models.SlugField(max_length=20, choices=META_CHOICES)
value = models.CharField(max_length=255) value = models.CharField(max_length=255)
def doc_upload_filename(doc, filename): def doc_upload_filename(doc, filename):
collection = doc.work.collection collection = doc.work.collection
storage = collection.storage storage = collection.storage
if not storage: if not storage:
raise RuntimeError("Collection has no storage attached") 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): class Document(models.Model):
""" """
@ -324,22 +409,22 @@ class Document(models.Model):
DOCTYPE_PDF = 1 DOCTYPE_PDF = 1
DOCTYPE_AUDIO = 2 DOCTYPE_AUDIO = 2
DOCTYPE_VIDEO = 3 DOCTYPE_VIDEO = 3
DOCTYPE_MISC= 4 DOCTYPE_MISC = 4
DOCTYPES = ( DOCTYPES = (
(DOCTYPE_PDF, 'PDF'), (DOCTYPE_PDF, "PDF"),
(DOCTYPE_AUDIO, 'Audio'), (DOCTYPE_AUDIO, "Audio"),
(DOCTYPE_VIDEO, 'Video'), (DOCTYPE_VIDEO, "Video"),
(DOCTYPE_MISC, 'Misc'), (DOCTYPE_MISC, "Misc"),
) )
DOCTYPE_MAP = { DOCTYPE_MAP = {
'.pdf': DOCTYPE_PDF, ".pdf": DOCTYPE_PDF,
'.mp3': DOCTYPE_AUDIO, ".mp3": DOCTYPE_AUDIO,
'.mp4': DOCTYPE_VIDEO, ".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) doctype = models.PositiveSmallIntegerField(choices=DOCTYPES, default=DOCTYPE_PDF)
upload = models.FileField(upload_to=doc_upload_filename, storage=library_storage) upload = models.FileField(upload_to=doc_upload_filename, storage=library_storage)
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
@ -355,6 +440,7 @@ class Document(models.Model):
def __str__(self): def __str__(self):
return self.upload.name return self.upload.name
class Section(models.Model): class Section(models.Model):
""" """
Section is a tagged portion of a Document Section is a tagged portion of a Document
@ -365,20 +451,21 @@ class Section(models.Model):
PAGE_RIGHT = 2 PAGE_RIGHT = 2
PAGE_PREFERENCE = ( PAGE_PREFERENCE = (
(PAGE_AUTO, 'auto'), (PAGE_AUTO, "auto"),
(PAGE_LEFT, 'left'), (PAGE_LEFT, "left"),
(PAGE_RIGHT, 'right'), (PAGE_RIGHT, "right"),
) )
doc = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="sections") doc = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="sections")
tag = models.CharField(max_length=50, blank=True) tag = models.CharField(max_length=50, blank=True)
start = models.SmallIntegerField(null=True, blank=True) start = models.SmallIntegerField(null=True, blank=True)
end = 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: class Meta:
ordering = ['doc', 'start', 'pk'] ordering = ["doc", "start", "pk"]
@property @property
def music_tag(self): def music_tag(self):
@ -390,11 +477,11 @@ class Section(models.Model):
@property @property
def bulma_class(self): 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 @property
def filename(self): 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 @property
def pagerange(self): def pagerange(self):
@ -406,4 +493,3 @@ class Section(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name

View File

@ -1,4 +1,3 @@
from collections import namedtuple from collections import namedtuple
GENERAL = """ GENERAL = """
@ -158,20 +157,21 @@ zith Zither
MUSIC_TAGS = [] MUSIC_TAGS = []
GENERAL_TAGS = set() GENERAL_TAGS = set()
for i, abbreviations in enumerate((GENERAL, INSTRUMENTS)): 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) parts = line.strip().split(maxsplit=1)
if len(parts) < 2: continue if len(parts) < 2:
name, _, _ = parts[1].partition('(') continue
name, _, _ = parts[1].partition("(")
MUSIC_TAGS.append((parts[0], name)) MUSIC_TAGS.append((parts[0], name))
if i == 0: if i == 0:
GENERAL_TAGS.add(parts[0]) GENERAL_TAGS.add(parts[0])
MUSIC_NAME_BY_TAG = dict(MUSIC_TAGS) 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 @classmethod
def from_tag(cls, tag): def from_tag(cls, tag):
""" """
@ -186,13 +186,13 @@ class MusicTag(namedtuple('MusicTag', ('name', 'variant'), defaults=[None])):
>>> MusicTag.from_tag('pce-A2') >>> MusicTag.from_tag('pce-A2')
MusicTag(name='Piece', variant='A2') MusicTag(name='Piece', variant='A2')
""" """
abbr, _, variant = tag.partition('-') abbr, _, variant = tag.partition("-")
name = MUSIC_NAME_BY_TAG.get(abbr.lower(), abbr) name = MUSIC_NAME_BY_TAG.get(abbr.lower(), abbr)
if variant: if variant:
return cls(name, variant) return cls(name, variant)
return cls(name, None) return cls(name, None)
@property @property
def tag(self): def tag(self):
l = self.name.lower() l = self.name.lower()
@ -206,7 +206,7 @@ class MusicTag(namedtuple('MusicTag', ('name', 'variant'), defaults=[None])):
>>> MusicTag('Violin', 2).is_general >>> MusicTag('Violin', 2).is_general
False False
""" """
return self.tag in GENERAL_TAGS return self.tag in GENERAL_TAGS
def abbreviate(self): def abbreviate(self):
""" """
@ -231,12 +231,15 @@ class MusicTag(namedtuple('MusicTag', ('name', 'variant'), defaults=[None])):
return f"{self.name} {self.variant}" return f"{self.name} {self.variant}"
return self.name return self.name
import re 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): def auto_tag(filename):
''' """
>>> auto_tag('Ode to Joy - Violin 1.pdf') >>> auto_tag('Ode to Joy - Violin 1.pdf')
MusicTag(name='Violin', variant=1) MusicTag(name='Violin', variant=1)
>>> auto_tag('Ode to Joy_Cello.pdf') >>> auto_tag('Ode to Joy_Cello.pdf')
@ -247,7 +250,7 @@ def auto_tag(filename):
MusicTag(name='Viola', variant=None) MusicTag(name='Viola', variant=None)
>>> auto_tag('Ode to Joy - fl-2 (piccolo).pdf') >>> auto_tag('Ode to Joy - fl-2 (piccolo).pdf')
MusicTag(name='Flute', variant=2) MusicTag(name='Flute', variant=2)
''' """
for pattern in PATTERNS: for pattern in PATTERNS:
for inst, ordinal in pattern.findall(filename): for inst, ordinal in pattern.findall(filename):
@ -257,9 +260,9 @@ def auto_tag(filename):
return MusicTag(inst.title(), ordinal) return MusicTag(inst.title(), ordinal)
if inst in MUSIC_NAME_BY_TAG: if inst in MUSIC_NAME_BY_TAG:
return MusicTag(MUSIC_NAME_BY_TAG[inst], ordinal) return MusicTag(MUSIC_NAME_BY_TAG[inst], ordinal)
if __name__ == "__main__": if __name__ == "__main__":
import doctest import doctest
print(doctest.testmod())
print(doctest.testmod())

View File

@ -5,22 +5,23 @@ import string
SAFECHARS = string.ascii_letters + string.digits + " _-" SAFECHARS = string.ascii_letters + string.digits + " _-"
def extract_pages(source, bookmark, start=None, end=None, count=1): def extract_pages(source, bookmark, start=None, end=None, count=1):
return extract_and_concat([(source, bookmark, start, end, count)]) return extract_and_concat([(source, bookmark, start, end, count)])
def extract_and_concat(items): def extract_and_concat(items):
# create a temporary directory for our sections # create a temporary directory for our sections
d = tempfile.TemporaryDirectory(prefix="polyphonic_") d = tempfile.TemporaryDirectory(prefix="polyphonic_")
pdfmarks = os.path.join(d.name, 'pdfmarks.txt') pdfmarks = os.path.join(d.name, "pdfmarks.txt")
marks = open(pdfmarks, 'w') marks = open(pdfmarks, "w")
sections = [] sections = []
current_page = 1 current_page = 1
for i, (source, bookmark, start, end, count) in enumerate(items): for i, (source, bookmark, start, end, count) in enumerate(items):
if count == 0: if count == 0:
continue continue
@ -28,23 +29,34 @@ def extract_and_concat(items):
sections.append(source) sections.append(source)
else: else:
if not end: if not end:
end = start 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', cmd = [
f'-dFirstPage={start}', f'-dLastPage={end}', "gs",
f'-sOutputFile={dest}', "-sDEVICE=pdfwrite",
source] "-dBATCH",
"-dNOPAUSE",
f"-dFirstPage={start}",
f"-dLastPage={end}",
f"-sOutputFile={dest}",
source,
]
bookmark = "".join(filter(lambda c: c in SAFECHARS, bookmark)) 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) 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): for j in range(count):
sections.append(dest) sections.append(dest)
current_page += pages current_page += pages
@ -52,10 +64,9 @@ def extract_and_concat(items):
marks.close() marks.close()
# concat the items # concat the items
output = tempfile.NamedTemporaryFile(prefix="polyphonic_", suffix='.pdf') output = tempfile.NamedTemporaryFile(prefix="polyphonic_", suffix=".pdf")
cmd = ['gs', '-sDEVICE=pdfwrite', '-q', '-dBATCH', '-dNOPAUSE', cmd = ["gs", "-sDEVICE=pdfwrite", "-q", "-dBATCH", "-dNOPAUSE", "-sOutputFile=-"]
'-sOutputFile=-']
cmd.extend(sections) cmd.extend(sections)
cmd.append(pdfmarks) cmd.append(pdfmarks)

View File

@ -1,4 +1,4 @@
from interface.tests import AccessTestCase from interface.tests import AccessTestCase
from byostorage.user import UserStorage from byostorage.user import UserStorage
from . import models from . import models
@ -7,87 +7,101 @@ from .views.api import WorkSerializer
import tempfile import tempfile
import json import json
class LibraryTestCase(AccessTestCase):
class LibraryTestCase(AccessTestCase):
USERS = ( 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 = ( ENSEMBLES = (
{'name': 'The Be Sharps', 'slug': 'be-sharps', 'admins': ['homer']}, {"name": "The Be Sharps", "slug": "be-sharps", "admins": ["homer"]},
{'name': 'Lisa & the Bleeding Gums', 'slug': 'bleeding-gums'}, {"name": "Lisa & the Bleeding Gums", "slug": "bleeding-gums"},
{'name': 'Party Posse'}, {"name": "Party Posse"},
) )
PROJECTS = ( PROJECTS = (
{'name': 'Baker St', 'ensemble': 'bleeding-gums', 'when': -12}, {"name": "Baker St", "ensemble": "bleeding-gums", "when": -12},
{'name': 'Navy Recruitment Day', 'ensemble': 'party-posse', 'when': 6}, {"name": "Navy Recruitment Day", "ensemble": "party-posse", "when": 6},
{'name': 'Barbershop Contest', 'ensemble': 'be-sharps', 'when': 28}, {"name": "Barbershop Contest", "ensemble": "be-sharps", "when": 28},
{'name': 'Open Mic Night', 'ensemble': 'bleeding-gums', 'when': 1 }, {"name": "Open Mic Night", "ensemble": "bleeding-gums", "when": 1},
) )
COLLECTIONS = ( COLLECTIONS = (
{'name': 'Springfield Elementary Library', 'prefix': 'sel'}, {"name": "Springfield Elementary Library", "prefix": "sel"},
{'name': 'Neds Library', 'prefix': 'ned', 'admins': ['homer']}, {"name": "Neds Library", "prefix": "ned", "admins": ["homer"]},
) )
WORKS = ( 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 = ( PROTECTED_URLS = (
'/collections/1', "/collections/1",
'/collections/1/add', "/collections/1/add",
'/collections/2/works/1', "/collections/2/works/1",
'/collections/2/works/1/edit', "/collections/2/works/1/edit",
'/collections/2/works/1/partset', "/collections/2/works/1/partset",
'/collections/2/works/1/add_to_project', "/collections/2/works/1/add_to_project",
'/collections/2/works/1/upload', "/collections/2/works/1/upload",
'/collections/2/docs/1/annotate', "/collections/2/docs/1/annotate",
# Need to add storage before we can test these # Need to add storage before we can test these
'/api/collections/2', "/api/collections/2",
'/api/collections/2/works/1', "/api/collections/2/works/1",
"/admin/library/collection/",
'/admin/library/collection/', "/admin/library/document/",
'/admin/library/document/', "/admin/library/ensembleaccess/",
'/admin/library/ensembleaccess/', "/admin/library/orchestration/",
'/admin/library/orchestration/', "/admin/library/projectitem/",
'/admin/library/projectitem/', "/admin/library/work/",
'/admin/library/work/',
) )
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
super().setUpTestData() super().setUpTestData()
cls.temp_dir = tempfile.TemporaryDirectory() cls.temp_dir = tempfile.TemporaryDirectory()
cls.storage = UserStorage.objects.create(name='local', storage='django.core.files.storage.FileSystemStorage', cls.storage = UserStorage.objects.create(
settings_data=json.dumps({'location': cls.temp_dir.name, 'base_url': 'file://' + cls.temp_dir.name})) 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 = {} cls.collections = {}
for details in cls.COLLECTIONS: for details in cls.COLLECTIONS:
admins = details.pop('admins', []) admins = details.pop("admins", [])
obj = models.Collection.objects.create(storage=cls.storage, **details) obj = models.Collection.objects.create(storage=cls.storage, **details)
for admin in admins: for admin in admins:
obj.administrators.add(cls.users[admin]) obj.administrators.add(cls.users[admin])
cls.collections[details['prefix']] = obj cls.collections[details["prefix"]] = obj
cls.works = {} cls.works = {}
for details in cls.WORKS: for details in cls.WORKS:
collection = cls.collections[details.pop('collection')] collection = cls.collections[details.pop("collection")]
#details.setdefault('docs', []) # details.setdefault('docs', [])
#details.setdefault('meta_info', []) # details.setdefault('meta_info', [])
#s = WorkSerializer(data=details) # s = WorkSerializer(data=details)
#assert s.is_valid(), s.errors # assert s.is_valid(), s.errors
#s.save(collection_id=collection.pk) # s.save(collection_id=collection.pk)
docs = details.pop('docs', []) docs = details.pop("docs", [])
obj = models.Work.objects.create(collection=collection, **details) obj = models.Work.objects.create(collection=collection, **details)
for doc in docs: for doc in docs:
obj.docs.create(**doc) obj.docs.create(**doc)
cls.works[details['name']] = obj cls.works[details["name"]] = obj
def setUp(self): def setUp(self):
pass pass
@ -95,82 +109,105 @@ class LibraryTestCase(AccessTestCase):
@classmethod @classmethod
def tearDownClass(cls): def tearDownClass(cls):
cls.temp_dir.cleanup() cls.temp_dir.cleanup()
def test_integration(self): def test_integration(self):
pass pass
def test_superuser_access(self): def test_superuser_access(self):
self.login('admin', 'secret') self.login("admin", "secret")
self.assertAccess({ self.assertAccess(
'/collections': True, {
'/collections/1': True, "/collections": True,
'/collections/2/works/1': True, "/collections/1": True,
}) "/collections/2/works/1": True,
}
)
def test_administrator_access(self): def test_administrator_access(self):
self.login('homer', 'maggie') self.login("homer", "maggie")
self.assertAccess({ self.assertAccess(
'/collections': True, {
'/collections/1': False, "/collections": True,
'/collections/2': True, "/collections/1": False,
'/collections/2/works/1': True, "/collections/2": True,
}) "/collections/2/works/1": True,
}
)
def test_link_access(self): def test_link_access(self):
self.assertAccess({ self.assertAccess(
'/collections': True, {
'/collections/1': False, "/collections": True,
'/collections/2': False, "/collections/1": False,
'/collections/2/works/1': False, "/collections/2": False,
}) "/collections/2/works/1": False,
}
)
self.authorize(models.Collection, pk=2) self.authorize(models.Collection, pk=2)
self.assertAccess({ self.assertAccess(
'/collections': True, {
'/collections/1': False, "/collections": True,
'/collections/2': True, "/collections/1": False,
'/collections/2/works/1': True, "/collections/2": True,
}) "/collections/2/works/1": True,
}
)
def test_anon_access(self): def test_anon_access(self):
self.assertAccess({ self.assertAccess(
'/collections': True, {
'/collections/1': False, "/collections": True,
'/collections/2': False, "/collections/1": False,
'/collections/2/works/1': False, "/collections/2": False,
}) "/collections/2/works/1": False,
}
)
def test_export_and_import(self): def test_export_and_import(self):
self.login('admin', 'secret') self.login("admin", "secret")
data = self.client.get('/api/collections/1/works/2', HTTP_ACCEPT="application/json").json() data = self.client.get(
response = self.client.post('/api/collections/2/import', data, "application/json") "/api/collections/1/works/2", HTTP_ACCEPT="application/json"
self.assertEqual(response.status_code, 201) ).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): 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. 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' 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") work = self.collections["sel"].works.create(
for g in ('vl-1', 'vl-2', 'vla', 'vc'): name="Some Quartet", composer="Beethoven"
doc = work.docs.create(upload=f'sel/beethoven/some_quartet/some_quartet_{g}.pdf') )
doc.sections.create(tag='mvmt-1', start=1, end=3) for g in ("vl-1", "vl-2", "vla", "vc"):
doc.sections.create(tag='mvmt-2', start=4, end=8) doc = work.docs.create(
doc.sections.create(tag='mvmt-3', start=9, end=12) 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) doc.sections.create(tag=g)
# no tags - get nothing (should it be everything?) # no tags - get nothing (should it be everything?)
self.assertEqual(work.list_sections(), []) self.assertEqual(work.list_sections(), [])
# single tag - should get just that range # 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 # 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) self.assertEqual(len(result), 4)
# multiple tags - returns the overlapping portion of all documents that have all tags # 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(
self.assertEqual(work.list_sections('vl-1', 'vl-2'), []) 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"), [])

View File

@ -6,42 +6,120 @@ from . import views
from library.views import api from library.views import api
#router = routers.DefaultRouter() # router = routers.DefaultRouter()
#router.register(r'collection', external.CollectionViewSet, basename="collection") # router.register(r'collection', external.CollectionViewSet, basename="collection")
#router.register(r'work', external.WorkViewSet, basename="work") # router.register(r'work', external.WorkViewSet, basename="work")
urlpatterns = [ urlpatterns = [
path(
path('projects/<int:project>/items', views.ProjectItemListView.as_view(), name="item_list"), "projects/<int:project>/items",
path('projects/<int:project>/items/manage', views.ProjectItemManageView.as_view(), name="item_list_manage"), views.ProjectItemListView.as_view(),
path('projects/<int:project>/items/append', views.ProjectItemAddView.as_view(), name="item_list_append"), name="item_list",
),
path('library', views.LibraryWorkListView.as_view(), name="work_list"), path(
"projects/<int:project>/items/manage",
path('collections', views.CollectionListView.as_view(), name="collection_list"), views.ProjectItemManageView.as_view(),
path('collections/<int:collection>', views.CollectionWorkListView.as_view(), name="collection_work_list"), name="item_list_manage",
path('collections/<int:collection>/add', views.WorkAddView.as_view(), name="work_add"), ),
path(
path('collections/<int:collection>/works/<int:pk>', views.WorkDetailView.as_view(), name="work_detail"), "projects/<int:project>/items/append",
path('collections/<int:collection>/works/<int:pk>/edit', views.WorkUpdateView.as_view(), name="work_edit"), views.ProjectItemAddView.as_view(),
path('collections/<int:collection>/works/<int:pk>/partset', views.WorkPartSetView.as_view(), name="work_partset"), name="item_list_append",
path('collections/<int:collection>/works/<int:pk>/parts', views.WorkPartsView.as_view(), name="work_parts"), ),
path('collections/<int:collection>/works/<int:pk>/add_to_project', views.WorkAddToProject.as_view(), name="work_add_to_project"), path("library", views.LibraryWorkListView.as_view(), name="work_list"),
path('collections/<int:collection>/works/<int:pk>/upload', views.WorkAddDocumentView.as_view(), name="document_add"), path("collections", views.CollectionListView.as_view(), name="collection_list"),
path('collections/<int:collection>/works/<int:pk>/download', views.WorkDownloadView.as_view(), name="work_download"), path(
"collections/<int:collection>",
path('collections/<int:collection>/docs/<int:pk>/delete', views.DocumentDeleteView.as_view(), name="document_delete"), views.CollectionWorkListView.as_view(),
path('collections/<int:collection>/docs/<int:pk>/download', views.DocumentDownloadView.as_view(), name="document_download"), name="collection_work_list",
path('collections/<int:collection>/docs/<int:pk>/annotate', views.DocumentAnnotateView.as_view(), name="document_annotate"), ),
path(
path('collections/<int:collection>/download/<int:section>/<str:filename>', views.PartDownloadView.as_view(), name="part_download"), "collections/<int:collection>/add", views.WorkAddView.as_view(), name="work_add"
path('collections/<int:collection>/browse', views.StorageBrowserView.as_view(), name="storage_browser"), ),
path('collections/<int:collection>/browse/<path:folder>', views.StorageBrowserView.as_view(), name="storage_browser_folder"), path(
"collections/<int:collection>/works/<int:pk>",
#path('api/', include(router.urls)) views.WorkDetailView.as_view(),
path('api/collections/<int:pk>', api.CollectionExportView.as_view(), name="collection_export"), name="work_detail",
path('api/collections/<int:collection>/works/<int:pk>', api.WorkExportView.as_view(), name="work_export"), ),
path('api/collections/<int:collection>/import', api.WorkImportView.as_view(), name="work_import"), path(
path('api/collections/<int:collection>/bulk_import', api.CollectionImportView.as_view(), name="collection_import"), "collections/<int:collection>/works/<int:pk>/edit",
views.WorkUpdateView.as_view(),
name="work_edit",
),
path(
"collections/<int:collection>/works/<int:pk>/partset",
views.WorkPartSetView.as_view(),
name="work_partset",
),
path(
"collections/<int:collection>/works/<int:pk>/parts",
views.WorkPartsView.as_view(),
name="work_parts",
),
path(
"collections/<int:collection>/works/<int:pk>/add_to_project",
views.WorkAddToProject.as_view(),
name="work_add_to_project",
),
path(
"collections/<int:collection>/works/<int:pk>/upload",
views.WorkAddDocumentView.as_view(),
name="document_add",
),
path(
"collections/<int:collection>/works/<int:pk>/download",
views.WorkDownloadView.as_view(),
name="work_download",
),
path(
"collections/<int:collection>/docs/<int:pk>/delete",
views.DocumentDeleteView.as_view(),
name="document_delete",
),
path(
"collections/<int:collection>/docs/<int:pk>/download",
views.DocumentDownloadView.as_view(),
name="document_download",
),
path(
"collections/<int:collection>/docs/<int:pk>/annotate",
views.DocumentAnnotateView.as_view(),
name="document_annotate",
),
path(
"collections/<int:collection>/download/<int:section>/<str:filename>",
views.PartDownloadView.as_view(),
name="part_download",
),
path(
"collections/<int:collection>/browse",
views.StorageBrowserView.as_view(),
name="storage_browser",
),
path(
"collections/<int:collection>/browse/<path:folder>",
views.StorageBrowserView.as_view(),
name="storage_browser_folder",
),
# path('api/', include(router.urls))
path(
"api/collections/<int:pk>",
api.CollectionExportView.as_view(),
name="collection_export",
),
path(
"api/collections/<int:collection>/works/<int:pk>",
api.WorkExportView.as_view(),
name="work_export",
),
path(
"api/collections/<int:collection>/import",
api.WorkImportView.as_view(),
name="work_import",
),
path(
"api/collections/<int:collection>/bulk_import",
api.CollectionImportView.as_view(),
name="collection_import",
),
] ]

View File

@ -1,6 +1,7 @@
""" """
Views relating to importing and exporting collection items Views relating to importing and exporting collection items
""" """
""" """
from interface.views import EnsembleMixin from interface.views import EnsembleMixin
from library.views import WorkMixin from library.views import WorkMixin
@ -49,22 +50,24 @@ import os.path
from django.db import transaction from django.db import transaction
from django.core.files.uploadedfile import TemporaryUploadedFile from django.core.files.uploadedfile import TemporaryUploadedFile
class WorkMetaSerializer(serializers.ModelSerializer): class WorkMetaSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = WorkMeta model = WorkMeta
exclude = ['id', 'work'] exclude = ["id", "work"]
def to_representation(self, instance): def to_representation(self, instance):
return f"{instance.name}:{instance.value}" return f"{instance.name}:{instance.value}"
def to_internal_value(self, data): def to_internal_value(self, data):
name, _, value = data.partition(':') name, _, value = data.partition(":")
return super().to_internal_value({'name': name, 'value': value}) return super().to_internal_value({"name": name, "value": value})
class SectionSerializer(serializers.ModelSerializer): class SectionSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Section model = Section
exclude = ['id', 'doc'] exclude = ["id", "doc"]
def to_representation(self, instance): def to_representation(self, instance):
start = instance.start or 0 start = instance.start or 0
@ -79,14 +82,14 @@ class SectionSerializer(serializers.ModelSerializer):
start = None start = None
if end < 1: if end < 1:
end = None 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): class DocumentSerializer(serializers.ModelSerializer):
upload = serializers.URLField() upload = serializers.URLField()
sections = SectionSerializer(many=True) sections = SectionSerializer(many=True)
#def to_internal_value(self, data): # def to_internal_value(self, data):
# r = requests.get(data['upload'], stream=True) # r = requests.get(data['upload'], stream=True)
# with tempfile.NamedTemporaryFile('wb') as f: # with tempfile.NamedTemporaryFile('wb') as f:
# shutil.copyfileobj(r.raw, f) # shutil.copyfileobj(r.raw, f)
@ -96,7 +99,7 @@ class DocumentSerializer(serializers.ModelSerializer):
def to_representation(self, instance): def to_representation(self, instance):
data = super().to_representation(instance) data = super().to_representation(instance)
data['upload'] = instance.upload.url data["upload"] = instance.upload.url
return data return data
def create(self, validated_data): def create(self, validated_data):
@ -115,35 +118,40 @@ class DocumentSerializer(serializers.ModelSerializer):
model = Document model = Document
exclude = ["id", "work", "version", "created"] exclude = ["id", "work", "version", "created"]
# Serializers define the API representation. # Serializers define the API representation.
class WorkSerializer(serializers.ModelSerializer): class WorkSerializer(serializers.ModelSerializer):
docs = DocumentSerializer(many=True) docs = DocumentSerializer(many=True)
meta_info = WorkMetaSerializer(many=True) meta_info = WorkMetaSerializer(many=True)
class Meta: class Meta:
model = Work model = Work
exclude = ['id', 'collection', 'projects', 'parent'] exclude = ["id", "collection", "projects", "parent"]
def create(self, validated): def create(self, validated):
with transaction.atomic(): with transaction.atomic():
docs = validated.pop('docs', []) docs = validated.pop("docs", [])
meta = validated.pop('meta_info', []) meta = validated.pop("meta_info", [])
work = Work.objects.create(**validated) work = Work.objects.create(**validated)
for d in docs: 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) 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: if r.status_code != 200:
raise APIException("Failed to download file") 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) shutil.copyfileobj(r.raw, f.file)
r.close() r.close()
d['upload'] = f d["upload"] = f
doc = Document.objects.create(work_id=work.pk, **d) doc = Document.objects.create(work_id=work.pk, **d)
for s in sections: for s in sections:
@ -154,22 +162,24 @@ class WorkSerializer(serializers.ModelSerializer):
return work return work
class CollectionSerializer(serializers.Serializer): class CollectionSerializer(serializers.Serializer):
works = WorkSerializer(many=True) works = WorkSerializer(many=True)
def create(self, validated): def create(self, validated):
s = WorkSerializer() s = WorkSerializer()
print(validated) print(validated)
collection = validated['collection_id'] collection = validated["collection_id"]
with transaction.atomic(): with transaction.atomic():
for work in validated['works']: for work in validated["works"]:
work['collection_id'] = collection work["collection_id"] = collection
s.create(work) s.create(work)
return Collection.objects.get(pk=collection) return Collection.objects.get(pk=collection)
from rest_framework import generics from rest_framework import generics
class CollectionExportView(AuthorizedResourceMixin, generics.RetrieveAPIView): class CollectionExportView(AuthorizedResourceMixin, generics.RetrieveAPIView):
serializer_class = CollectionSerializer serializer_class = CollectionSerializer
@ -178,23 +188,26 @@ class CollectionExportView(AuthorizedResourceMixin, generics.RetrieveAPIView):
return Collection.objects.all() return Collection.objects.all()
return Collection.objects.filter(administrators=self.request.user) return Collection.objects.filter(administrators=self.request.user)
class WorkExportView(AuthorizedResourceMixin, generics.RetrieveAPIView): class WorkExportView(AuthorizedResourceMixin, generics.RetrieveAPIView):
serializer_class = WorkSerializer serializer_class = WorkSerializer
def get_queryset(self): 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: if self.request.user.is_superuser:
return works return works
return works.filter(collection__administrators=self.request.user) return works.filter(collection__administrators=self.request.user)
class WorkImportView(AuthorizedResourceMixin, generics.CreateAPIView): class WorkImportView(AuthorizedResourceMixin, generics.CreateAPIView):
serializer_class = WorkSerializer serializer_class = WorkSerializer
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(collection_id=self.kwargs['collection']) serializer.save(collection_id=self.kwargs["collection"])
class CollectionImportView(AuthorizedResourceMixin, generics.CreateAPIView): class CollectionImportView(AuthorizedResourceMixin, generics.CreateAPIView):
serializer_class = CollectionSerializer serializer_class = CollectionSerializer
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(collection_id=self.kwargs['pk']) serializer.save(collection_id=self.kwargs["pk"])

View File

@ -25,6 +25,7 @@ packages = [{include = "*", from="app"}]
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
django-debug-toolbar = "5.2" django-debug-toolbar = "5.2"
ruff = "^0.15.12"
[tool.poetry.scripts] [tool.poetry.scripts]
manage = "manage:main" manage = "manage:main"