Compare commits

...

2 Commits

Author SHA1 Message Date
5e0e165037 Interface cleanup 2026-05-12 11:04:22 +10:00
4164d56dea Library cleanup 2026-05-12 11:03:11 +10:00
24 changed files with 937 additions and 576 deletions

View File

@ -2,26 +2,31 @@ from django.contrib import admin
from . import models from . import models
class EnsembleAdmin(admin.ModelAdmin): class EnsembleAdmin(admin.ModelAdmin):
list_display = ['name', 'slug'] list_display = ["name", "slug"]
class ModuleInline(admin.StackedInline): class ModuleInline(admin.StackedInline):
model = models.Module model = models.Module
extra = 0 extra = 0
class ProjectAdmin(admin.ModelAdmin):
list_display = ['name', 'ensemble', 'event_date', 'active'] class ProjectAdmin(admin.ModelAdmin):
list_filter = ['ensemble', 'active'] list_display = ["name", "ensemble", "event_date", "active"]
list_filter = ["ensemble", "active"]
inlines = [ModuleInline] inlines = [ModuleInline]
class ResourceAdmin(admin.ModelAdmin): class ResourceAdmin(admin.ModelAdmin):
list_display = ['name', 'media_type', 'project'] list_display = ["name", "media_type", "project"]
list_filter = ['project'] list_filter = ["project"]
class WikiPageAdmin(admin.ModelAdmin): class WikiPageAdmin(admin.ModelAdmin):
list_display = ['title', 'project'] list_display = ["title", "project"]
list_filter = ['project'] list_filter = ["project"]
admin.site.register(models.Ensemble, EnsembleAdmin) admin.site.register(models.Ensemble, EnsembleAdmin)
admin.site.register(models.Project, ProjectAdmin) admin.site.register(models.Project, ProjectAdmin)

View File

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

View File

@ -1,4 +1,5 @@
from crispy_forms.layout import Field from crispy_forms.layout import Field
class BulmaFileUpload(Field): class BulmaFileUpload(Field):
template = 'bulma/file_upload.html' template = "bulma/file_upload.html"

View File

@ -5,58 +5,72 @@ from crispy_bulma.layout import FormGroup
from . import models, fields from . import models, fields
class BaseForm(forms.Form):
class BaseForm(forms.Form):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.helper = self.get_form_helper() self.helper = self.get_form_helper()
def get_form_helper(self): def get_form_helper(self):
helper = FormHelper(self) helper = FormHelper(self)
#helper.add_input(Submit('submit', 'Submit', css_class='button is-link')) # helper.add_input(Submit('submit', 'Submit', css_class='button is-link'))
#helper.layout.subm append(HTML('<a class="button is-light">Cancel</a>')) # helper.layout.subm append(HTML('<a class="button is-light">Cancel</a>'))
#print(helper.layout) # print(helper.layout)
helper.layout.append(FormGroup( helper.layout.append(
Submit('submit', 'Save', css_class="button is-primary"), FormGroup(
HTML('{% if view.cancel_url %}<div class="control"><a href="{{ view.cancel_url }}" class="button is-light">Cancel</a></div>{% endif %}') Submit("submit", "Save", css_class="button is-primary"),
)) HTML(
'{% if view.cancel_url %}<div class="control"><a href="{{ view.cancel_url }}" class="button is-light">Cancel</a></div>{% endif %}'
),
)
)
return helper return helper
class ProjectForm(forms.ModelForm, BaseForm): class ProjectForm(forms.ModelForm, BaseForm):
class Meta: class Meta:
model = models.Project model = models.Project
fields = ['name', 'description', 'modules', 'event_date'] fields = ["name", "description", "modules", "event_date"]
#widgets = { # widgets = {
# 'event_date': forms.DateTimeInput(attrs={'type': 'date'}) # 'event_date': forms.DateTimeInput(attrs={'type': 'date'})
#} # }
modules = forms.MultipleChoiceField(
choices=[(x, x.title()) for x in models.settings.POLYPHONIC_MODULES],
widget=forms.CheckboxSelectMultiple,
required=False,
)
modules = forms.MultipleChoiceField(choices=[ (x, x.title()) for x in models.settings.POLYPHONIC_MODULES ],
widget=forms.CheckboxSelectMultiple, required=False)
class ResourceForm(forms.ModelForm, BaseForm): class ResourceForm(forms.ModelForm, BaseForm):
class Meta: class Meta:
model = models.Resource model = models.Resource
fields = ['name', 'media_type', 'description', 'file'] fields = ["name", "media_type", "description", "file"]
def get_form_helper(self): def get_form_helper(self):
helper = super().get_form_helper() helper = super().get_form_helper()
helper[3].wrap(fields.BulmaFileUpload) helper[3].wrap(fields.BulmaFileUpload)
return helper return helper
class WikiForm(forms.ModelForm, BaseForm):
class WikiForm(forms.ModelForm, BaseForm):
class Meta: class Meta:
model = models.WikiPage model = models.WikiPage
fields = ['title', 'markdown'] fields = ["title", "markdown"]
class CodeForm(BaseForm): class CodeForm(BaseForm):
code = forms.CharField(max_length=14, code = forms.CharField(
widget=forms.TextInput(attrs={'placeholder': 'xxx-xxx-xxx', 'inputmode': 'numeric'})) max_length=14,
widget=forms.TextInput(
attrs={"placeholder": "xxx-xxx-xxx", "inputmode": "numeric"}
),
)
passphrase = forms.CharField(max_length=32) passphrase = forms.CharField(max_length=32)
class ResourceUploadForm(forms.Form): class ResourceUploadForm(forms.Form):
pass pass
# file = S3UploadField() # file = S3UploadField()

View File

@ -14,11 +14,12 @@ import os.path
from .utils import sign_data from .utils import sign_data
MEDIA_TYPES = [ MEDIA_TYPES = [
('audio', "Audio"), ("audio", "Audio"),
('video', "Video"), ("video", "Video"),
('general', "General"), ("general", "General"),
] ]
def rough_date(d): def rough_date(d):
if not d: if not d:
return False, "sometime..." return False, "sometime..."
@ -27,22 +28,22 @@ def rough_date(d):
if in_past: if in_past:
days = abs(days) days = abs(days)
if days == 0: if days == 0:
m = int((d-timezone.now()).seconds/60) m = int((d - timezone.now()).seconds / 60)
if m > 60: if m > 60:
return in_past, "{0:d} hours".format(int(m / 60)) return in_past, "{0:d} hours".format(int(m / 60))
return in_past, "{0:d} minutes!".format(int(m % 60)) return in_past, "{0:d} minutes!".format(int(m % 60))
if days >= 14: if days >= 14:
return in_past, "{0:d} weeks".format(int(days/7)) return in_past, "{0:d} weeks".format(int(days / 7))
if days >= 7: if days >= 7:
return in_past, "{0:d} weeks, {1:d} days".format(int(days / 7), int(days % 7)) return in_past, "{0:d} weeks, {1:d} days".format(int(days / 7), int(days % 7))
return in_past, f"{days} days" return in_past, f"{days} days"
def generate_code(length=9): def generate_code(length=9):
return "".join([ random.choice('0123456789') for _ in range(length) ]) return "".join([random.choice("0123456789") for _ in range(length)])
class EnsembleQuerySet(models.QuerySet): class EnsembleQuerySet(models.QuerySet):
def for_user(self, user, ensemble_keys=[], project_keys=[]): def for_user(self, user, ensemble_keys=[], project_keys=[]):
if user.is_superuser: if user.is_superuser:
return self return self
@ -54,26 +55,35 @@ class EnsembleQuerySet(models.QuerySet):
return self.filter(f).distinct() return self.filter(f).distinct()
class Ensemble(models.Model):
''' A group that plays together
''' class Ensemble(models.Model):
name = models.CharField(max_length=100, """A group that plays together"""
help_text="Display name")
slug = models.SlugField(max_length=100, editable=False, unique=True, name = models.CharField(max_length=100, help_text="Display name")
help_text="Short name for the ensemble - used for folders") slug = models.SlugField(
admins = models.ManyToManyField('auth.User', related_name='ensembles') max_length=100,
details = models.TextField(blank=True, editable=False,
help_text="Description of the ensemble (markdown)") unique=True,
storage = models.ForeignKey('byostorage.UserStorage', null=True, on_delete=models.SET_NULL, help_text="Short name for the ensemble - used for folders",
help_text="Default storage for this ensemble") )
nonce = models.SmallIntegerField(default=1, admins = models.ManyToManyField("auth.User", related_name="ensembles")
help_text="Increment this to reset the authentication links") details = models.TextField(
blank=True, help_text="Description of the ensemble (markdown)"
)
storage = models.ForeignKey(
"byostorage.UserStorage",
null=True,
on_delete=models.SET_NULL,
help_text="Default storage for this ensemble",
)
nonce = models.SmallIntegerField(
default=1, help_text="Increment this to reset the authentication links"
)
objects = EnsembleQuerySet.as_manager() objects = EnsembleQuerySet.as_manager()
class Meta: class Meta:
ordering = ('slug', ) ordering = ("slug",)
def active_projects(self): def active_projects(self):
return self.projects.active().current() return self.projects.active().current()
@ -83,7 +93,7 @@ class Ensemble(models.Model):
return False return False
if user.is_superuser: if user.is_superuser:
return True return True
return user.pk in self.admins.values_list('pk', flat=True) return user.pk in self.admins.values_list("pk", flat=True)
def save(self, **kwargs): def save(self, **kwargs):
if not self.slug: if not self.slug:
@ -91,17 +101,21 @@ class Ensemble(models.Model):
super(Ensemble, self).save(**kwargs) super(Ensemble, self).save(**kwargs)
def get_absolute_url(self): def get_absolute_url(self):
return resolve_url('ensemble_detail', ensemble=self.slug) return resolve_url("ensemble_detail", ensemble=self.slug)
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 ProjectQuerySet(models.QuerySet): class ProjectQuerySet(models.QuerySet):
def current(self): def current(self):
return self.filter(models.Q(event_date__gte=(timezone.now()-timezone.timedelta(7))) | models.Q(event_date=None)) return self.filter(
models.Q(event_date__gte=(timezone.now() - timezone.timedelta(7)))
| models.Q(event_date=None)
)
def active(self): def active(self):
return self.filter(active=True) return self.filter(active=True)
@ -117,23 +131,26 @@ class ProjectQuerySet(models.QuerySet):
return self.filter(f) return self.filter(f)
class Project(models.Model): class Project(models.Model):
''' A Project linked to an ensemble """A Project linked to an ensemble"""
'''
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
ensemble = models.ForeignKey(Ensemble, related_name='projects', on_delete=models.CASCADE, null=True) ensemble = models.ForeignKey(
description = models.TextField(blank=True, Ensemble, related_name="projects", on_delete=models.CASCADE, null=True
help_text="Markdown format") )
description = models.TextField(blank=True, help_text="Markdown format")
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
event_date =models.DateTimeField(null=True, blank=True) event_date = models.DateTimeField(null=True, blank=True)
owner = models.CharField(max_length=255, blank=True) owner = models.CharField(max_length=255, blank=True)
nonce = models.SmallIntegerField(default=1, nonce = models.SmallIntegerField(
help_text="Increment this to reset the authentication links") default=1, help_text="Increment this to reset the authentication links"
)
objects = ProjectQuerySet.as_manager() objects = ProjectQuerySet.as_manager()
class Meta: class Meta:
ordering = ['active', 'event_date'] ordering = ["active", "event_date"]
@property @property
def days(self): def days(self):
@ -162,62 +179,74 @@ class Project(models.Model):
@property @property
def active_modules(self): def active_modules(self):
return self.modules.values_list('name', flat=True) return self.modules.values_list("name", flat=True)
def get_absolute_url(self): def get_absolute_url(self):
return resolve_url('project_detail', project=self.pk) return resolve_url("project_detail", project=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 Module(models.Model): class Module(models.Model):
''' Enable modules on a oriject """Enable modules on a oriject"""
'''
name = models.SlugField(max_length=20, choices=[ (x, x.title()) for x in settings.POLYPHONIC_MODULES ]) name = models.SlugField(
project = models.ForeignKey(Project, related_name="modules", on_delete=models.CASCADE) max_length=20, choices=[(x, x.title()) for x in settings.POLYPHONIC_MODULES]
)
project = models.ForeignKey(
Project, related_name="modules", on_delete=models.CASCADE
)
def __str__(self): def __str__(self):
return self.name return self.name
def resource_key(resource, filename): def resource_key(resource, filename):
return f'{resource.project.folder}/resources/{filename}' return f"{resource.project.folder}/resources/{filename}"
class Resource(models.Model): class Resource(models.Model):
''' A viewable file resource attached to a project """A viewable file resource attached to a project
e.g PDF instructions, MP3 backing track e.g PDF instructions, MP3 backing track
''' """
project = models.ForeignKey(Project, related_name='resources', on_delete=models.CASCADE)
project = models.ForeignKey(
Project, related_name="resources", on_delete=models.CASCADE
)
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
description = models.TextField(blank=True) description = models.TextField(blank=True)
file = models.FileField(storage=BYOStorage(), upload_to=resource_key) file = models.FileField(storage=BYOStorage(), upload_to=resource_key)
media_type = models.CharField(max_length=10, choices=MEDIA_TYPES, default='*') media_type = models.CharField(max_length=10, choices=MEDIA_TYPES, default="*")
visible = models.BooleanField(default=True) visible = models.BooleanField(default=True)
class Meta: class Meta:
ordering = ['-visible', '-pk'] ordering = ["-visible", "-pk"]
def accept(self): def accept(self):
if self.media_type == 'general': if self.media_type == "general":
return ".*" return ".*"
return f"{self.media_type}/*" return f"{self.media_type}/*"
def __str__(self): def __str__(self):
return self.name return self.name
class WikiPage(models.Model):
''' An editable wiki page for the project in markdown format
''' class WikiPage(models.Model):
project = models.ForeignKey(Project, related_name='wiki_pages', on_delete=models.CASCADE) """An editable wiki page for the project in markdown format"""
project = models.ForeignKey(
Project, related_name="wiki_pages", on_delete=models.CASCADE
)
title = models.CharField(max_length=255) title = models.CharField(max_length=255)
markdown = models.TextField() markdown = models.TextField()
def get_absolute_url(self): def get_absolute_url(self):
return resolve_url('wiki', project=self.project_id, pk=self.pk) return resolve_url("wiki", project=self.project_id, pk=self.pk)
def __str__(self): def __str__(self):
return self.title return self.title

View File

@ -3,7 +3,9 @@ import os.path
register = template.Library() register = template.Library()
def basename(value): def basename(value):
return os.path.basename(value) return os.path.basename(value)
register.filter('basename', basename)
register.filter("basename", basename)

View File

@ -3,12 +3,16 @@ from django.utils import timesince
register = template.Library() register = template.Library()
def roughtimesince(value): def roughtimesince(value):
return timesince.timesince(value, depth=1) return timesince.timesince(value, depth=1)
register.filter('roughtimesince', roughtimesince)
register.filter("roughtimesince", roughtimesince)
def roughtimeuntil(value): def roughtimeuntil(value):
return timesince.timeuntil(value, depth=1) return timesince.timeuntil(value, depth=1)
register.filter('roughtimeuntil', roughtimeuntil)
register.filter("roughtimeuntil", roughtimeuntil)

View File

@ -2,6 +2,7 @@ from django import template
register = template.Library() register = template.Library()
@register.simple_tag(takes_context=True) @register.simple_tag(takes_context=True)
def url_update(context, **kwargs): def url_update(context, **kwargs):
params = context.request.GET.copy() params = context.request.GET.copy()

View File

@ -4,8 +4,8 @@ from django.contrib.auth.models import User
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from datetime import timedelta
class AccessTestCase(TestCase):
class AccessTestCase(TestCase):
USERS = () USERS = ()
ENSEMBLES = () ENSEMBLES = ()
@ -19,13 +19,13 @@ class AccessTestCase(TestCase):
cls.users = {} cls.users = {}
for details in cls.USERS: for details in cls.USERS:
cls.users[details['username']] = User.objects.create_user(**details) cls.users[details["username"]] = User.objects.create_user(**details)
now = timezone.now() now = timezone.now()
cls.ensembles = {} cls.ensembles = {}
for details in cls.ENSEMBLES: for details in cls.ENSEMBLES:
admins = details.pop('admins', []) admins = details.pop("admins", [])
obj = models.Ensemble.objects.create(**details) obj = models.Ensemble.objects.create(**details)
for admin in admins: for admin in admins:
obj.admins.add(cls.users[admin]) obj.admins.add(cls.users[admin])
@ -33,37 +33,41 @@ class AccessTestCase(TestCase):
cls.projects = {} cls.projects = {}
for details in cls.PROJECTS: for details in cls.PROJECTS:
when = details.pop('when', 0) when = details.pop("when", 0)
ensemble = details.pop('ensemble') ensemble = details.pop("ensemble")
details['event_date'] = now + timedelta(days=when) if when else None details["event_date"] = now + timedelta(days=when) if when else None
obj = cls.ensembles[ensemble].projects.create(**details) obj = cls.ensembles[ensemble].projects.create(**details)
cls.projects[details['name']] = obj cls.projects[details["name"]] = obj
return return
def test_protected_views(self): def test_protected_views(self):
self.assertAccess({ x: False for x in self.PROTECTED_URLS }) self.assertAccess({x: False for x in self.PROTECTED_URLS})
if 'admin' in self.users: if "admin" in self.users:
self.client.force_login(self.users['admin']) self.client.force_login(self.users["admin"])
self.assertAccess({ x: True for x in self.PROTECTED_URLS }) self.assertAccess({x: True for x in self.PROTECTED_URLS})
def login(self, user, passwd): def login(self, user, passwd):
response = self.client.post('/login', {'username': user, 'password': passwd}) response = self.client.post("/login", {"username": user, "password": passwd})
self.assertEqual(response.status_code, 302, f"Failed to login as {user}") self.assertEqual(response.status_code, 302, f"Failed to login as {user}")
def authorize(self, model, **kwargs): def authorize(self, model, **kwargs):
object = model.objects.get(**kwargs) object = model.objects.get(**kwargs)
response = self.client.get(f'{object.get_absolute_url()}?auth={object.auth()}') response = self.client.get(f"{object.get_absolute_url()}?auth={object.auth()}")
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
def assertAccess(self, urls): def assertAccess(self, urls):
for url, expected in urls.items(): for url, expected in urls.items():
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code == 200, expected, f"Expected {expected} for {url} (status: {response.status_code})") self.assertEqual(
response.status_code == 200,
expected,
f"Expected {expected} for {url} (status: {response.status_code})",
)
def assertObjectList(self, response, expected, element='name'): def assertObjectList(self, response, expected, element="name"):
self.assertEqual(response.status_code, 200, "No result returned") self.assertEqual(response.status_code, 200, "No result returned")
objects = response.context['object_list'].values_list(element, flat=True) objects = response.context["object_list"].values_list(element, flat=True)
self.assertEqual(list(objects), expected) self.assertEqual(list(objects), expected)

View File

@ -5,179 +5,213 @@ from django.contrib.auth.models import User
from . import AccessTestCase from . import AccessTestCase
class InterfaceAccessTestCase(AccessTestCase):
class InterfaceAccessTestCase(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},
{'name': 'Current Repertoire', 'ensemble': 'be-sharps'}, {"name": "Current Repertoire", "ensemble": "be-sharps"},
) )
PROTECTED_URLS = ( PROTECTED_URLS = (
'/ensembles/be-sharps', "/ensembles/be-sharps",
'/ensembles/be-sharps/new-project', "/ensembles/be-sharps/new-project",
"/projects/3",
'/projects/3', "/projects/3/resources",
'/projects/3/resources', "/projects/3/resources/add",
'/projects/3/resources/add', "/admin/interface/ensemble/",
"/admin/interface/project/",
'/admin/interface/ensemble/', "/admin/interface/resource/",
'/admin/interface/project/', "/admin/interface/wikipage/",
'/admin/interface/resource/',
'/admin/interface/wikipage/',
) )
def test_bad_login(self): def test_bad_login(self):
with self.assertRaisesMessage(self.failureException, 'Failed to login as admin'): with self.assertRaisesMessage(
self.login('admin', 'admin') self.failureException, "Failed to login as admin"
):
self.login("admin", "admin")
def test_superuser_ensembles(self): def test_superuser_ensembles(self):
self.login('admin', 'secret') self.login("admin", "secret")
response = self.client.get('/ensembles') response = self.client.get("/ensembles")
self.assertObjectList(response, ['The Be Sharps', 'Lisa & the Bleeding Gums', 'Party Posse']) self.assertObjectList(
self.assertContains(response, 'Django Admin') response, ["The Be Sharps", "Lisa & the Bleeding Gums", "Party Posse"]
)
self.assertContains(response, "Django Admin")
def test_superuser_ensemble_permissions(self): def test_superuser_ensemble_permissions(self):
self.login('admin', 'secret') self.login("admin", "secret")
response = self.client.get('/ensembles/party-posse') response = self.client.get("/ensembles/party-posse")
self.assertTrue(response.context['request'].is_admin) self.assertTrue(response.context["request"].is_admin)
self.assertContains(response, "Add project") self.assertContains(response, "Add project")
self.assertAccess({ self.assertAccess(
'/ensembles/be-sharps': True, {
'/ensembles/bleeding-gums': True, "/ensembles/be-sharps": True,
'/ensembles/party-posse': True, "/ensembles/bleeding-gums": True,
'/ensembles/unknown': False, "/ensembles/party-posse": True,
'/ensembles/be-sharps/new-project': True, "/ensembles/unknown": False,
}) "/ensembles/be-sharps/new-project": True,
}
)
def test_superuser_projects(self): def test_superuser_projects(self):
self.login('admin', 'secret') self.login("admin", "secret")
response = self.client.get('/projects') response = self.client.get("/projects")
self.assertObjectList(response, ['Current Repertoire', 'Open Mic Night', 'Navy Recruitment Day', 'Barbershop Contest']) self.assertObjectList(
response,
self.assertObjectList(self.client.get('/ensembles/bleeding-gums'), ['Open Mic Night']) [
self.assertObjectList(self.client.get('/ensembles/bleeding-gums?inactive'), ['Open Mic Night', 'Baker St']) "Current Repertoire",
"Open Mic Night",
"Navy Recruitment Day",
"Barbershop Contest",
],
)
self.assertObjectList(
self.client.get("/ensembles/bleeding-gums"), ["Open Mic Night"]
)
self.assertObjectList(
self.client.get("/ensembles/bleeding-gums?inactive"),
["Open Mic Night", "Baker St"],
)
def test_user_ensembles(self): def test_user_ensembles(self):
self.login('homer', 'maggie') self.login("homer", "maggie")
response = self.client.get('/ensembles') response = self.client.get("/ensembles")
self.assertObjectList(response, ['The Be Sharps']) self.assertObjectList(response, ["The Be Sharps"])
self.assertNotContains(response, 'Django Admin') self.assertNotContains(response, "Django Admin")
def test_user_ensemble_permissions(self): def test_user_ensemble_permissions(self):
self.login('homer', 'maggie') self.login("homer", "maggie")
response = self.client.get('/ensembles/be-sharps') response = self.client.get("/ensembles/be-sharps")
self.assertTrue(response.context['request'].is_admin) self.assertTrue(response.context["request"].is_admin)
self.assertContains(response, "Add project") self.assertContains(response, "Add project")
self.assertContains(response, 'Show all') self.assertContains(response, "Show all")
self.assertAccess({ self.assertAccess(
'/ensembles/be-sharps': True, {
'/ensembles/bleeding-gums': False, "/ensembles/be-sharps": True,
'/ensembles/party-posse': False, "/ensembles/bleeding-gums": False,
'/ensembles/be-sharps/new-project': True, "/ensembles/party-posse": False,
'/ensembles/party-posse/new-project': False, "/ensembles/be-sharps/new-project": True,
}) "/ensembles/party-posse/new-project": False,
}
)
self.authorize(models.Ensemble, slug='bleeding-gums') self.authorize(models.Ensemble, slug="bleeding-gums")
self.assertAccess({ self.assertAccess(
'/ensembles/be-sharps': True, {
'/ensembles/bleeding-gums': True, "/ensembles/be-sharps": True,
'/ensembles/party-posse': False, "/ensembles/bleeding-gums": True,
'/ensembles/be-sharps/new-project': True, "/ensembles/party-posse": False,
'/ensembles/party-posse/new-project': False, "/ensembles/be-sharps/new-project": True,
}) "/ensembles/party-posse/new-project": False,
response = self.client.get('/ensembles/bleeding-gums') }
self.assertFalse(response.context['request'].is_admin) )
self.assertNotContains(response, 'Add project') response = self.client.get("/ensembles/bleeding-gums")
self.assertNotContains(response, 'Show all') self.assertFalse(response.context["request"].is_admin)
self.assertNotContains(response, "Add project")
self.assertNotContains(response, "Show all")
def test_user_projects(self): def test_user_projects(self):
self.login('homer', 'maggie') self.login("homer", "maggie")
response = self.client.get('/projects') response = self.client.get("/projects")
self.assertObjectList(response, ['Current Repertoire', 'Barbershop Contest']) self.assertObjectList(response, ["Current Repertoire", "Barbershop Contest"])
response = self.client.get('/projects/3') response = self.client.get("/projects/3")
self.assertTrue(response.context['request'].is_admin) self.assertTrue(response.context["request"].is_admin)
self.assertAccess(
self.assertAccess({ {
'/projects/3': True, "/projects/3": True,
'/projects/3/resources': True, "/projects/3/resources": True,
'/projects/3/resources/add': True, "/projects/3/resources/add": True,
'/projects/4': False, "/projects/4": False,
'/projects/4/resources': False, "/projects/4/resources": False,
'/projects/4/resources/add': False, "/projects/4/resources/add": False,
}) }
)
self.authorize(models.Project, pk=4) self.authorize(models.Project, pk=4)
response = self.client.get('/projects') response = self.client.get("/projects")
self.assertObjectList(response, ['Current Repertoire', 'Open Mic Night', 'Barbershop Contest']) self.assertObjectList(
response = self.client.get('/projects/4') response, ["Current Repertoire", "Open Mic Night", "Barbershop Contest"]
self.assertFalse(response.context['request'].is_admin) )
response = self.client.get("/projects/4")
self.assertFalse(response.context["request"].is_admin)
def test_anon_ensembles(self): def test_anon_ensembles(self):
response = self.client.get('/ensembles') response = self.client.get("/ensembles")
self.assertObjectList(response, []) self.assertObjectList(response, [])
self.assertContains(response, 'You don\'t currently have access to any ensembles') self.assertContains(
response, "You don't currently have access to any ensembles"
)
def test_anon_authorized_ensemble(self): def test_anon_authorized_ensemble(self):
self.authorize(models.Ensemble, slug='party-posse') self.authorize(models.Ensemble, slug="party-posse")
response = self.client.get('/ensembles/party-posse') response = self.client.get("/ensembles/party-posse")
self.assertContains(response, 'Party Posse') self.assertContains(response, "Party Posse")
response = self.client.get('/ensembles') response = self.client.get("/ensembles")
self.assertObjectList(response, ['Party Posse']) self.assertObjectList(response, ["Party Posse"])
self.assertAccess({ self.assertAccess(
'/ensembles/be-sharps': False, {
'/ensembles/party-posse': True, "/ensembles/be-sharps": False,
'/ensembles/bleeding-gums': False, "/ensembles/party-posse": True,
'/ensembles/unknown': False, "/ensembles/bleeding-gums": False,
}) "/ensembles/unknown": False,
response = self.client.get('/projects') }
self.assertObjectList(response, ['Navy Recruitment Day']) )
response = self.client.get("/projects")
self.assertObjectList(response, ["Navy Recruitment Day"])
def test_anon_authorized_project(self): def test_anon_authorized_project(self):
self.authorize(models.Project, pk=4) self.authorize(models.Project, pk=4)
self.assertObjectList(self.client.get('/projects'), ['Open Mic Night']) self.assertObjectList(self.client.get("/projects"), ["Open Mic Night"])
self.assertObjectList(self.client.get('/ensembles'), ['Lisa & the Bleeding Gums']) self.assertObjectList(
self.client.get("/ensembles"), ["Lisa & the Bleeding Gums"]
)
self.assertAccess({ self.assertAccess(
'/projects/4': True, {
'/projects/4/resources': True, "/projects/4": True,
'/projects/1': False, "/projects/4/resources": True,
'/projects/1/resources': False, "/projects/1": False,
}) "/projects/1/resources": False,
}
)
def test_anon_permission_denied(self): def test_anon_permission_denied(self):
self.assertAccess({ self.assertAccess(
'/ensembles': True, {
'/ensembles/be-sharps': False, "/ensembles": True,
'/ensembles/party-posse': False, "/ensembles/be-sharps": False,
'/ensembles/bleeding-gums': False, "/ensembles/party-posse": False,
'/ensembles/unknown': False, "/ensembles/bleeding-gums": False,
}) "/ensembles/unknown": False,
}
)
def test_anon_deauthorize_project(self): def test_anon_deauthorize_project(self):
self.authorize(models.Project, pk=4) self.authorize(models.Project, pk=4)
self.assertAccess({ self.assertAccess({"/projects/4": True})
'/projects/4': True
})
models.Project.objects.filter(pk=4).update(nonce=2) models.Project.objects.filter(pk=4).update(nonce=2)
self.assertAccess({ self.assertAccess({"/projects/4": False})
'/projects/4': False
})

View File

@ -1,6 +1,6 @@
from django.test import TestCase from django.test import TestCase
class IntegrationTestCase(TestCase):
class IntegrationTestCase(TestCase):
def test_runs(self): def test_runs(self):
self.assertTrue(True) self.assertTrue(True)

View File

@ -77,4 +77,3 @@ if settings.DEBUG:
urlpatterns.append( urlpatterns.append(
path("local_storage/<path:path>", serve, {"document_root": "local_storage"}) path("local_storage/<path:path>", serve, {"document_root": "local_storage"})
) )

View File

@ -5,8 +5,10 @@ from django.core.exceptions import SuspiciousOperation
signer = Signer() signer = Signer()
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def sign_data(data, l=None): def sign_data(data, l=None):
sig = signer.sign(data) sig = signer.sign(data)
p = len(data) + 1 p = len(data) + 1
@ -14,6 +16,7 @@ def sign_data(data, l=None):
l += p l += p
return sig[p:l] return sig[p:l]
def signed_url(name, **kwargs): def signed_url(name, **kwargs):
""" """
>>> signed_url('foo/bar') >>> signed_url('foo/bar')
@ -23,9 +26,10 @@ def signed_url(name, **kwargs):
sep = "&" if "?" in url else "?" sep = "&" if "?" in url else "?"
return sig.replace(":", f"{sep}auth=") return sig.replace(":", f"{sep}auth=")
def check_signed_url(full_path): def check_signed_url(full_path):
p = full_path.rfind('auth') p = full_path.rfind("auth")
url = full_path[:p-1] url = full_path[: p - 1]
logger.debug("check_signed_url: %s", url) logger.debug("check_signed_url: %s", url)
signed = signed_url(url) signed = signed_url(url)
if signed != full_path: if signed != full_path:
@ -33,6 +37,8 @@ def check_signed_url(full_path):
signed = "_HIDDEN_" signed = "_HIDDEN_"
raise SuspiciousOperation("Bad auth code") raise SuspiciousOperation("Bad auth code")
if __name__ == '__main__':
if __name__ == "__main__":
import doctest import doctest
print(doctest.testmod()) print(doctest.testmod())

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): class OrchestrationAdmin(admin.ModelAdmin):
list_display = ['name', 'instruments'] list_display = ["name", "instruments"]
admin.site.register(models.Orchestration, OrchestrationAdmin) 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) qs = Work.objects.filter(ensemble_id=instance.ensemble_id).exclude(
self.fields['work'].queryset = qs id__in=existing
self.instance = instance )
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,7 +186,7 @@ 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:
@ -231,11 +231,14 @@ 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)
@ -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):
@ -259,7 +262,7 @@ def auto_tag(filename):
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

@ -7,56 +7,63 @@ 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
@ -64,30 +71,37 @@ class LibraryTestCase(AccessTestCase):
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
@ -96,81 +110,104 @@ class LibraryTestCase(AccessTestCase):
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"
).json()
response = self.client.post(
"/api/collections/2/import", data, "application/json"
)
self.assertEqual(response.status_code, 201) 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"