diff --git a/app/library/forms.py b/app/library/forms.py index abb90dc..6b9d6ab 100644 --- a/app/library/forms.py +++ b/app/library/forms.py @@ -5,7 +5,6 @@ from interface.forms import BaseForm class WorkCreateForm(forms.ModelForm, BaseForm): - #uploads = forms.FileField(label="PDFs to upload", widget=forms.ClearableFileInput(attrs={'multiple': True}), required=False) class Meta: model = Work diff --git a/app/library/migrations/0013_auto_20230223_1322.py b/app/library/migrations/0013_auto_20230223_1322.py new file mode 100644 index 0000000..05776f8 --- /dev/null +++ b/app/library/migrations/0013_auto_20230223_1322.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.7 on 2023-02-23 02:22 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0012_auto_20230220_1013'), + ] + + operations = [ + migrations.AlterModelOptions( + name='section', + options={'ordering': ['doc', 'start', 'pk']}, + ), + migrations.RemoveField( + model_name='section', + name='ordinal', + ), + migrations.RemoveField( + model_name='section', + name='type', + ), + ] diff --git a/app/library/migrations/0014_auto_20230223_1422.py b/app/library/migrations/0014_auto_20230223_1422.py new file mode 100644 index 0000000..e12451f --- /dev/null +++ b/app/library/migrations/0014_auto_20230223_1422.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.7 on 2023-02-23 03:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0013_auto_20230223_1322'), + ] + + operations = [ + migrations.AddField( + model_name='collection', + name='nonce', + field=models.SmallIntegerField(default=1, help_text='Increment this to reset the authentication links'), + ), + migrations.AlterField( + model_name='work', + name='code', + field=models.CharField(blank=True, help_text='Collection specific code or number. Will be auto-generated if not supplied', max_length=100), + ), + migrations.AlterField( + model_name='work', + name='composer', + field=models.CharField(default='Anon', help_text='Composer or compilation editor. Use Surname, Initial for easy searching', max_length=255), + ), + ] diff --git a/app/library/models.py b/app/library/models.py index 156c0d6..bb2b001 100644 --- a/app/library/models.py +++ b/app/library/models.py @@ -1,5 +1,6 @@ from os import SCHED_OTHER from django.conf import settings +from django.shortcuts import resolve_url from django.db import models from django.utils.text import slugify from django.utils.timezone import now @@ -11,7 +12,9 @@ import re from byostorage.user import BYOStorage from byostorage.cached import CachedStorage -from .imslp import Instrument + +from library.music_tags import MusicTag +from interface.utils import sign_data import logging @@ -41,7 +44,7 @@ class Orchestration(models.Model): def as_list(self): tags = [ t.strip() for t in self.instruments.split(' ') ] - return [ (t, Instrument.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): tags = [ t.strip() for t in self.instruments.split(' ') if t ] @@ -101,6 +104,8 @@ class Collection(models.Model): help_text="User storage for documents") notes = models.TextField(blank=True, help_text="Publicly visible notes about collection and loans policy (markdown format)") + 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() @@ -121,6 +126,12 @@ class Collection(models.Model): return 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) + + def auth(self): + return sign_data(f'{self.pk}-{self.nonce}', 12) + def __str__(self): return self.name @@ -171,14 +182,14 @@ class Work(models.Model): 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="Surname, Initials") + 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)") # 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") + 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") @@ -213,7 +224,7 @@ class Work(models.Model): return [] parts = list(self.original_parts.items()) parts.sort(key=self.orchestration.sorter()) - return [ (Instrument.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): @@ -314,7 +325,7 @@ class Document(models.Model): ) work = models.ForeignKey('Work', on_delete=models.CASCADE, related_name="docs") - doctype = models.PositiveSmallIntegerField(choices=DOCTYPES, default=1) + 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) version = models.CharField(max_length=30, blank=True) @@ -330,23 +341,7 @@ class Section(models.Model): """ Section is a tagged portion of a Document """ - - TYPE_INSTRUMENT = 1 - TYPE_MOVEMENT = 2 - TYPE_EXCERPT = 3 - - SECTION_TYPES = ( - (TYPE_INSTRUMENT, "Instrument"), - (TYPE_MOVEMENT, "Movement"), - (TYPE_EXCERPT, "Excerpt"), - ) - - SECTION_CLASSES = { - TYPE_INSTRUMENT: 'info', - TYPE_MOVEMENT: 'success', - TYPE_EXCERPT: 'warning', - } - + PAGE_AUTO = 0 PAGE_LEFT = 1 PAGE_RIGHT = 2 @@ -358,33 +353,30 @@ class Section(models.Model): ) - type = models.SmallIntegerField(choices=SECTION_TYPES) doc = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="sections") tag = models.CharField(max_length=50, blank=True) - ordinal = models.IntegerField(default=0) 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 class Meta: - ordering = ['type', 'ordinal', 'doc', 'start', 'pk'] + ordering = ['doc', 'start', 'pk'] + + @property + def music_tag(self): + return MusicTag.from_tag(self.tag) @property def name(self): - if self.type == self.TYPE_INSTRUMENT: - instr = Instrument.from_tag(self.tag) - if self.ordinal: - return f'{instr} {self.ordinal}' - return str(instr) - return f"{self.ordinal} - {self.tag}" + return str(self.music_tag) @property def bulma_class(self): - return self.SECTION_CLASSES[self.type] + return "success" if self.music_tag.is_general else 'info' @property def filename(self): - return slugify(f'{self.doc.work.name} - {self.name}') + '.pdf' + return slugify(f'{self.doc.work.name} - {self.name}').title() + '.pdf' @property def pagerange(self): diff --git a/app/library/imslp.py b/app/library/music_tags.py similarity index 61% rename from app/library/imslp.py rename to app/library/music_tags.py index a5d372c..b72e709 100644 --- a/app/library/imslp.py +++ b/app/library/music_tags.py @@ -1,11 +1,19 @@ from collections import namedtuple -# taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_Instruments -# Place any extra abbreviations at the top +GENERAL = """ +mvmt Movement +ex Excerpt +sec Section +pce Piece +""" -ABBREVIATIONS = """ +# taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_MusicTags +# Abbreviations at the top will take precidence in reverse lookups + +INSTRUMENTS = """ score Score + cb Double bass mall Mallet percussion @@ -25,7 +33,7 @@ bgtr Bass guitar bjo Banjo bn Bassoon bob Bass oboe (Baritone oboe) -br Brass instruments +br Brass MusicTags bryt Baryton bstcl Basset clarinet bsthn Basset horn @@ -52,7 +60,7 @@ dom Domra dulc Dulcimer egtr Electric guitar eh English horn (Cor anglais) -elec Electronic Instruments +elec Electronic MusicTags epf Electric piano eq Equal voices erhu Erhu @@ -72,7 +80,7 @@ heck Heckelphone hn Horn hp Harp hpd Harpsichord -kbd Keyboard instrument +kbd Keyboard MusicTag lute Lute lyre Lyre mand Mandolin @@ -114,7 +122,7 @@ skbt Sackbut sop Soprano (voice) srp Serpent stpt Slide trumpet -str String instruments +str String MusicTags sxh Saxhorn syn Synthesizer tba Tuba @@ -138,48 +146,45 @@ vn Violin vuv Vuvuzela vv Voices (multiple soloists) wag Wagner tuba -ww Woodwind instruments +ww Woodwind MusicTags xiao Xiao xyl Xylophone zith Zither """ -ORCHESTRATIONS = { - 'SATB': ('S', 'A', 'T', 'B'), - 'String Quartet': ('Vln1', 'Vln2', 'Vla', 'Vc'), - 'String Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb'), - 'Chamber Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb', - 'Fl1', 'Fl2', 'Cl1', 'Cl2', 'Hn1', 'Hn2', - 'Tpt1', 'Tpt2', 'Tbn1', 'Tbn2', 'Tuba', - 'Timp', 'Drum', 'Perc'), - 'Custom': (), -} +MUSIC_TAGS = [] +GENERAL_TAGS = set() +for i, abbreviations in enumerate((GENERAL, INSTRUMENTS)): + for line in abbreviations.split('\n'): + parts = line.strip().split(maxsplit=1) + if len(parts) < 2: continue + name, _, _ = parts[1].partition('(') + MUSIC_TAGS.append((parts[0], name)) + if i == 0: + GENERAL_TAGS.add(parts[0]) -INSTRUMENTS = [] -for line in ABBREVIATIONS.split('\n'): - parts = line.strip().split(maxsplit=1) - if len(parts) < 2: continue - name, _, _ = parts[1].partition('(') - INSTRUMENTS.append((parts[0], name)) +MUSIC_NAME_BY_TAG = dict(MUSIC_TAGS) +MUSIC_TAG_BY_NAME = dict( ( (x[1].lower(), x[0]) for x in MUSIC_TAGS ) ) -INSTRUMENT_NAMES = dict(INSTRUMENTS) -INSTRUMENT_TAGS = dict( ( (x[1].lower(), x[0]) for x in INSTRUMENTS ) ) - -class Instrument(namedtuple('Instrument', ('name', 'variant'), defaults=[None])): +class MusicTag(namedtuple('MusicTag', ('name', 'variant'), defaults=[None])): @classmethod def from_tag(cls, tag): """ - >>> Instrument.from_tag('vn-1') - Instrument(name='Violin', variant='1') - >>> Instrument.from_tag('db') - Instrument(name='Double Bass', variant=None) - >>> Instrument.from_tag('Jaws Harp') - Instrument(name='Jaws Harp', variant=None) + >>> MusicTag.from_tag('vn-1') + MusicTag(name='Violin', variant='1') + >>> MusicTag.from_tag('db') + MusicTag(name='Double Bass', variant=None) + >>> MusicTag.from_tag('Jaws Harp') + MusicTag(name='Jaws Harp', variant=None) + >>> MusicTag.from_tag('mvmt-2') + MusicTag(name='Movement', variant='2') + >>> MusicTag.from_tag('pce-A2') + MusicTag(name='Piece', variant='A2') """ abbr, _, variant = tag.partition('-') - name = INSTRUMENT_NAMES.get(abbr.lower(), abbr) + name = MUSIC_NAME_BY_TAG.get(abbr.lower(), abbr) if variant: return cls(name, variant) @@ -188,25 +193,35 @@ class Instrument(namedtuple('Instrument', ('name', 'variant'), defaults=[None])) @property def tag(self): l = self.name.lower() - return INSTRUMENT_TAGS.get(l, l) + return MUSIC_TAG_BY_NAME.get(l, l) + + @property + def is_general(self): + """ + >>> MusicTag('Piece', 'A3').is_general + True + >>> MusicTag('Violin', 2).is_general + False + """ + return self.tag in GENERAL_TAGS def abbreviate(self): """ - >>> Instrument('Violin', 1).abbreviate() + >>> MusicTag('Violin', 1).abbreviate() 'vn-1' - >>> Instrument('Double Bass').abbreviate() + >>> MusicTag('Double Bass').abbreviate() 'db' """ - tag = INSTRUMENT_TAGS.get(self.name.lower()) + tag = MUSIC_TAG_BY_NAME.get(self.name.lower()) if self.variant: tag = f"{tag}-{self.variant}" return tag def __str__(self): """ - >>> str(Instrument('Violin', 1)) + >>> str(MusicTag('Violin', 1)) 'Violin 1' - >>> str(Instrument('Double Bass')) + >>> str(MusicTag('Double Bass')) 'Double Bass' """ if self.variant: diff --git a/app/library/tests.py b/app/library/tests.py index 731b8c8..6c2f9d6 100644 --- a/app/library/tests.py +++ b/app/library/tests.py @@ -1,12 +1,56 @@ -from django.test import TestCase +from interface.tests import AccessTestCase from django.contrib.auth.models import User from interface.models import Ensemble, Project from . import models -class IntegrationTestCase(TestCase): +class IntegrationTestCase(AccessTestCase): - def setUp(self): + USERS = ( + {'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'}, + ) + + PROJECTS = ( + ('Baker St', 'bleeding-gums', -12), + ('Navy Recruitment Day', 'party-posse', 6), + ('Barbershop Contest', 'be-sharps', 28), + ('Open Mic Night', 'bleeding-gums', 1) + ) + + COLLECTIONS = ( + {'name': 'Springfield Elementary Library', 'prefix': 'sel'}, + {'name': 'Neds Library', 'prefix': 'ned', 'admins': ['homer']}, + ) + + WORKS = ( + {'name': 'Baby on Board', 'collection': 'ned'}, + ) + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.collections = {} + for details in cls.COLLECTIONS: + admins = details.pop('admins', []) + obj = models.Collection.objects.create(**details) + for admin in admins: + obj.administrators.add(cls.users[admin]) + cls.collections[details['prefix']] = obj + + cls.works = {} + for details in cls.WORKS: + collection = details.pop('collection') + cls.works[details['name']] = models.Work.objects.create(collection=cls.collections[collection], **details) + + def oldSetUp(self): self.homer = User.objects.create(username='homer') self.ned = User.objects.create(username="ned") self.lisa = User.objects.create(username="lisa") @@ -21,30 +65,72 @@ class IntegrationTestCase(TestCase): 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, + }) + + def test_administrator_access(self): + 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.authorize(models.Collection, pk=2) + 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, + }) + + 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.sel.works.create(name="Some Quartet", composer="Beethoven") - for g in ('vl1', 'vl2', 'vla', 'vc'): + 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=f'inst:{g}') + 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.extract(), []) # single tag - should get just that range - self.assertEqual(work.extract('inst:vl1'), [('sel/beethoven/some_quartet/some_quartet_vl1.pdf', None, None)]) + self.assertEqual(work.extract('vl-1'), [('sel/beethoven/some_quartet/some_quartet_vl-1.pdf', None, None)]) # single tag - returns all documents with that range - result = work.extract('mvmt:2') + result = work.extract('mvmt-2') self.assertEqual(len(result), 4) # multiple tags - returns the overlapping portion of all documents that have all tags - self.assertEqual(work.extract('inst:vl1', 'mvmt:2'), [('sel/beethoven/some_quartet/some_quartet_vl1.pdf', 4, 8)]) - self.assertEqual(work.extract('inst:vl1', 'inst:vl2'), []) \ No newline at end of file + self.assertEqual(work.extract('vl-1', 'mvmt-2'), [('sel/beethoven/some_quartet/some_quartet_vl-1.pdf', 4, 8)]) + self.assertEqual(work.extract('vl-1', 'vl-2'), []) \ No newline at end of file diff --git a/app/library/views/__init__.py b/app/library/views/__init__.py index 819a8a9..816576d 100644 --- a/app/library/views/__init__.py +++ b/app/library/views/__init__.py @@ -9,6 +9,8 @@ from django.db import transaction from django.utils.timezone import now from django.urls import reverse from django.template.loader import render_to_string +from django.core.exceptions import SuspiciousOperation +from django.http import Http404, HttpResponseRedirect import json import os.path @@ -17,14 +19,10 @@ import re from interface.views import EnsembleMixin, ProjectMixin, AuthorizedResourceMixin from interface.models import Project from library.models import Collection, Work, Document, Section -from library.imslp import INSTRUMENT_TAGS, INSTRUMENTS +from library.music_tags import MUSIC_TAGS from library import forms, models from library.pdf_utils import extract_pages, extract_and_concat -""" - -""" - class ProjectItemListView(ProjectMixin, ListView): template_name = "library/item_list.html" @@ -72,7 +70,7 @@ class ProjectItemListView(ProjectMixin, ListView): def get_context_data(self, **kwargs): data = super(ProjectItemListView, self).get_context_data(**kwargs) - data['instruments'] = INSTRUMENTS + data['instruments'] = MUSIC_TAGS data['instrument'] = self.request.session.get('instrument', 'Score') data['part'] = self.request.session.get('part', '0') data['running_time'] = self.get_queryset().aggregate(Sum('work__running_time'))['work__running_time__sum'] @@ -129,7 +127,10 @@ class CollectionMixin(AuthorizedResourceMixin): if self.collection.has_administrator(self.request.user): self.request.is_admin = True return True - + + if self.is_authorized_key('collection', collection_id, self.collection.nonce): + return True + return False def get_context_data(self, **kwargs): @@ -159,6 +160,14 @@ class CollectionListView(ListView): class WorkListView(CollectionMixin, ListView): paginate_by = 20 + def request_denied(self): + if 'auth' in self.request.GET: + if self.request.GET['auth'] != self.collection.auth(): + raise SuspiciousOperation("Bad collection link") + self.add_authorized_key('collection', self.collection.pk, self.collection.nonce) + return HttpResponseRedirect(self.request.path) + return super().request_denied() + def get_works(self): collections = CollectionMixin.get_queryset(self) return Work.objects.filter(collection__in=collections).select_related('collection') @@ -230,7 +239,7 @@ class WorkDetailView(CollectionMixin, DetailView): model = models.Work class WorkUpdateView(CollectionMixin, UpdateView): - #fields = ['name', 'composer', 'edition', 'code', 'orchestration', 'licence', 'max_projects', 'running_time', 'notes'] + model = models.Work form_class = forms.WorkCreateForm template_name = 'interface/default_form.html' @@ -348,12 +357,10 @@ class DocumentMixin(CollectionMixin): model = models.Document def get_queryset(self): - return models.Document.objects.filter(work__collection=self.collection) - -# def get_queryset(self): -# if self.request.is_admin: -# return Document.objects.select_related('work') -# return Document.objects.filter(work__ensemble=self.request.ensemble_id).select_related('work') + qs = models.Document.objects.select_related('work') + if self.request.is_admin: + return qs + return qs.filter(work__collection=self.collection) class DocumentDetailView(DocumentMixin, DetailView): pass @@ -395,7 +402,7 @@ class DocumentAnnotateView(DocumentMixin, DetailView): for part in data['document'].sections.all(): pages.append((part.tag, part.start, part.end)) - data['json_data'] = {'pageTags': pages, 'instruments': dict(INSTRUMENTS)} + data['json_data'] = {'pageTags': pages, 'instruments': dict(MUSIC_TAGS)} return data class DocumentDeleteView(DocumentMixin, DeleteView):