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):