38 Commits
Author SHA1 Message Date
tris 4a22264d3f Rewrote tags code in annotate (with help from OpenCode) 2026-07-08 10:54:19 +10:00
tris fefa0a28b6 Switched to bulma 1.0 2026-07-08 10:52:45 +10:00
tris 0e69cdeca4 Tweeked deployment (again!) 2026-06-19 14:03:50 +10:00
tris e797876c16 Merge pull request 'Material Icons' (#17) from material into master
Reviewed-on: #17
2026-06-19 13:58:45 +10:00
tris 1301d19c08 Switched to material icons 2026-06-16 09:18:21 +10:00
tris ca3effcad1 Fixed my music page 2026-05-29 22:47:33 +10:00
tris 5468f6d3e7 Added handling of gdrive resource_keys - fixes #16 2026-05-27 22:46:08 +10:00
tris e46d8145a7 Install from git 2026-05-27 09:35:15 +10:00
tris 3444cdbc59 Merge pull request 'gdrive' (#15) from gdrive into master
Reviewed-on: #15
2026-05-24 16:45:13 +10:00
tris 6e77474d15 Couple of fixes 2026-05-24 16:42:51 +10:00
tris 72bad08a61 Merge branch 'master' into gdrive 2026-05-24 14:24:34 +10:00
tris b217fbea5e Sorted out linting 2026-05-24 14:23:59 +10:00
tris cec64ecd2f Working with manual sync 2026-05-24 13:51:23 +10:00
tris d30005d5b6 Indexing bug fix 2026-05-24 11:48:42 +10:00
tris ef981e06e8 Shared gdrive access 2026-05-24 11:35:18 +10:00
tris 4cef5800bc Merge pull request 'Repackaged as a single project' (#13) from repackage into master
Reviewed-on: #13
2026-05-24 11:11:27 +10:00
tris 78441dc142 Version bump 2026-05-24 11:07:49 +10:00
tris c96a2ad80b Refactored docker 2026-05-24 11:00:05 +10:00
tris 7e0c77f260 Refactored to polyphonic package 2026-05-24 10:51:43 +10:00
tris ff17114514 Minor test change 2026-05-24 10:07:05 +10:00
tris 533d6f09e8 Fixed compose launch 2026-05-23 22:10:37 +10:00
tris eeb35ce4f6 Modify PYTHONPATH 2026-05-23 21:46:51 +10:00
tris 947626c2af Added indexer tests 2026-05-23 11:38:40 +10:00
tris c1f0e48f80 Another go at sorting the settings mess 2026-05-23 10:00:09 +10:00
tris ab7d32d46e Renamed to poly-tool 2026-05-23 00:05:29 +10:00
tris 5116247ae8 Simplified settings 2026-05-22 23:51:25 +10:00
tris 93c4926dfd Updated docker build 2026-05-22 14:11:03 +10:00
tris 27d1b03c3c Added fuzzy matching 2026-05-14 11:02:55 +10:00
tris 4e102c07ac Skip migrations 2026-05-13 11:08:03 +10:00
tris b1ea75cec0 Merge pull request 'GdriveFolder code' (#12) from gdrive into master
Reviewed-on: #12
2026-05-13 09:35:48 +10:00
tris 7d041e1fd0 GdriveFolder code 2026-05-13 09:32:22 +10:00
tris 504c2ee56b Moving makefile to poetry 2026-05-12 23:24:46 +10:00
tris cfd6d45189 Added linting and got tests passing 2026-05-12 13:26:56 +10:00
tris ca62ed693a Code cleanup 2026-05-12 11:10:21 +10:00
tris 5e0e165037 Interface cleanup 2026-05-12 11:04:22 +10:00
tris 4164d56dea Library cleanup 2026-05-12 11:03:11 +10:00
tris 75dced77b8 Fixed logout + cleanup 2026-05-11 22:53:31 +10:00
tris b3675e28af Cleanup 2026-05-11 22:06:05 +10:00
127 changed files with 3205 additions and 1861 deletions
+4
View File
@@ -5,7 +5,10 @@ __pycache__
credentials.json
credentials
local_settings.py
local.mk
.coverage
.lint
.deploy
Session.vim
poetry.lock
/env
@@ -17,3 +20,4 @@ poetry.lock
/local_storage
/media
/index
/dist
+16 -12
View File
@@ -1,21 +1,25 @@
FROM alpine:3.14
FROM alpine:latest
RUN apk add --no-cache python3 git ghostscript sqlite
ENV TARGET=/opt/polyphonic
ENV RELEASE=polyphonic-0.8.4-py3-none-any.whl
#ENV RELEASE=git+https://gitea.tfconsulting.com.au/projects/polyphonic.git
RUN apk add --no-cache python3 py3-pip git ghostscript sqlite
WORKDIR /root
RUN python3 -m ensurepip
RUN pip3 install -U pip --no-cache-dir
COPY app/requirements.txt .
RUN pip3 install -r requirements.txt --no-cache-dir
RUN python3 -m venv ${TARGET}
ENV PATH="${TARGET}/bin:$PATH"
COPY app /opt/polyphonic
WORKDIR /opt/polyphonic
COPY dist/${RELEASE} .
RUN pip3 install ${RELEASE} --no-cache-dir
RUN pip3 install gunicorn whitenoise
COPY docker_settings.py polyphonic/local_settings.py
RUN SECRET_KEY=_ python3 manage.py collectstatic --noinput
WORKDIR ${TARGET}
RUN SECRET_KEY=_ poly-tool collectstatic --noinput
VOLUME ["/var/polyphonic"]
EXPOSE 8000/tcp
ENTRYPOINT ["python3", "manage.py"]
CMD ["runserver", "0.0.0.0:8000", "--insecure"]
CMD ["gunicorn", "-b", "0.0.0.0", "polyphonic.config.wsgi"]
+32 -10
View File
@@ -1,20 +1,42 @@
PYTHON=env/bin/python
DROPZONE=5.7.0
test:
coverage run --include "app/*" --omit "*/migrations/*" app/manage.py test app
coverage html
coverage report
VERSION=0.8.4
export DJANGO_SETTINGS_MODULE=polyphonic.config.settings.dev
-include local.mk
test: .coverage
check: .lint
pre-commit: check test
.coverage: polyphonic
poetry run coverage run --include "polyphonic/*" --omit "*/migrations/*" polyphonic/manage.py test polyphonic
poetry run coverage html
poetry run coverage report
.lint: polyphonic
poetry run ruff check polyphonic
poetry run ruff format --check polyphonic
touch $@
build: dist/polyphonic-${VERSION}-py3-none-any.whl
dist/polyphonic-${VERSION}-py3-none-any.whl: polyphonic
poetry build
dev-setup:
env/bin/pip install -r requirements.txt
env/bin/pip install -r dev-requirements.txt
${PYTHON} manage.py migrate
${PYTHON} manage.py createsuperuser --username admin --email admin@localhost
poetry install --with=dev
poetry run manage migrate
poetry run manage createsuperuser --username admin --email admin@localhost
upgrade:
${PYTHON} manage.py migrate
${PYTHON} manage.py collectstatic
poetry run manage migrate
poetry run manage collectstatic
${MAKE} libraries
libraries: static/dropzone static/fonts/Quicksand_Book.otf
-62
View File
@@ -1,62 +0,0 @@
from django import forms
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit, HTML, Div
from crispy_bulma.layout import FormGroup
from . import models, fields
class BaseForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = self.get_form_helper()
def get_form_helper(self):
helper = FormHelper(self)
#helper.add_input(Submit('submit', 'Submit', css_class='button is-link'))
#helper.layout.subm append(HTML('<a class="button is-light">Cancel</a>'))
#print(helper.layout)
helper.layout.append(FormGroup(
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
class ProjectForm(forms.ModelForm, BaseForm):
class Meta:
model = models.Project
fields = ['name', 'description', 'modules', 'event_date']
#widgets = {
# '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)
class ResourceForm(forms.ModelForm, BaseForm):
class Meta:
model = models.Resource
fields = ['name', 'media_type', 'description', 'file']
def get_form_helper(self):
helper = super().get_form_helper()
helper[3].wrap(fields.BulmaFileUpload)
return helper
class WikiForm(forms.ModelForm, BaseForm):
class Meta:
model = models.WikiPage
fields = ['title', 'markdown']
class CodeForm(BaseForm):
code = forms.CharField(max_length=14,
widget=forms.TextInput(attrs={'placeholder': 'xxx-xxx-xxx', 'inputmode': 'numeric'}))
passphrase = forms.CharField(max_length=32)
class ResourceUploadForm(forms.Form):
pass
# file = S3UploadField()
-14
View File
@@ -1,14 +0,0 @@
from django import template
from django.utils import timesince
register = template.Library()
def roughtimesince(value):
return timesince.timesince(value, depth=1)
register.filter('roughtimesince', roughtimesince)
def roughtimeuntil(value):
return timesince.timeuntil(value, depth=1)
register.filter('roughtimeuntil', roughtimeuntil)
-183
View File
@@ -1,183 +0,0 @@
from django.test import TestCase, Client
from interface import models, utils
from django.contrib.auth.models import User
from . import AccessTestCase
class InterfaceAccessTestCase(AccessTestCase):
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 = (
{'name': 'Baker St', 'ensemble': 'bleeding-gums', 'when': -12},
{'name': 'Navy Recruitment Day', 'ensemble': 'party-posse', 'when': 6},
{'name': 'Barbershop Contest', 'ensemble': 'be-sharps', 'when': 28},
{'name': 'Open Mic Night', 'ensemble': 'bleeding-gums', 'when': 1 },
{'name': 'Current Repertoire', 'ensemble': 'be-sharps'},
)
PROTECTED_URLS = (
'/ensembles/be-sharps',
'/ensembles/be-sharps/new-project',
'/projects/3',
'/projects/3/resources',
'/projects/3/resources/add',
'/admin/interface/ensemble/',
'/admin/interface/project/',
'/admin/interface/resource/',
'/admin/interface/wikipage/',
)
def test_bad_login(self):
with self.assertRaisesMessage(self.failureException, 'Failed to login as admin'):
self.login('admin', 'admin')
def test_superuser_ensembles(self):
self.login('admin', 'secret')
response = self.client.get('/ensembles')
self.assertObjectList(response, ['The Be Sharps', 'Lisa & the Bleeding Gums', 'Party Posse'])
self.assertContains(response, 'Django Admin')
def test_superuser_ensemble_permissions(self):
self.login('admin', 'secret')
response = self.client.get('/ensembles/party-posse')
self.assertTrue(response.context['request'].is_admin)
self.assertContains(response, "Add project")
self.assertAccess({
'/ensembles/be-sharps': True,
'/ensembles/bleeding-gums': True,
'/ensembles/party-posse': True,
'/ensembles/unknown': False,
'/ensembles/be-sharps/new-project': True,
})
def test_superuser_projects(self):
self.login('admin', 'secret')
response = self.client.get('/projects')
self.assertObjectList(response, ['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):
self.login('homer', 'maggie')
response = self.client.get('/ensembles')
self.assertObjectList(response, ['The Be Sharps'])
self.assertNotContains(response, 'Django Admin')
def test_user_ensemble_permissions(self):
self.login('homer', 'maggie')
response = self.client.get('/ensembles/be-sharps')
self.assertTrue(response.context['request'].is_admin)
self.assertContains(response, "Add project")
self.assertContains(response, 'Show all')
self.assertAccess({
'/ensembles/be-sharps': True,
'/ensembles/bleeding-gums': False,
'/ensembles/party-posse': False,
'/ensembles/be-sharps/new-project': True,
'/ensembles/party-posse/new-project': False,
})
self.authorize(models.Ensemble, slug='bleeding-gums')
self.assertAccess({
'/ensembles/be-sharps': True,
'/ensembles/bleeding-gums': True,
'/ensembles/party-posse': 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')
self.assertNotContains(response, 'Show all')
def test_user_projects(self):
self.login('homer', 'maggie')
response = self.client.get('/projects')
self.assertObjectList(response, ['Current Repertoire', 'Barbershop Contest'])
response = self.client.get('/projects/3')
self.assertTrue(response.context['request'].is_admin)
self.assertAccess({
'/projects/3': True,
'/projects/3/resources': True,
'/projects/3/resources/add': True,
'/projects/4': False,
'/projects/4/resources': False,
'/projects/4/resources/add': False,
})
self.authorize(models.Project, pk=4)
response = self.client.get('/projects')
self.assertObjectList(response, ['Current Repertoire', 'Open Mic Night', 'Barbershop Contest'])
response = self.client.get('/projects/4')
self.assertFalse(response.context['request'].is_admin)
def test_anon_ensembles(self):
response = self.client.get('/ensembles')
self.assertObjectList(response, [])
self.assertContains(response, 'You don\'t currently have access to any ensembles')
def test_anon_authorized_ensemble(self):
self.authorize(models.Ensemble, slug='party-posse')
response = self.client.get('/ensembles/party-posse')
self.assertContains(response, 'Party Posse')
response = self.client.get('/ensembles')
self.assertObjectList(response, ['Party Posse'])
self.assertAccess({
'/ensembles/be-sharps': False,
'/ensembles/party-posse': True,
'/ensembles/bleeding-gums': False,
'/ensembles/unknown': False,
})
response = self.client.get('/projects')
self.assertObjectList(response, ['Navy Recruitment Day'])
def test_anon_authorized_project(self):
self.authorize(models.Project, pk=4)
self.assertObjectList(self.client.get('/projects'), ['Open Mic Night'])
self.assertObjectList(self.client.get('/ensembles'), ['Lisa & the Bleeding Gums'])
self.assertAccess({
'/projects/4': True,
'/projects/4/resources': True,
'/projects/1': False,
'/projects/1/resources': False,
})
def test_anon_permission_denied(self):
self.assertAccess({
'/ensembles': True,
'/ensembles/be-sharps': False,
'/ensembles/party-posse': False,
'/ensembles/bleeding-gums': False,
'/ensembles/unknown': False,
})
def test_anon_deauthorize_project(self):
self.authorize(models.Project, pk=4)
self.assertAccess({
'/projects/4': True
})
models.Project.objects.filter(pk=4).update(nonce=2)
self.assertAccess({
'/projects/4': False
})
-37
View File
@@ -1,37 +0,0 @@
from django.urls import path
from django.contrib.auth import views as auth_views
from django.views.generic.base import RedirectView
from . import views
urlpatterns = [
path('', RedirectView.as_view(url='projects', permanent=False), name='home'),
path('login', auth_views.LoginView.as_view(), name='login'),
path('logout', auth_views.LogoutView.as_view(), name='logout'),
path('forget/<resource>/<key>', views.ForgetResourceView.as_view(), name="forget_resource"),
path('ensembles', views.EnsembleListView.as_view(), name="ensemble_list"),
path('ensembles/<slug:ensemble>', views.EnsembleDetailView.as_view(), name='ensemble_detail'),
path('ensembles/<slug:ensemble>/new-project', views.ProjectCreateView.as_view(), name="project_create"),
path('projects', views.ProjectListView.as_view(), name="project_list"),
path('projects/<int:project>', views.ProjectDetailView.as_view(), name="project_detail"),
path('projects/<int:project>/edit', views.ProjectUpdateView.as_view(), name="project_edit"),
#path('projects/<int:project>/submissions.mk', views.ProjectMakefileView.as_view(), name="project_makefile"),
path('projects/<int:project>/page/create', views.WikiCreateView.as_view(), name="wiki_create"),
path('projects/<int:project>/page/<int:pk>', views.WikiView.as_view(), name="wiki"),
path('projects/<int:project>/page/<int:pk>/edit', views.WikiEditView.as_view(), name="wiki_edit"),
path('projects/<int:project>/resources', views.ResourceListView.as_view(), name="resource_list"),
path('projects/<int:project>/resources/add', views.ResourceCreateView.as_view(), name="resource_create"),
path('projects/<int:project>/resources/<int:pk>/upload', views.ResourceUploadView.as_view(), name="resource_upload"),
path('projects/<int:project>/resources/<int:pk>/edit', views.ResourceEditView.as_view(), name="resource_edit"),
]
from django.conf import settings
if settings.DEBUG:
from django.views.static import serve
urlpatterns.append(path('local_storage/<path:path>', serve, {'document_root': 'local_storage'}))
-33
View File
@@ -1,33 +0,0 @@
from django import forms
from .models import Work
from interface.models import Project
from interface.forms import BaseForm
class WorkCreateForm(forms.ModelForm, BaseForm):
class Meta:
model = Work
fields = ['name', 'composer', 'edition', 'code', 'orchestration', 'licence', 'running_time', 'notes']
class PlaylistAddForm(forms.Form):
work = forms.ModelChoiceField(queryset=Work.objects.all())
def __init__(self, instance, *args, **kwargs):
super(PlaylistAddForm, self).__init__(*args, **kwargs)
existing = [ x[0] for x in instance.works.values_list('pk') ]
qs = Work.objects.filter(ensemble_id=instance.ensemble_id).exclude(id__in=existing)
self.fields['work'].queryset = qs
self.instance = instance
def save(self):
self.instance.works.add(self.cleaned_data['work'])
class ProjectEnsembleChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
return f"{obj.ensemble.name} - {obj.name}"
class ProjectSelectForm(BaseForm):
project = ProjectEnsembleChoiceField(queryset=Project.objects.all())
@@ -1,20 +0,0 @@
from django.core.management.base import BaseCommand, CommandError
import argparse
import csv
from library import models
class Command(BaseCommand):
help = 'Imports works from a csv file'
def add_arguments(self, parser):
parser.add_argument('collection', type=int, help="Collection ID")
parser.add_argument('source', type=argparse.FileType('r'), help="Source CSV")
def handle(self, *args, **options):
collection = models.Collection.objects.get(pk=options['collection'])
reader = csv.DictReader(options['source'])
for row in reader:
collection.works.create(name=row['Piece'], composer=row['Composer'], notes=row['Notes'])
-409
View File
@@ -1,409 +0,0 @@
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
from django.utils.functional import cached_property
from django.db.models import Q, Count, Min, Max
import re
import os.path
from byostorage.user import BYOStorage
from byostorage.cached import CachedStorage
from library.music_tags import MusicTag
from interface.utils import sign_data
import logging
#from polyphonic.settings import LIBRARY_STORAGE
logger = logging.getLogger(__name__)
#try:
# library_storage = get_storage_class(settings.LIBRARY_STORAGE)()
#except (ImportError, AttributeError):
# logger.exception("Failed to load library storage")
# library_storage = get_storage_class()()
#logger.info("Library storage: %s", library_storage.__class__.__name__)
# FIXME: move back to settings
library_storage = CachedStorage(BYOStorage())
class Orchestration(models.Model):
"""
Stores a list of instrument codes as a single entry (space delimited).
Can be global or ensemble specific
"""
collection = models.ForeignKey('Collection', on_delete=models.CASCADE, related_name="custom_orchestrations", null=True, blank=True)
name = models.CharField(max_length=100)
instruments = models.TextField()
def as_list(self):
tags = [ t.strip() for t in self.instruments.split(' ') ]
return [ (t, MusicTag.from_tag(t)) for t in tags if t ]
def tag_order(self):
tags = [ t.strip() for t in self.instruments.split(' ') if t ]
order = {'score': 0}
for i, t in enumerate(tags):
order.setdefault(t.strip('-0123456789'), i*2+1)
return order
def sorter(self):
tag_order = self.tag_order()
def f(x):
return (tag_order.get(x[0].strip('-0123456789'), 1000), x[0])
return f
def save(self):
self.as_list()
super(Orchestration, self).save()
def __str__(self):
return self.name
class ProjectItem(models.Model):
"""
ProjectItem represents a Work attached to a Project e.g. item in set list or programme
It also allows works to be shared from one ensemble to another on a per-project basis.
"""
project = models.ForeignKey('interface.Project', on_delete=models.CASCADE, related_name='items')
work = models.ForeignKey('Work', on_delete=models.CASCADE, related_name='project_items')
checkout = models.DateTimeField()
due = models.DateTimeField(null=True, blank=True)
returned = models.DateTimeField(null=True, blank=True)
approved_by = models.ForeignKey('auth.User', on_delete=models.CASCADE)
order = models.SmallIntegerField(default=0)
section = models.CharField(max_length=100, blank=True)
class Meta:
ordering = ['order', 'work']
def __str__(self):
return f"<{self.project_id}:{slugify(self.work.name)}>"
class Collection(models.Model):
"""
A logical collection of works, typically owned by an organisation or person (physical or virtual)
"""
name = models.CharField(max_length=255,
help_text="Name of the collection")
prefix = models.CharField(max_length=255, default="default",
help_text="Folder to store works in")
administrators = models.ManyToManyField('auth.User', related_name="collections",
help_text="Administrators for this collection")
location = models.CharField(max_length=100,
help_text="Physical location (institution, town...)", blank=True)
storage = models.ForeignKey('byostorage.UserStorage', on_delete=models.CASCADE, null=True, blank=True,
help_text="User storage for documents")
notes = models.TextField(blank=True,
help_text="Publicly visible notes about collection and loans policy (markdown format)")
settings = models.JSONField(default=dict, blank=True,
help_text="Storage specific settings")
nonce = models.SmallIntegerField(default=1,
help_text="Increment this to reset the authentication links")
def meta(self, name):
items = WorkMeta.objects.filter(work__collection=self.pk, name=name).values_list('value', flat=True).distinct()
return items
@property
def tags(self):
return self.meta('tag')
@property
def genres(self):
return self.meta('genre')
def has_administrator(self, user):
if not user.is_authenticated:
return False
if user.is_superuser:
return True
return user.pk in self.administrators.values_list('pk', flat=True)
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
class EnsembleAccess(models.Model):
"""
Can have different access levels to a collection
"""
ACCESS_UNLIMITED = 1
ACCESS_APPROVED = 2
ACCESS_TYPES = (
(ACCESS_UNLIMITED, 'Unlimited'),
(ACCESS_APPROVED, 'Approval required'),
)
ensemble = models.ForeignKey('interface.Ensemble', on_delete=models.CASCADE, related_name="allowed_collections")
collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name="allowed_ensembles")
access_type = models.PositiveSmallIntegerField(choices=ACCESS_TYPES, default=2)
class Meta:
verbose_name_plural = "Ensemble access"
class Work(models.Model):
"""
A musical work 'owned' by a collection from a licencing perspective.
"""
LICENCE_PUBLIC = 2
LICENCE_EXPIRED = 4
LICENCE_RECORDING = 5
LICENCE_PERFORMANCE = 6
LICENCE_PERUSAL = 8
LICENCE_NONE = 10
LICENCE_TYPES = (
(LICENCE_PUBLIC, 'Public Domain'),
(LICENCE_EXPIRED, 'Copyright Expired'),
(LICENCE_RECORDING, 'Recording Licence'),
(LICENCE_PERFORMANCE, 'Performance Licence'),
(LICENCE_PERUSAL, 'Perusal Licence'),
(LICENCE_NONE, 'Internal use only'),
)
name = models.CharField(max_length=255, help_text="Original name of the work")
edition = models.CharField(max_length=255, blank=True,
help_text="Edition details to distinguish multiple versions")
parent = models.ForeignKey('Work', null=True, blank=True, on_delete=models.SET_NULL, related_name="related_works",
help_text="Arrangement of another work or part of an anthology")
composer = models.CharField(max_length=255, default='Anon',
help_text="Composer or compilation editor. Use <b>Surname, Initial</b> 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. Will be auto-generated if not supplied")
licence = models.PositiveSmallIntegerField(choices=LICENCE_TYPES, default=6, help_text="Copyright status")
max_projects = models.IntegerField(default=1, help_text="How many active projects can this work be attached to")
# Extra info
running_time = models.DurationField(null=True, blank=True, help_text="Running time in mm:ss format")
notes = models.TextField(blank=True)
# Allocation to projects
projects = models.ManyToManyField('interface.Project', through='ProjectItem', related_name="works", help_text="Current usage")
@property
def folder(self):
return f"{slugify(self.composer)}/{slugify(self.name)}-{self.pk:04d}"
def tagged_sections(self, *tags):
qs = self.docs.filter(sections__tag__in=tags)
qs = qs.annotate(Count('sections'), end=Min('sections__end'), start=Max('sections__start')) \
.filter(sections__count=len(tags))
return qs
def list_sections(self, *tags):
return list(self.tagged_sections(*tags).values_list('upload', 'start', 'end'))
@property
def digital_parts(self):
sections = [ (s.tag, s.name) for s in Section.objects.filter(doc__work=self.pk) ]
sections.sort(key=self.orchestration.sorter())
#return [ s[1] for s in sections ]
sections = list(dict(sections).items()) # primitive unique()
return sections
def pdfs(self):
return self.docs.filter(doctype=Document.DOCTYPE_PDF)
@property
def physical_parts(self):
if not self.original_parts:
return []
parts = list(self.original_parts.items())
parts.sort(key=self.orchestration.sorter())
return [ (MusicTag.from_tag(x[0]), x[1]) for x in parts ]
@property
def tags(self):
return self.meta_info.filter(name='tag').values_list('value', flat=True)
@property
def meta(self):
return self.meta_info.exclude(name='tag')
@property
def current_loans(self):
return self.project_items.filter(checkout__lte=now(), returned=None).select_related('project')
@cached_property
def loans(self):
try:
return self.loan_count
except AttributeError:
return self.project_items.filter(checkout__lte=now(), returned=None).count()
@property
def is_available(self):
if self.max_projects < 0:
return True
return self.max_projects > self.loans
@property
def available(self):
if self.max_projects < 0:
return 'Unlimited'
a = self.max_projects - self.loans
return '{0} of {1}'.format(max(a, 0), self.max_projects)
@property
def identifier(self):
if self.code:
return self.code;
composer = self.composer or "Anon"
composer = re.sub('[^\w]', '', composer)
words = self.name.split()
work = words[0][:3]
return f"{composer[:4]}-{work}-{self.pk:05d}".upper()
def assigned_instruments(self):
return Section.objects.filter(doc__work_id=self.pk).values_list('tag', flat=True)
def unassigned_instruments(self):
assigned = set(self.assigned_instruments())
return [ x for x in self.orchestration.as_list() if not x[0] in assigned ]
def music_tags(self):
tags = dict(self.orchestration.as_list())
for section in Section.objects.filter(doc__work_id=self.pk):
tags.setdefault(section.tag, section.name)
return tags.items()
def __str__(self):
return f"{self.name} ({self.composer})"
class WorkMeta(models.Model):
META_CHOICES = (
('tag', 'Tag'),
('arr', 'Arranger'),
('lyrics', 'Lyracist'),
('genre', 'Genre'),
('style', 'Style'),
('orchestration', 'Orchestration'),
)
work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name='meta_info')
name = models.SlugField(max_length=20, choices=META_CHOICES)
value = models.CharField(max_length=255)
def doc_upload_filename(doc, filename):
collection = doc.work.collection
storage = collection.storage
if not storage:
raise RuntimeError("Collection has no storage attached")
return f'{storage}:library/{collection.prefix}/{doc.work.folder}/{filename}'
class Document(models.Model):
"""
Document represents a single file stored in the storage backend.
"""
DOCTYPE_PDF = 1
DOCTYPE_AUDIO = 2
DOCTYPE_VIDEO = 3
DOCTYPE_MISC= 4
DOCTYPES = (
(DOCTYPE_PDF, 'PDF'),
(DOCTYPE_AUDIO, 'Audio'),
(DOCTYPE_VIDEO, 'Video'),
(DOCTYPE_MISC, 'Misc'),
)
DOCTYPE_MAP = {
'.pdf': DOCTYPE_PDF,
'.mp3': DOCTYPE_AUDIO,
'.mp4': DOCTYPE_VIDEO,
}
work = models.ForeignKey('Work', on_delete=models.CASCADE, related_name="docs")
doctype = models.PositiveSmallIntegerField(choices=DOCTYPES, default=DOCTYPE_PDF)
upload = models.FileField(upload_to=doc_upload_filename, storage=library_storage)
created = models.DateTimeField(auto_now_add=True)
version = models.CharField(max_length=30, blank=True)
def delete(self, *args, **kwargs):
self.upload.delete(save=False)
return super().delete(*args, **kwargs)
def filename(self):
return os.path.basename(self.upload.name)
def __str__(self):
return self.upload.name
class Section(models.Model):
"""
Section is a tagged portion of a Document
"""
PAGE_AUTO = 0
PAGE_LEFT = 1
PAGE_RIGHT = 2
PAGE_PREFERENCE = (
(PAGE_AUTO, 'auto'),
(PAGE_LEFT, 'left'),
(PAGE_RIGHT, 'right'),
)
doc = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="sections")
tag = models.CharField(max_length=50, blank=True)
start = models.SmallIntegerField(null=True, blank=True)
end = models.SmallIntegerField(null=True, blank=True)
page = models.SmallIntegerField(default=PAGE_AUTO, choices=PAGE_PREFERENCE) # NOT CURRENTLY USED
class Meta:
ordering = ['doc', 'start', 'pk']
@property
def music_tag(self):
return MusicTag.from_tag(self.tag)
@property
def name(self):
return str(self.music_tag)
@property
def bulma_class(self):
return "success" if self.music_tag.is_general else 'info'
@property
def filename(self):
return slugify(f'{self.doc.work.name} - {self.name}').title() + '.pdf'
@property
def pagerange(self):
if self.start:
if self.end:
return f"{self.start}-{self.end}"
return str(self.start)
return "all"
def __str__(self):
return self.name
@@ -1,450 +0,0 @@
{% extends "interface/project_base.html" %}
{% block admin %}
<a href="#" onclick="saveTags()" class="button is-link">
<span class="icon"><i class="fas fa-save"></i></span>
<span>Save</span>
</a>
<a href="{% url 'work_detail' collection=collection.pk pk=object.work_id %}" class="button is-link is-light">
<span>Cancel</span>
</a>
{% endblock %}
{% block media %}
<style>
.tag-grid {
display: flex;
}
.grid-column {
margin-left: 1em;
margin-right: 1em;
}
.grid-page {
height: 25px;
margin-bottom: 5px;
border: 1px solid #999;
border-radius: 5px;
text-align: center;
width: 50px;
cursor: pointer;
}
.grid-page.is-active {
background-color: var(--primary);
color: white;
}
.grid-tag {
border: 2px solid var(--primary);
background-color: rgba(240, 240, 240, 0.5);
border-radius: 5px;
padding-left: 1em;
padding-right: 1em;
margin-bottom: 5px;
min-width: 200px;
position: absolute;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: start;
}
#tag-area {
min-width: 220px;
}
.instrument {
display: list-item !important;
}
.instrument:hover {
background-color: var(--primary);
color: white;
}
</style>
{% endblock %}
{% block page %}
<h3 class="subtitle"><a href="{% url 'work_detail' collection.pk document.work_id %}">{{ document.work.name }}</a></h3>
<div id="annotation-area" class="columns is-centered">
<div class="column is-narrow">
<div class="columns">
<div class="column is-narrow">
<button class="button is-small is-primary" id="prev">&lt;</button>
</div>
<div class="column">
<span id="page-num">-</span> / <span id="page-count">-</span>
</div>
<div class="column is-narrow">
<button class="button is-small is-primary" id="next">&gt;</button>
</div>
</div>
<ul id="unassigned-area">
{% for tag, inst in document.work.music_tags %}
<li class="is-clickable" onclick="assignInstrument('{{tag}}', this)")>{{ inst }}</li>
{% endfor %}
</ul>
<a onclick="document.getElementById('add-modal').classList.add('is-active')">Add Tag</a>
</div>
<div class="column is-narrow">
<div class="has-text-centered">
<div class="box" style="display: inline-block;">
<canvas id="inline-viewer" style="width: 500px;"></canvas>
</div>
</div>
</div>
<div class="column is-narrow">
<div class="tag-grid">
<div class="grid-column" id="page-list">
</div>
<div class="grid-column" id="tag-area">
</div>
</div>
<button class="button is-primary" style="width: 100%" onclick="expandEntries()">Expand</button>
</div>
</div>
<div class="modal" id="add-modal">
<div class="modal-background" onclick="closeAddModal()"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Add Instrument</p>
<button class="delete" aria-label="close" onclick="closeAddModal()"></button>
</header>
<section class="modal-card-body">
<div class="field has-addons">
<span class="control">
<input type="text" class="input" list="instrument-list" id="add-instrument-name"/>
<datalist id="instrument-list">
{% for inst in json_data.instruments.values %}
<option value="{{inst}}"/>
{% endfor %}
</datalist>
</span>
<span class="control">
<input type="number" class="input" max="4" min="1" size="3" id="add-instrument-variant"/>
</span>
<span class="control">
<button class="button is-primary" onclick="addInstrument();">Add</button>
</span>
</div>
</section>
</div>
</div>
<p>{{ document.upload.name }}</p>
{% endblock %}
{% block scripts %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.7.570/pdf.min.js"
integrity="sha512-g4FwCPWM/fZB1Eie86ZwKjOP+yBIxSBM/b2gQAiSVqCgkyvZ0XxYPDEcN2qqaKKEvK6a05+IPL1raO96RrhYDQ=="
crossorigin="anonymous"></script>
{{ json_data|json_script:"data" }}
<script type="text/javascript">
let url = "{% url 'document_download' collection.pk document.pk %}";
// Loaded via <script> tag, create shortcut to access PDF.js exports.
let pdfjsLib = window['pdfjs-dist/build/pdf'];
// The workerSrc property shall be specified.
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.7.570/pdf.worker.min.js';
// get current page tags
let data = JSON.parse(document.getElementById('data').textContent);
let tagArea = document.getElementById('tag-area');
var dirty = false;
//document.getElementById('tag-list').onclick = (e) => setTag(e.target.dataset.tag);
var pdfDoc = null,
pageNum = 1,
pageRendering = false,
pageNumPending = null,
scale = 1,
canvas = document.getElementById('inline-viewer'),
ctx = canvas.getContext('2d');
/**
* Get page info from document, resize canvas accordingly, and render page.
* @param num Page number.
*/
function renderPage(num) {
pageRendering = true;
// Using promise to fetch the page
pdfDoc.getPage(num).then(function (page) {
var viewport = page.getViewport({ scale: scale });
canvas.height = viewport.height;
canvas.width = viewport.width;
// Render PDF page into canvas context
var renderContext = {
canvasContext: ctx,
viewport: viewport
};
var renderTask = page.render(renderContext);
try {
document.getElementById('grid-page-' + pageNum).classList.remove('is-active');
} catch(e) {}
pageNum = num;
document.getElementById('grid-page-' + pageNum).classList.add('is-active');
// Wait for rendering to finish
renderTask.promise.then(function () {
pageRendering = false;
if (pageNumPending !== null) {
// New page rendering is pending
renderPage(pageNumPending);
pageNumPending = null;
}
});
});
// Update page counters
document.getElementById('page-num').textContent = num;
}
/**
* If another page rendering in progress, waits until the rendering is
* finised. Otherwise, executes rendering immediately.
*/
function queueRenderPage(num) {
if (pageRendering) {
pageNumPending = num;
} else {
renderPage(num);
}
}
/**
* Displays previous page.
*/
function onPrevPage() {
if (pageNum <= 1) {
return;
}
queueRenderPage(pageNum-1);
}
document.getElementById('prev').addEventListener('click', onPrevPage);
/**
* Displays next page.
*/
function onNextPage() {
if (pageNum >= pdfDoc.numPages) {
return;
}
queueRenderPage(pageNum+1);
}
document.getElementById('next').addEventListener('click', onNextPage);
/**
* Asynchronously downloads PDF.
*/
pdfjsLib.getDocument(url).promise.then(function (pdfDoc_) {
pdfDoc = pdfDoc_;
const pageList = document.getElementById('page-list');
pageList.innerHTML = '';
for (var i=0; i<pdfDoc.numPages; i++) {
let page = i+1;
let el = document.createElement('div');
el.className = 'grid-page';
el.id = 'grid-page-' + page;
el.innerHTML = page;
el.addEventListener('click', (evt) => {
queueRenderPage(page);
});
pageList.appendChild(el);
}
document.getElementById('page-count').textContent = pdfDoc.numPages;
// Initial/first page rendering
renderPage(pageNum);
tagArea.innerHTML = '';
for (let pageTag of data.pageTags) {
addTag(pageTag[0], pageTag[1], pageTag[2]);
}
dirty = false;
});
function closeAddModal() {
document.getElementById('add-modal').classList.remove('is-active');
document.getElementById('add-instrument-name').value = "";
document.getElementById('add-instrument-variant').value = "";
}
function addInstrument() {
let name = document.getElementById('add-instrument-name');
let variant = document.getElementById('add-instrument-variant');
let inst_name = name.value;
var tag = null;
for (let key in data.instruments) {
if (data.instruments[key] == inst_name) {
tag = key;
break;
}
}
if (!tag) {
alert("Unknown tag: " + name);
return;
}
if (variant.value) {
tag += "-" + variant.value;
}
variant.value = '';
name.value = '';
let li = document.createElement('li');
li.classList.add("is-clickable");
li.addEventListener('click', () => assignInstrument(tag, li));
li.innerHTML = get_instrument(tag);
document.getElementById('unassigned-area').appendChild(li);
addTag(tag, pageNum, pageNum);
closeAddModal();
}
function assignInstrument(tag, el) {
addTag(tag, pageNum, pageNum);
//el.remove();
}
function addTag(tag, start, end) {
console.log("addTag", tag, start, end);
const el = document.createElement('div');
el.className = 'grid-tag';
el.dataset.tag = tag;
el.dataset.start = start;
el.dataset.end = end;
let setStart = document.createElement('span');
setStart.className = "icon is-action";
setStart.innerHTML = '<i class="fas fa-sort-amount-down" title="Set start page"></i>';
setStart.addEventListener('click', () => setTagStart(el));
el.appendChild(setStart);
let label = document.createElement('span');
let name = document.createElement('b');
name.innerHTML = get_instrument(tag);
label.appendChild(name);
let del = document.createElement('span');
del.className = "icon is-action";
del.innerHTML = '<i class="fas fa-trash-alt" title="Remove this tag"></i>';
del.addEventListener('click', () => {
el.remove();
dirty=true;
});
label.appendChild(del)
el.appendChild(label);
let setEnd = document.createElement('span');
setEnd.className = "icon is-action";
setEnd.innerHTML = '<i class="fas fa-sort-amount-up" title="Set end page"></i>';
setEnd.addEventListener('click', () => setTagEnd(el));
el.appendChild(setEnd);
updateTag(el);
tagArea.appendChild(el);
}
function updateTag(tag) {
let start = tag.dataset.start;
let end = tag.dataset.end;
let span = end-start+1;
let height = span * 25 + (span-1) * 5;
let top = (start-1) * 30;
tag.style.height = height + 'px';
tag.style.marginTop = top + 'px';
dirty = true;
}
function setTagStart(el) {
if (pageNum > el.dataset.end) {
alert("Select the first page and click extend");
return;
}
el.dataset.start = pageNum;
updateTag(el);
}
function setTagEnd(el) {
if (pageNum < el.dataset.start) {
alert("Select the last page and click extend");
return;
}
el.dataset.end = pageNum;
updateTag(el);
}
function expandEntries() {
const entries = Array.from(tagArea.children);
entries.sort((a, b) => a.dataset.start-b.dataset.start);
const c = entries.length;
for (let i=0; i<c-1; i++) {
entries[i].dataset.end = entries[i+1].dataset.start - 1;
updateTag(entries[i]);
}
entries[c-1].dataset.end = pdfDoc.numPages;
updateTag(entries[c-1]);
}
function saveTags() {
const pageTags = [];
for (let pageTag of tagArea.children ) {
let start = pageTag.dataset.start;
let end = pageTag.dataset.end;
if (start == 1 && end == pdfDoc.numPages) {
start = null;
end = null;
}
pageTags.push([pageTag.dataset.tag, start, end])
}
console.log(pageTags);
fetch("", {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRFToken": "{{ csrf_token }}" },
body: JSON.stringify(pageTags)
}
).then((response) => {
if (response.ok) {
window.location = "{% url 'work_detail' collection.pk document.work_id %}"
} else {
alert("Failed: " + response.statusText)
}
});
dirty = false;
}
function get_instrument(s) {
let parts = s.split('-');
let instrument = data.instruments[parts[0]];
if (parts.length == 2) {
return instrument + " " + parts[1];
}
return instrument;
}
function checkSaved(e) {
if (dirty) {
e.preventDefault();
}
}
window.addEventListener('beforeunload', checkSaved);
</script>
{% endblock %}
-176
View File
@@ -1,176 +0,0 @@
from interface.tests import AccessTestCase
from byostorage.user import UserStorage
from . import models
from .views.api import WorkSerializer
import tempfile
import json
class LibraryTestCase(AccessTestCase):
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 = (
{'name': 'Baker St', 'ensemble': 'bleeding-gums', 'when': -12},
{'name': 'Navy Recruitment Day', 'ensemble': 'party-posse', 'when': 6},
{'name': 'Barbershop Contest', 'ensemble': 'be-sharps', 'when': 28},
{'name': 'Open Mic Night', 'ensemble': 'bleeding-gums', 'when': 1 },
)
COLLECTIONS = (
{'name': 'Springfield Elementary Library', 'prefix': 'sel'},
{'name': 'Neds Library', 'prefix': 'ned', 'admins': ['homer']},
)
WORKS = (
{'name': 'Baby on Board', 'collection': 'ned', 'docs': [{'upload': 'local:baby_on_board.pdf'}]},
{'name': 'Star Spangled Banner', 'collection': 'sel'},
)
PROTECTED_URLS = (
'/collections/1',
'/collections/1/add',
'/collections/2/works/1',
'/collections/2/works/1/edit',
'/collections/2/works/1/partset',
'/collections/2/works/1/add_to_project',
'/collections/2/works/1/upload',
'/collections/2/docs/1/annotate',
# Need to add storage before we can test these
'/api/collections/2',
'/api/collections/2/works/1',
'/admin/library/collection/',
'/admin/library/document/',
'/admin/library/ensembleaccess/',
'/admin/library/orchestration/',
'/admin/library/projectitem/',
'/admin/library/work/',
)
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.temp_dir = tempfile.TemporaryDirectory()
cls.storage = UserStorage.objects.create(name='local', storage='django.core.files.storage.FileSystemStorage',
settings_data=json.dumps({'location': cls.temp_dir.name, 'base_url': 'file://' + cls.temp_dir.name}))
cls.collections = {}
for details in cls.COLLECTIONS:
admins = details.pop('admins', [])
obj = models.Collection.objects.create(storage=cls.storage, **details)
for admin in admins:
obj.administrators.add(cls.users[admin])
cls.collections[details['prefix']] = obj
cls.works = {}
for details in cls.WORKS:
collection = cls.collections[details.pop('collection')]
#details.setdefault('docs', [])
#details.setdefault('meta_info', [])
#s = WorkSerializer(data=details)
#assert s.is_valid(), s.errors
#s.save(collection_id=collection.pk)
docs = details.pop('docs', [])
obj = models.Work.objects.create(collection=collection, **details)
for doc in docs:
obj.docs.create(**doc)
cls.works[details['name']] = obj
def setUp(self):
pass
@classmethod
def tearDownClass(cls):
cls.temp_dir.cleanup()
def test_integration(self):
pass
def test_superuser_access(self):
self.login('admin', 'secret')
self.assertAccess({
'/collections': True,
'/collections/1': True,
'/collections/2/works/1': True,
})
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_export_and_import(self):
self.login('admin', 'secret')
data = self.client.get('/api/collections/1/works/2', HTTP_ACCEPT="application/json").json()
response = self.client.post('/api/collections/2/import', data, "application/json")
self.assertEqual(response.status_code, 201)
def test_movement_from_large_work(self):
'''
Will be common to store a work which has several movements, but the project is only going to play one.
This also should give us the ability to store an anthology as one Work have Project reference 'no:23'
'''
work = self.collections['sel'].works.create(name="Some Quartet", composer="Beethoven")
for g in ('vl-1', 'vl-2', 'vla', 'vc'):
doc = work.docs.create(upload=f'sel/beethoven/some_quartet/some_quartet_{g}.pdf')
doc.sections.create(tag='mvmt-1', start=1, end=3)
doc.sections.create(tag='mvmt-2', start=4, end=8)
doc.sections.create(tag='mvmt-3', start=9, end=12)
doc.sections.create(tag=g)
# no tags - get nothing (should it be everything?)
self.assertEqual(work.list_sections(), [])
# single tag - should get just that range
self.assertEqual(work.list_sections('vl-1'), [('sel/beethoven/some_quartet/some_quartet_vl-1.pdf', None, None)])
# single tag - returns all documents with that range
result = work.list_sections('mvmt-2')
self.assertEqual(len(result), 4)
# multiple tags - returns the overlapping portion of all documents that have all tags
self.assertEqual(work.list_sections('vl-1', 'mvmt-2'), [('sel/beethoven/some_quartet/some_quartet_vl-1.pdf', 4, 8)])
self.assertEqual(work.list_sections('vl-1', 'vl-2'), [])
-47
View File
@@ -1,47 +0,0 @@
from django.urls import path, include
from django.contrib.auth import views as auth_views
from rest_framework import routers
from . import views
from library.views import api
#router = routers.DefaultRouter()
#router.register(r'collection', external.CollectionViewSet, basename="collection")
#router.register(r'work', external.WorkViewSet, basename="work")
urlpatterns = [
path('projects/<int:project>/items', views.ProjectItemListView.as_view(), name="item_list"),
path('projects/<int:project>/items/manage', views.ProjectItemManageView.as_view(), name="item_list_manage"),
path('projects/<int:project>/items/append', views.ProjectItemAddView.as_view(), name="item_list_append"),
path('library', views.LibraryWorkListView.as_view(), name="work_list"),
path('collections', views.CollectionListView.as_view(), name="collection_list"),
path('collections/<int:collection>', views.CollectionWorkListView.as_view(), name="collection_work_list"),
path('collections/<int:collection>/add', views.WorkAddView.as_view(), name="work_add"),
path('collections/<int:collection>/works/<int:pk>', views.WorkDetailView.as_view(), name="work_detail"),
path('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"),
]
-6
View File
@@ -1,6 +0,0 @@
try:
from .local_settings import *
except ImportError:
from .default_settings import *
INSTALLED_APPS += POLYPHONIC_MODULES
+14
View File
@@ -0,0 +1,14 @@
services:
polyphonic:
image: "polyphonic:0.8.4"
build: "."
ports:
- "8001:8000"
volumes:
- "./data:/var/polyphonic"
- "./local_settings.py:/opt/polyphonic/local_settings.py"
env_file: "compose.env"
environment:
DJANGO_SETTINGS_MODULE: local_settings
PYTHONPATH: /opt/polyphonic
WORK_DIR: /var/polyphonic
-8
View File
@@ -1,8 +0,0 @@
from .default_settings import *
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': '/var/polyphonic/db.sqlite3',
}
}
@@ -11,6 +11,6 @@ import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'polyphonic.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polyphonic.settings")
application = get_asgi_application()
@@ -11,26 +11,29 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
"""
from pathlib import Path
from os import environ
import os
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent
SECRET_KEY = environ.get("SECRET_KEY")
# A place to put things
WORK_DIR = os.environ.get("WORK_DIR") or os.path.join(BASE_DIR, "data")
# Will fail to start if not defined
SECRET_KEY = os.environ.get("SECRET_KEY")
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False
ALLOWED_HOSTS = ["localhost"]
INTERNAL_IPS = ["127.0.0.1"]
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
# Application definition
POLYPHONIC_MODULES = ["library"]
POLYPHONIC_MODULES = ["polyphonic.library"]
INSTALLED_APPS = [
"django.contrib.admin",
@@ -44,9 +47,11 @@ INSTALLED_APPS = [
"crispy_forms",
"crispy_bulma",
"byostorage",
"interface",
"polyphonic.interface",
]
INSTALLED_APPS += POLYPHONIC_MODULES
CRISPY_ALLOWED_TEMPLATE_PACKS = ("bulma",)
CRISPY_TEMPLATE_PACK = "bulma"
@@ -60,7 +65,7 @@ MIDDLEWARE = [
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "polyphonic.urls"
ROOT_URLCONF = "polyphonic.config.urls"
TEMPLATES = [
{
@@ -78,7 +83,7 @@ TEMPLATES = [
},
]
WSGI_APPLICATION = "polyphonic.wsgi.application"
WSGI_APPLICATION = "polyphonic.config.wsgi.application"
# Database
@@ -87,7 +92,7 @@ WSGI_APPLICATION = "polyphonic.wsgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
"NAME": os.path.join(WORK_DIR, "db.sqlite3"),
}
}
@@ -120,17 +125,18 @@ LANGUAGE_CODE = "en-us"
TIME_ZONE = "Australia/Melbourne"
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.1/howto/static-files/
STATIC_URL = "/static/"
STATIC_ROOT = "static"
# Need to set this
AWS_BUCKET = ""
# Library settings
CACHED_STORAGE_REMOTE = "byostorage.user.BYOStorage"
CACHED_STORAGE_DIR = os.path.join(WORK_DIR, "cache")
WHOOSH_INDEX = os.path.join(WORK_DIR, "index")
STORAGE_CLASSES = ["polyphonic.library.gdrive.storage.GDriveLinkStorage"]
+28
View File
@@ -0,0 +1,28 @@
from .base import * # noqa
from os import environ
DEBUG = True
SECRET_KEY = "DO NOT USE IN PRODUCTION"
# Enable debug toolbar
INSTALLED_APPS.append("debug_toolbar") # noqa
MIDDLEWARE.insert(1, "debug_toolbar.middleware.DebugToolbarMiddleware") # noqa
INTERNAL_IPS = ["127.0.0.1"]
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": environ.get("DEBUG_LEVEL", "WARNING"),
},
},
"loggers": {
"polyphonic": {
"handlers": ["console"],
"level": environ.get("DEBUG_LEVEL", "WARNING"),
}
},
}
+4
View File
@@ -0,0 +1,4 @@
from .base import * # noqa
# Enable WhiteNoise for static files
MIDDLEWARE.insert(1, "whitenoise.middleware.WhiteNoiseMiddleware") # noqa
@@ -15,13 +15,13 @@ Including another URLconf
"""
from django.contrib import admin
from django.urls import path, re_path, include
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("interface.urls")),
path("", include("polyphonic.interface.urls")),
# path('', include('submissions.urls')),
path("", include("library.urls")),
path("", include("polyphonic.library.urls")),
]
try:
@@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'polyphonic.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polyphonic.config.settings.base")
application = get_wsgi_application()
@@ -2,28 +2,33 @@ from django.contrib import admin
from . import models
class EnsembleAdmin(admin.ModelAdmin):
list_display = ['name', 'slug']
list_display = ["name", "slug"]
class ModuleInline(admin.StackedInline):
model = models.Module
extra = 0
class ProjectAdmin(admin.ModelAdmin):
list_display = ['name', 'ensemble', 'event_date', 'active']
list_filter = ['ensemble', 'active']
class ProjectAdmin(admin.ModelAdmin):
list_display = ["name", "ensemble", "event_date", "active"]
list_filter = ["ensemble", "active"]
inlines = [ModuleInline]
class ResourceAdmin(admin.ModelAdmin):
list_display = ['name', 'media_type', 'project']
list_filter = ['project']
list_display = ["name", "media_type", "project"]
list_filter = ["project"]
class WikiPageAdmin(admin.ModelAdmin):
list_display = ['title', 'project']
list_filter = ['project']
list_display = ["title", "project"]
list_filter = ["project"]
admin.site.register(models.Ensemble, EnsembleAdmin)
admin.site.register(models.Project, ProjectAdmin)
admin.site.register(models.Resource, ResourceAdmin)
admin.site.register(models.WikiPage, WikiPageAdmin)
admin.site.register(models.WikiPage, WikiPageAdmin)
@@ -2,4 +2,4 @@ from django.apps import AppConfig
class InterfaceConfig(AppConfig):
name = 'interface'
name = "polyphonic.interface"
@@ -1,4 +1,5 @@
from crispy_forms.layout import Field
class BulmaFileUpload(Field):
template = 'bulma/file_upload.html'
template = "bulma/file_upload.html"
+76
View File
@@ -0,0 +1,76 @@
from django import forms
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit, HTML
from crispy_bulma.layout import FormGroup
from . import models, fields
class BaseForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = self.get_form_helper()
def get_form_helper(self):
helper = FormHelper(self)
# helper.add_input(Submit('submit', 'Submit', css_class='button is-link'))
# helper.layout.subm append(HTML('<a class="button is-light">Cancel</a>'))
# print(helper.layout)
helper.layout.append(
FormGroup(
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
class ProjectForm(forms.ModelForm, BaseForm):
class Meta:
model = models.Project
fields = ["name", "description", "modules", "event_date"]
# widgets = {
# '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,
)
class ResourceForm(forms.ModelForm, BaseForm):
class Meta:
model = models.Resource
fields = ["name", "media_type", "description", "file"]
def get_form_helper(self):
helper = super().get_form_helper()
helper[3].wrap(fields.BulmaFileUpload)
return helper
class WikiForm(forms.ModelForm, BaseForm):
class Meta:
model = models.WikiPage
fields = ["title", "markdown"]
class CodeForm(BaseForm):
code = forms.CharField(
max_length=14,
widget=forms.TextInput(
attrs={"placeholder": "xxx-xxx-xxx", "inputmode": "numeric"}
),
)
passphrase = forms.CharField(max_length=32)
class ResourceUploadForm(forms.Form):
pass
# file = S3UploadField()
@@ -4,7 +4,7 @@ import byostorage.user
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import interface.models
import polyphonic.interface.models
class Migration(migrations.Migration):
@@ -23,7 +23,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Display name', max_length=100)),
('slug', models.SlugField(editable=False, help_text='Short name for the ensemble - used for folders', max_length=100, unique=True)),
('code', models.CharField(default=interface.models.generate_code, help_text='Ensemble registration code', max_length=9)),
('code', models.CharField(default=polyphonic.interface.models.generate_code, help_text='Ensemble registration code', max_length=9)),
('passphrase', models.CharField(help_text='Used to register ensembles', max_length=100)),
('details', models.TextField(blank=True, help_text='Description of the ensemble (markdown)')),
('admins', models.ManyToManyField(related_name='ensembles', to=settings.AUTH_USER_MODEL)),
@@ -60,7 +60,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('description', models.TextField(blank=True)),
('file', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=interface.models.resource_key)),
('file', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=polyphonic.interface.models.resource_key)),
('media_type', models.CharField(choices=[('audio', 'Audio'), ('video', 'Video'), ('general', 'General')], default='*', max_length=10)),
('visible', models.BooleanField(default=True)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='resources', to='interface.project')),
@@ -8,17 +8,16 @@ from byostorage.user import BYOStorage
import random
from urllib.parse import urlparse
import os.path
from .utils import sign_data
MEDIA_TYPES = [
('audio', "Audio"),
('video', "Video"),
('general', "General"),
("audio", "Audio"),
("video", "Video"),
("general", "General"),
]
def rough_date(d):
if not d:
return False, "sometime..."
@@ -27,53 +26,62 @@ def rough_date(d):
if in_past:
days = abs(days)
if days == 0:
m = int((d-timezone.now()).seconds/60)
m = int((d - timezone.now()).seconds / 60)
if m > 60:
return in_past, "{0:d} hours".format(int(m / 60))
return in_past, "{0:d} minutes!".format(int(m % 60))
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:
return in_past, "{0:d} weeks, {1:d} days".format(int(days / 7), int(days % 7))
return in_past, f"{days} days"
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):
def for_user(self, user, ensemble_keys=[], project_keys=[]):
if user.is_superuser:
return self
return self
f = models.Q(slug__in=ensemble_keys) | models.Q(projects__in=project_keys)
if user.is_authenticated:
f |= models.Q(admins=user.pk)
return self.filter(f).distinct()
class Ensemble(models.Model):
''' A group that plays together
'''
name = models.CharField(max_length=100,
help_text="Display name")
slug = models.SlugField(max_length=100, editable=False, unique=True,
help_text="Short name for the ensemble - used for folders")
admins = models.ManyToManyField('auth.User', related_name='ensembles')
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")
"""A group that plays together"""
name = models.CharField(max_length=100, help_text="Display name")
slug = models.SlugField(
max_length=100,
editable=False,
unique=True,
help_text="Short name for the ensemble - used for folders",
)
admins = models.ManyToManyField("auth.User", related_name="ensembles")
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()
class Meta:
ordering = ('slug', )
ordering = ("slug",)
def active_projects(self):
return self.projects.active().current()
@@ -83,7 +91,7 @@ class Ensemble(models.Model):
return False
if user.is_superuser:
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):
if not self.slug:
@@ -91,49 +99,56 @@ class Ensemble(models.Model):
super(Ensemble, self).save(**kwargs)
def get_absolute_url(self):
return resolve_url('ensemble_detail', ensemble=self.slug)
return resolve_url("ensemble_detail", ensemble=self.slug)
def auth(self):
return sign_data(f'{self.pk}-{self.nonce}', 12)
return sign_data(f"{self.pk}-{self.nonce}", 12)
def __str__(self):
return self.name
class ProjectQuerySet(models.QuerySet):
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):
return self.filter(active=True)
def for_user(self, user, project_keys=[], ensemble_keys=[]):
if user.is_superuser:
return self
return self
f = models.Q(pk__in=project_keys) | models.Q(ensemble__slug__in=ensemble_keys)
if user.is_authenticated:
f |= models.Q(ensemble__admins=user.pk)
return self.filter(f)
class Project(models.Model):
''' A Project linked to an ensemble
'''
"""A Project linked to an ensemble"""
name = models.CharField(max_length=100)
ensemble = models.ForeignKey(Ensemble, related_name='projects', on_delete=models.CASCADE, null=True)
description = models.TextField(blank=True,
help_text="Markdown format")
ensemble = models.ForeignKey(
Ensemble, related_name="projects", on_delete=models.CASCADE, null=True
)
description = models.TextField(blank=True, help_text="Markdown format")
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)
nonce = models.SmallIntegerField(default=1,
help_text="Increment this to reset the authentication links")
nonce = models.SmallIntegerField(
default=1, help_text="Increment this to reset the authentication links"
)
objects = ProjectQuerySet.as_manager()
class Meta:
ordering = ['active', 'event_date']
ordering = ["active", "event_date"]
@property
def days(self):
@@ -162,62 +177,74 @@ class Project(models.Model):
@property
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):
return resolve_url('project_detail', project=self.pk)
return resolve_url("project_detail", project=self.pk)
def auth(self):
return sign_data(f'{self.pk}-{self.nonce}', 12)
return sign_data(f"{self.pk}-{self.nonce}", 12)
def __str__(self):
return self.name
class Module(models.Model):
''' Enable modules on a oriject
'''
name = models.SlugField(max_length=20, choices=[ (x, x.title()) for x in settings.POLYPHONIC_MODULES ])
project = models.ForeignKey(Project, related_name="modules", on_delete=models.CASCADE)
"""Enable modules on a oriject"""
name = models.SlugField(
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):
return self.name
def resource_key(resource, filename):
return f'{resource.project.folder}/resources/{filename}'
return f"{resource.project.folder}/resources/{filename}"
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
'''
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)
description = models.TextField(blank=True)
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)
class Meta:
ordering = ['-visible', '-pk']
ordering = ["-visible", "-pk"]
def accept(self):
if self.media_type == 'general':
if self.media_type == "general":
return ".*"
return f"{self.media_type}/*"
def __str__(self):
return self.name
class WikiPage(models.Model):
''' An editable wiki page for the project in markdown format
'''
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)
markdown = models.TextField()
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):
return self.title
return self.title

Before

Width:  |  Height:  |  Size: 426 KiB

After

Width:  |  Height:  |  Size: 426 KiB

@@ -32,10 +32,11 @@
.menu-label {
color: var(--primary);
}
/*
.button.is-primary, .button.is-primary:hover {
background-color: var(--primary);
}
*/
A.admin-link:after {
content: "*";
@@ -60,4 +61,4 @@ TEXTAREA.input {
padding: 10px 10px;
margin-top: 30px;
border-top-left-radius: 6px;
}
}

Before

Width:  |  Height:  |  Size: 258 B

After

Width:  |  Height:  |  Size: 258 B

@@ -1,17 +1,19 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="light">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="{% static 'interface/icon.png' %}" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<!--link rel="stylesheet" href="{% static 'interface/css/bulmaswatch.min.css' %}"></link-->
<link rel="stylesheet" href="{% static 'interface/css/polyphonic.css' %}"></link>
<script src="{% static 'interface/js/interface.js' %}"></script>
<script src="//unpkg.com/alpinejs" defer></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js" defer></script>
<script src="//kit.fontawesome.com/c837098e5b.js" crossorigin="anonymous" defer></script>
<!-- script src="//kit.fontawesome.com/c837098e5b.js" crossorigin="anonymous" defer></script -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" rel="stylesheet" />
<title>{% block title %}Polyphonic{% endblock %}</title>
{% block media %}{% endblock %}
<style>{% block style %}{% endblock %}</style>
@@ -23,7 +25,7 @@
<nav class="navbar" role="navigation">
<div class="navbar-brand has-text-primary">
<a class="navbar-item" href="/">
<span class="icon fancy is-size-2 is-size-4-mobile mx-4"><i class="fas fa-random"></i></span>
<span class="icon fancy mx-4"><span class="material-symbols-outlined is-size-1 is-size-3-mobile">groups</span></span>
<span class="fancy is-size-2 is-size-4-mobile">Polyphonic</span>
</a>
<span class="navbar-item is-hidden-mobile fancy is-size-5">Musical Ensemble Manager</span>
@@ -6,7 +6,7 @@
{% crispy_field field 'class' 'file-input'%}
<span class="file-cta">
<span class="file-icon">
<i class="fas fa-upload"></i>
<span class="material-symbols-outlined">file_upload</span>
</span>
<span class="file-label">
Choose a file…
@@ -25,4 +25,4 @@
fileName.textContent = fileInput.files[0].name;
}
}
</script>
</script>
@@ -1,19 +1,20 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% load polyphonic %}
{% block admin %}
<a href="{% url 'project_create' object.slug %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
{{ "add_notes"|icon }}
<span>Add project</span>
</a>
{% if inactive %}
<a href="?" class="button is-link">
<span class="icon"><i class="fas fa-archive"></i></span>
{{ "preview_off"|icon }}
<span>Hide old</span>
</a>
{% else %}
<a href="?inactive" class="button is-link">
<span class="icon"><i class="fas fa-archive"></i></span>
{{ "preview"|icon }}
<span>Show all</span>
</a>
{% endif %}
@@ -54,4 +55,4 @@ Contacts:
<a href="{% url 'forget_resource' 'ensemble' ensemble.slug %}">Forget this ensemble</a>
</div>
{% endblock %}
{% endblock %}
@@ -5,7 +5,7 @@
{% comment %}
<div class="admin-tools is-pulled-right">
<a class="button is-link" href="{% url 'register' %}">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
{% icon "add_file" %}
<span>Register another</span>
</a>
</div>
@@ -25,10 +25,13 @@
<img src="https://www.gravatar.com/avatar/{{ ensemble.email }}?d=mp" alt="Placeholder image">
</figure>
</div>
<div class="media-content" style="min-height: 60px">
<div class="media-content" style="min-height: 100px">
<a href="{% url 'ensemble_detail' ensemble.slug %}">
<p class="title is-4">{{ ensemble.name }}</p>
</a>
<div class="mt-3">
{{ ensemble.details|markdown }}
</div>
</div>
</div>
</div>
@@ -45,4 +48,4 @@
</div>
{% endfor %}
</div>
{% endblock %}
{% endblock %}
@@ -4,11 +4,11 @@
{% block admin %}
<a href="{% url 'wiki_create' project=project.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-file"></i></span>
<span>Add Page</span>
{{ "add_notes"|icon }}
<span>Add Page</span>
</a>
<a href="{% url 'project_edit' project=project.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-edit"></i></span>
{{ "edit"|icon }}
<span>Edit</span>
</a>
{% endblock %}
@@ -33,4 +33,4 @@
</div>
</div>
{% endfor %}
</div>
</div>
@@ -1,9 +1,10 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% load polyphonic %}
{% block admin %}
<a class="button is-link" href="{% url 'resource_create' project=project.pk %}">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
{% icon "add_notes" %}
<span>Add new</span>
</a>
{% endblock %}
@@ -27,11 +28,11 @@
<div class="card-header-icon">
{% if request.is_admin %}
<a href="{% url 'resource_upload' project=project.pk pk=resource.pk %}" class="icon" title="Upload">
<i class="fas fa-upload"></i>
<a href="{% url 'resource_upload' project=project.pk pk=resource.pk %}" title="Upload">
{% icon "upload_file" %}
</a>
<a href="{% url 'resource_edit' project=project.pk pk=resource.pk %}" class="icon" title="Edit">
<i class="fas fa-edit"></i>
<a href="{% url 'resource_edit' project=project.pk pk=resource.pk %}" title="Edit">
{% icon "edit" %}
</a>
{% endif %}
</div>
@@ -1,9 +1,10 @@
{% extends "interface/project_base.html" %}
{% load polyphonic %}
{% block admin %}
<a href="{% url 'wiki_edit' project=project.pk pk=wikipage.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-edit"></i></span>
<span>Edit</span>
{{ "edit"|icon }}
<span>Edit</span>
</a>
{% endblock %}
@@ -12,4 +13,4 @@
<div class="box content wiki-page">
{{ wiki_html|safe }}
</div>
{% endblock %}
{% endblock %}
@@ -3,7 +3,9 @@ import os.path
register = template.Library()
def basename(value):
return os.path.basename(value)
register.filter('basename', basename)
register.filter("basename", basename)
@@ -0,0 +1,36 @@
from django import template
from django.utils import timesince
from django.utils.html import format_html
register = template.Library()
@register.filter("icon", is_safe=True)
def material_icon(value):
return f'<span class="icon"><span class="material-symbols-outlined">{value}</span></span>'
@register.simple_tag
def icon(name, element="span", classes=[]):
classes = ["icon"] + classes
return format_html(
'<{} class="{}"><span class="material-symbols-outlined">{}</span></{}>',
element,
" ".join(classes),
name,
element,
)
def roughtimesince(value):
return timesince.timesince(value, depth=1)
register.filter("roughtimesince", roughtimesince)
def roughtimeuntil(value):
return timesince.timeuntil(value, depth=1)
register.filter("roughtimeuntil", roughtimeuntil)
@@ -2,9 +2,10 @@ from django import template
register = template.Library()
@register.simple_tag(takes_context=True)
def url_update(context, **kwargs):
params = context.request.GET.copy()
for k in kwargs:
params[k] = kwargs[k]
return "?" + params.urlencode()
return "?" + params.urlencode()
@@ -1,11 +1,11 @@
from django.test import TestCase
from interface import models, utils
from polyphonic.interface import models
from django.contrib.auth.models import User
from django.utils import timezone
from datetime import timedelta
class AccessTestCase(TestCase):
class AccessTestCase(TestCase):
USERS = ()
ENSEMBLES = ()
@@ -16,16 +16,15 @@ class AccessTestCase(TestCase):
@classmethod
def setUpTestData(cls):
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()
cls.ensembles = {}
for details in cls.ENSEMBLES:
admins = details.pop('admins', [])
admins = details.pop("admins", [])
obj = models.Ensemble.objects.create(**details)
for admin in admins:
obj.admins.add(cls.users[admin])
@@ -33,37 +32,40 @@ class AccessTestCase(TestCase):
cls.projects = {}
for details in cls.PROJECTS:
when = details.pop('when', 0)
ensemble = details.pop('ensemble')
details['event_date'] = now + timedelta(days=when) if when else None
when = details.pop("when", 0)
ensemble = details.pop("ensemble")
details["event_date"] = now + timedelta(days=when) if when else None
obj = cls.ensembles[ensemble].projects.create(**details)
cls.projects[details['name']] = obj
cls.projects[details["name"]] = obj
return
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:
self.client.force_login(self.users['admin'])
self.assertAccess({ x: True for x in self.PROTECTED_URLS })
if "admin" in self.users:
self.client.force_login(self.users["admin"])
self.assertAccess({x: True for x in self.PROTECTED_URLS})
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}")
def authorize(self, model, **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)
def assertAccess(self, urls):
for url, expected in urls.items():
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")
objects = response.context['object_list'].values_list(element, flat=True)
self.assertEqual(list(objects), expected)
objects = response.context["object_list"].values_list(element, flat=True)
self.assertEqual(list(objects), expected)
+214
View File
@@ -0,0 +1,214 @@
from polyphonic.interface import models
from . import AccessTestCase
class InterfaceAccessTestCase(AccessTestCase):
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 = (
{"name": "Baker St", "ensemble": "bleeding-gums", "when": -12},
{"name": "Navy Recruitment Day", "ensemble": "party-posse", "when": 6},
{"name": "Barbershop Contest", "ensemble": "be-sharps", "when": 28},
{"name": "Open Mic Night", "ensemble": "bleeding-gums", "when": 1},
{"name": "Current Repertoire", "ensemble": "be-sharps"},
)
PROTECTED_URLS = (
"/ensembles/be-sharps",
"/ensembles/be-sharps/new-project",
"/projects/3",
"/projects/3/resources",
"/projects/3/resources/add",
"/admin/interface/ensemble/",
"/admin/interface/project/",
"/admin/interface/resource/",
"/admin/interface/wikipage/",
)
def test_bad_login(self):
with self.assertRaisesMessage(
self.failureException, "Failed to login as admin"
):
self.login("admin", "admin")
def test_superuser_ensembles(self):
self.login("admin", "secret")
response = self.client.get("/ensembles")
self.assertObjectList(
response, ["The Be Sharps", "Lisa & the Bleeding Gums", "Party Posse"]
)
self.assertContains(response, "Django Admin")
def test_superuser_ensemble_permissions(self):
self.login("admin", "secret")
response = self.client.get("/ensembles/party-posse")
self.assertTrue(response.context["request"].is_admin)
self.assertContains(response, "Add project")
self.assertAccess(
{
"/ensembles/be-sharps": True,
"/ensembles/bleeding-gums": True,
"/ensembles/party-posse": True,
"/ensembles/unknown": False,
"/ensembles/be-sharps/new-project": True,
}
)
def test_superuser_projects(self):
self.login("admin", "secret")
response = self.client.get("/projects")
self.assertObjectList(
response,
[
"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):
self.login("homer", "maggie")
response = self.client.get("/ensembles")
self.assertObjectList(response, ["The Be Sharps"])
self.assertNotContains(response, "Django Admin")
def test_user_ensemble_permissions(self):
self.login("homer", "maggie")
response = self.client.get("/ensembles/be-sharps")
self.assertTrue(response.context["request"].is_admin)
self.assertContains(response, "Add project")
self.assertContains(response, "Show all")
self.assertAccess(
{
"/ensembles/be-sharps": True,
"/ensembles/bleeding-gums": False,
"/ensembles/party-posse": False,
"/ensembles/be-sharps/new-project": True,
"/ensembles/party-posse/new-project": False,
}
)
self.authorize(models.Ensemble, slug="bleeding-gums")
self.assertAccess(
{
"/ensembles/be-sharps": True,
"/ensembles/bleeding-gums": True,
"/ensembles/party-posse": 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")
self.assertNotContains(response, "Show all")
def test_user_projects(self):
self.login("homer", "maggie")
response = self.client.get("/projects")
self.assertObjectList(response, ["Current Repertoire", "Barbershop Contest"])
response = self.client.get("/projects/3")
self.assertTrue(response.context["request"].is_admin)
self.assertAccess(
{
"/projects/3": True,
"/projects/3/resources": True,
"/projects/3/resources/add": True,
"/projects/4": False,
"/projects/4/resources": False,
"/projects/4/resources/add": False,
}
)
self.authorize(models.Project, pk=4)
response = self.client.get("/projects")
self.assertObjectList(
response, ["Current Repertoire", "Open Mic Night", "Barbershop Contest"]
)
response = self.client.get("/projects/4")
self.assertFalse(response.context["request"].is_admin)
def test_anon_ensembles(self):
response = self.client.get("/ensembles")
self.assertObjectList(response, [])
self.assertContains(
response, "You don't currently have access to any ensembles"
)
def test_anon_authorized_ensemble(self):
self.authorize(models.Ensemble, slug="party-posse")
response = self.client.get("/ensembles/party-posse")
self.assertContains(response, "Party Posse")
response = self.client.get("/ensembles")
self.assertObjectList(response, ["Party Posse"])
self.assertAccess(
{
"/ensembles/be-sharps": False,
"/ensembles/party-posse": True,
"/ensembles/bleeding-gums": False,
"/ensembles/unknown": False,
}
)
response = self.client.get("/projects")
self.assertObjectList(response, ["Navy Recruitment Day"])
def test_anon_authorized_project(self):
self.authorize(models.Project, pk=4)
self.assertObjectList(self.client.get("/projects"), ["Open Mic Night"])
self.assertObjectList(
self.client.get("/ensembles"), ["Lisa & the Bleeding Gums"]
)
self.assertAccess(
{
"/projects/4": True,
"/projects/4/resources": True,
"/projects/1": False,
"/projects/1/resources": False,
}
)
def test_anon_permission_denied(self):
self.assertAccess(
{
"/ensembles": True,
"/ensembles/be-sharps": False,
"/ensembles/party-posse": False,
"/ensembles/bleeding-gums": False,
"/ensembles/unknown": False,
}
)
def test_anon_deauthorize_project(self):
self.authorize(models.Project, pk=4)
self.assertAccess({"/projects/4": True})
models.Project.objects.filter(pk=4).update(nonce=2)
self.assertAccess({"/projects/4": False})
@@ -1,6 +1,6 @@
from django.test import TestCase
class IntegrationTestCase(TestCase):
class IntegrationTestCase(TestCase):
def test_runs(self):
self.assertTrue(True)
self.assertTrue(True)
+79
View File
@@ -0,0 +1,79 @@
from django.urls import path
from django.contrib.auth import views as auth_views
from django.views.generic.base import RedirectView
from django.conf import settings
from . import views
urlpatterns = [
path("", RedirectView.as_view(url="projects", permanent=False), name="home"),
path("login", auth_views.LoginView.as_view(), name="login"),
path("logout", views.logout_view, name="logout"),
path(
"forget/<resource>/<key>",
views.ForgetResourceView.as_view(),
name="forget_resource",
),
path("ensembles", views.EnsembleListView.as_view(), name="ensemble_list"),
path(
"ensembles/<slug:ensemble>",
views.EnsembleDetailView.as_view(),
name="ensemble_detail",
),
path(
"ensembles/<slug:ensemble>/new-project",
views.ProjectCreateView.as_view(),
name="project_create",
),
path("projects", views.ProjectListView.as_view(), name="project_list"),
path(
"projects/<int:project>",
views.ProjectDetailView.as_view(),
name="project_detail",
),
path(
"projects/<int:project>/edit",
views.ProjectUpdateView.as_view(),
name="project_edit",
),
# path('projects/<int:project>/submissions.mk', views.ProjectMakefileView.as_view(), name="project_makefile"),
path(
"projects/<int:project>/page/create",
views.WikiCreateView.as_view(),
name="wiki_create",
),
path("projects/<int:project>/page/<int:pk>", views.WikiView.as_view(), name="wiki"),
path(
"projects/<int:project>/page/<int:pk>/edit",
views.WikiEditView.as_view(),
name="wiki_edit",
),
path(
"projects/<int:project>/resources",
views.ResourceListView.as_view(),
name="resource_list",
),
path(
"projects/<int:project>/resources/add",
views.ResourceCreateView.as_view(),
name="resource_create",
),
path(
"projects/<int:project>/resources/<int:pk>/upload",
views.ResourceUploadView.as_view(),
name="resource_upload",
),
path(
"projects/<int:project>/resources/<int:pk>/edit",
views.ResourceEditView.as_view(),
name="resource_edit",
),
]
if settings.DEBUG:
from django.views.static import serve
urlpatterns.append(
path("local_storage/<path:path>", serve, {"document_root": "local_storage"})
)
@@ -1,18 +1,20 @@
from django.shortcuts import resolve_url
from django.core.signing import Signer
from django.core.exceptions import SuspiciousOperation
import logging
signer = Signer()
import logging
logger = logging.getLogger(__name__)
def sign_data(data, l=None):
def sign_data(data, length=None):
sig = signer.sign(data)
p = len(data) + 1
if l:
l += p
return sig[p:l]
pos = len(data) + 1
if length:
length += pos
return sig[pos:length]
def signed_url(name, **kwargs):
"""
@@ -23,16 +25,19 @@ def signed_url(name, **kwargs):
sep = "&" if "?" in url else "?"
return sig.replace(":", f"{sep}auth=")
def check_signed_url(full_path):
p = full_path.rfind('auth')
url = full_path[:p-1]
p = full_path.rfind("auth")
url = full_path[: p - 1]
logger.debug("check_signed_url: %s", url)
signed = signed_url(url)
if signed != full_path:
logger.debug("Mismatch: %s != %s", full_path, signed)
signed = "_HIDDEN_"
raise SuspiciousOperation("Bad auth code")
if __name__ == '__main__':
if __name__ == "__main__":
import doctest
print(doctest.testmod())
print(doctest.testmod())
@@ -10,11 +10,12 @@ from django.views.generic.edit import CreateView, UpdateView
from django.core.exceptions import SuspiciousOperation
from django.http import Http404, HttpResponseRedirect
from django.http.request import HttpRequest
from django.contrib.auth import logout
from markdown2 import markdown
from . import models, forms
from interface.utils import check_signed_url
from .utils import check_signed_url
import logging
@@ -108,6 +109,11 @@ class AuthorizedResourceMixin(object):
# TODO: RevokeResourceView - increment nonce
def logout_view(request):
logout(request)
return redirect("/")
class ForgetResourceView(AuthorizedResourceMixin, RedirectView):
def is_authorized(self):
return True
@@ -2,60 +2,77 @@ from django.contrib import admin
from . import models
class EnsembleAccessInline(admin.StackedInline):
model = models.EnsembleAccess
extra = 0
class CollectionAdmin(admin.ModelAdmin):
list_display = ['name', 'location', 'storage', 'prefix']
list_display = ["name", "location", "storage", "prefix"]
inlines = [EnsembleAccessInline]
admin.site.register(models.Collection, CollectionAdmin)
class ItemInline(admin.TabularInline):
model = models.ProjectItem
extra = 0
class DocInline(admin.TabularInline):
model = models.Document
extra = 0
class MetaInline(admin.TabularInline):
model = models.WorkMeta
extra = 0
class WorkAdmin(admin.ModelAdmin):
list_display = ['name', 'composer', 'edition', 'identifier', 'running_time']
list_filter = ['collection']
search_fields = ['name', 'composer']
list_display = ["name", "composer", "edition", "identifier", "running_time"]
list_filter = ["collection"]
search_fields = ["name", "composer"]
inlines = [MetaInline, DocInline, ItemInline]
admin.site.register(models.Work, WorkAdmin)
class SectionInline(admin.TabularInline):
model = models.Section
fields = ['tag', 'start', 'end', 'page']
fields = ["tag", "start", "end", "page"]
class DocumentAdmin(admin.ModelAdmin):
list_display = ['work', '__str__']
list_filter = ['work__collection']
list_display = ["work", "__str__"]
list_filter = ["work__collection"]
inlines = [SectionInline]
admin.site.register(models.Document, DocumentAdmin)
class ItemAdmin(admin.ModelAdmin):
list_display = ['project', 'work', 'order']
list_filter = ['project']
list_display = ["project", "work", "order"]
list_filter = ["project"]
admin.site.register(models.ProjectItem, ItemAdmin)
class EnsembleAccessAdmin(admin.ModelAdmin):
list_display = ['ensemble', 'collection', 'access_type']
list_filter = ['ensemble']
list_display = ["ensemble", "collection", "access_type"]
list_filter = ["ensemble"]
admin.site.register(models.EnsembleAccess, EnsembleAccessAdmin)
class OrchestrationAdmin(admin.ModelAdmin):
list_display = ['name', 'instruments']
admin.site.register(models.Orchestration, OrchestrationAdmin)
class OrchestrationAdmin(admin.ModelAdmin):
list_display = ["name", "instruments"]
admin.site.register(models.Orchestration, OrchestrationAdmin)
@@ -2,4 +2,4 @@ from django.apps import AppConfig
class LibraryConfig(AppConfig):
name = 'library'
name = "polyphonic.library"
+54
View File
@@ -0,0 +1,54 @@
from django import forms
from .models import Work
from polyphonic.interface.models import Project
from polyphonic.interface.forms import BaseForm
class WorkCreateForm(forms.ModelForm, BaseForm):
class Meta:
model = Work
fields = [
"name",
"composer",
"edition",
"code",
"orchestration",
"licence",
"running_time",
"notes",
]
class PlaylistAddForm(forms.Form):
work = forms.ModelChoiceField(queryset=Work.objects.all())
def __init__(self, instance, *args, **kwargs):
super(PlaylistAddForm, self).__init__(*args, **kwargs)
existing = [x[0] for x in instance.works.values_list("pk")]
qs = Work.objects.filter(ensemble_id=instance.ensemble_id).exclude(
id__in=existing
)
self.fields["work"].queryset = qs
self.instance = instance
def save(self):
self.instance.works.add(self.cleaned_data["work"])
class ProjectEnsembleChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
return f"{obj.ensemble.name} - {obj.name}"
class ProjectSelectForm(BaseForm):
project = ProjectEnsembleChoiceField(queryset=Project.objects.all())
class DocumentLinkForm(BaseForm):
link = forms.URLField(help_text="Paste the direct link relevant to this storage")
class DocumentBulkForm(BaseForm):
folder_link = forms.URLField(help_text="Paste the folder link for this storage")
+90
View File
@@ -0,0 +1,90 @@
from polyphonic.library.models import Collection, Work, WorkMeta, Document
from byostorage.models import UserStorage
import logging
logger = logging.getLogger(__name__)
def sync_work(work: Work):
try:
folder_id = work.meta_info.get(name="folderid").value
except WorkMeta.DoesNotExist as err:
raise IndexError("Work not currently linked to a gdrive folder") from err
logger.info("Syncing '%s' from %r", work.name, folder_id)
storage = UserStorage.objects.get(name="gdrive").instance()
existing = set(
[
storage.parse_resource(x.partition(":")[2]).id
for x in work.docs.values_list("upload", flat=True)
]
)
logger.debug("%d existing documents", len(existing))
_, files = storage.listdir(folder_id)
logger.debug("Remote files: %r", files)
for file in files:
if file.id in existing:
logger.debug("%30s: Skipping existing (%s)", file.name, file.id)
existing.discard(file.id)
continue
if not file.name.lower().endswith(".pdf"):
logger.debug("%40s: Not a PDF", file.name)
continue
logger.info("%40s: Adding", file.name)
doc = work.docs.create(upload=f"gdrive:{file}", doctype=Document.DOCTYPE_PDF)
doc.auto_tag()
for uri in existing:
logger.warning("Local entry not in folder: %s", uri)
def sync_partial_collection(collection: Collection, sync_existing: bool = True):
works = Work.objects.filter(collection=collection, meta_info__name="folderid")
for work in works:
sync_work(work)
def sync_collection(collection: Collection, sync_existing: bool = False):
logger.info("Syncing '%s'", collection)
if not collection.storage.storage.endswith("GDriveLinkStorage"):
return sync_partial_collection(collection, sync_existing)
existing = dict(
WorkMeta.objects.filter(
work__collection=collection, name="folderid"
).values_list("value", "work_id")
)
folder = collection.prefix
storage = collection.storage.instance()
folders, _ = storage.listdir(folder)
for folder in folders:
if folder[0] == "_":
continue
if folder.id in existing:
if sync_existing:
logger.info("%40s: Syncing (%s)", folder.name, folder.id[:12])
sync_work(Work.objects.get(pk=existing[folder.id]))
del existing[folder.id]
continue
logger.info("%40s: Adding", folder.name)
work = Work(name=folder.name, collection=collection)
work.save()
work.meta_info.create(name="folderid", value=folder.id)
sync_work(work)
for folderid, work in existing:
logger.warning("Folder for work %d no longer in drive (%s)", work, folderid)
+141
View File
@@ -0,0 +1,141 @@
from django.core.files.storage import Storage
from collections import namedtuple
import requests
import re
from gzip import GzipFile
import logging
logger = logging.getLogger(__name__)
SHARED_FOLDER = re.compile(
r"https://drive.google.com/drive[u0-9\/]+folders/([\w\-]+)(\?resourcekey=([\w\-]+))?"
)
SHARED_FILE = re.compile(
r"https://drive.google.com/file/d/([\w\-]+)(\?resourcekey=([\w\-]+))?"
)
FILES_API = "https://www.googleapis.com/drive/v3/files"
class DriveObject(namedtuple("DriveObject", ("id", "key", "name"))):
@classmethod
def from_string(cls, s: str):
resource, _, name = s.partition("/")
id, _, key = resource.partition("#")
return cls(id, key, name)
def __str__(self):
resource = f"{self.id}#{self.key}" if self.key else self.id
return f"{resource}/{self.name}"
class GDriveLinkStorage(Storage):
is_writable = False
def __init__(self, api_key):
self.api_key = api_key
super().__init__()
def parse_resource(self, name) -> DriveObject:
return DriveObject.from_string(name)
def extract_resource(self, url, *patterns) -> DriveObject:
logger.debug("EXTRACT_RESOURCE: %r", url)
for pattern in patterns:
match = pattern.match(url)
if match:
groups = match.groups()
logger.debug(groups)
if len(groups) == 3:
return DriveObject(groups[0], groups[2], "")
return DriveObject(groups[0], None, "")
raise FileNotFoundError(f"Not a valid url: {url}")
def get_json(self, url, resource: DriveObject):
headers = {}
if resource.key:
headers["X-Goog-Drive-Resource-Keys"] = f"{resource.id}/{resource.key}"
logger.debug("GET_JSON: %s %r", url, headers)
response = requests.get(url, headers=headers)
data = response.json()
logger.debug("Data: %r", data)
return data
def listdir(self, path) -> tuple[list[str], list[str]]:
# used to test for valid connection parameters - should do something to validate API key here
logger.debug("listdir: %s", path)
if path == "":
return [], []
folder = self.parse_resource(path)
url = f"{FILES_API}?q='{folder.id}'+in+parents&key={self.api_key}"
data = self.get_json(url, folder)
files = []
folders = []
for x in data["files"]:
if x["mimeType"] == "application/vnd.google-apps.folder":
# folders.append(f"{x['id']}/{x['name']}")
folders.append(DriveObject(x["id"], x.get("resourceKey"), x["name"]))
else:
# files.append(f"{x['id']}/{x['name']}")
files.append(DriveObject(x["id"], x.get("resourceKey"), x["name"]))
return folders, files
def get_meta(self, name):
file_resource = self.parse_resource(name)
url = f"{FILES_API}/{file_resource.id}?key={self.api_key}"
return self.get_json(url, file_resource)
def open(self, name, mode="rb"):
resource = self.parse_resource(name)
url = f"{FILES_API}/{resource.id}?alt=media&key={self.api_key}"
headers = {}
if resource.key:
headers["X-Goog-Drive-Resource-Keys"] = f"{resource.id}/{resource.key}"
logger.info("URL: %s [%r]", url, headers)
response = requests.get(url, headers=headers, stream=True)
return GzipFile(name, "rb", 9, response.raw)
def size(self, name):
raise NotImplementedError()
def delete(self, name):
pass
def url(self, name):
logger.debug("URL: %r", name)
resource = self.parse_resource(name)
uri = f"https://drive.usercontent.google.com/download?export=download&id={resource.id}&confirm=yes"
if resource.key:
uri += f"&resourcekey=${resource.key}"
return uri
def get_folder_id(self, url):
try:
return self.extract_resource(url, SHARED_FOLDER)
except FileNotFoundError:
return None
def get_file_id(self, url):
try:
return self.extract_resource(url, SHARED_FILE)
except FileNotFoundError:
return None
def import_link(self, url) -> str:
file_resource = self.extract_resource(url, SHARED_FILE)
meta = self.get_meta(file_resource)
return f"{file_resource}/{meta['name']}"
"""
def folder_import(self, url) -> list[str]:
folder_id = self.extract_id(url, SHARED_FOLDER)
_, files = self.listdir(folder_id)
return files
"""
+62
View File
@@ -0,0 +1,62 @@
from django.shortcuts import resolve_url, redirect
from django.views.generic import FormView
from django.views.generic.detail import SingleObjectMixin
from polyphonic.library.views import CollectionMixin
from polyphonic.library.models import Work, Document
from polyphonic.library import forms
from byostorage.models import UserStorage
from . import sync_work
class WorkGDriveView(CollectionMixin, SingleObjectMixin, FormView):
model = Work
template_name = "library/gdrive.html"
form_class = forms.DocumentLinkForm
@property
def cancel_url(self):
return resolve_url("work_detail", self.collection.pk, self.kwargs["pk"])
def get_context_data(self, *args, **kwargs):
self.object = self.get_object()
data = super().get_context_data(*args, **kwargs)
data["meta"] = dict(self.object.meta_info.values_list("name", "value"))
print(data["meta"])
return data
def form_valid(self, form):
link = form.cleaned_data["link"]
# storage = self.collection.storage.instance()
storage = UserStorage.objects.get(name="gdrive").instance()
self.object = self.get_object()
folderid = storage.get_folder_id(link)
if folderid:
self.object.meta_info.update_or_create(
name="folderid", defaults={"value": folderid}
)
sync_work(self.object)
return redirect("work_detail", self.collection.pk, self.kwargs["pk"])
link = storage.import_link(link)
if link is None:
form.add_error("link", "Not a valid link")
return self.form_invalid(form)
work = self.collection.works.get(pk=self.kwargs["pk"])
doc = Document(
work=work,
upload=f"gdrive:{link}",
# upload=f"{self.collection.storage.name}:{link}",
doctype=Document.DOCTYPE_PDF,
)
doc.save()
doc.auto_tag()
return redirect("work_detail", self.collection.pk, self.kwargs["pk"])
@@ -3,9 +3,9 @@ from django.conf import settings
from django.utils.module_loading import import_module
from django.db.models import QuerySet
from library.models import Work, Collection
from polyphonic.library.models import Work, Collection
instance = getattr(settings, "INDEXER", "library.indexer.whoosh")
instance = getattr(settings, "INDEXER", "polyphonic.library.indexer.whoosh")
class Indexer(Protocol):
@@ -1,15 +1,13 @@
from whoosh.index import create_in, open_dir, EmptyIndexError, Index
from whoosh.analysis import StemmingAnalyzer, CharsetFilter
from whoosh.support.charset import accent_map
from whoosh.fields import Schema, ID, TEXT, KEYWORD, STORED, NUMERIC
from whoosh.fields import Schema, TEXT, KEYWORD, NUMERIC
from whoosh.qparser import QueryParser
from whoosh.query import Term, NullQuery
from whoosh.query import Term, NullQuery, FuzzyTerm
from library.models import Work
from typing import Any
from django.conf import settings
from django.db.models import QuerySet
import os.path
import shutil
@@ -26,7 +24,7 @@ schema = Schema(
)
index_path = os.path.join(os.path.dirname(settings.BASE_DIR), "index")
index_path = settings.WHOOSH_INDEX
def create_index() -> Index:
@@ -63,20 +61,26 @@ def search(
pagesize: int = 20,
) -> tuple[list[dict], dict[str, Any]]:
meta = {}
query = query.lower()
qp = QueryParser("text", schema=schema)
q = qp.parse(query.lower())
meta["query"] = str(q)
q = qp.parse(query)
terms = NullQuery
limit = NullQuery
for c in collections:
terms = terms | Term("collection", c)
q = q & terms
limit |= Term("collection", c)
hits = []
ix = get_index()
with ix.searcher() as searcher:
results = searcher.search_page(q, page, pagesize)
results = searcher.search_page(q & limit, page, pagesize)
# if no results, do a prefix search
if results.results.is_empty():
qp.termclass = FuzzyTerm
q = qp.parse(query)
results = searcher.search_page(q & limit, page, pagesize)
for result in results:
hits.append(
dict(
@@ -87,7 +91,7 @@ def search(
collection_id=int(result["collection"]),
)
)
meta["query"] = str(q & limit)
meta["total"] = len(results)
return hits, meta
@@ -0,0 +1,23 @@
from django.core.management.base import BaseCommand
import argparse
import csv
from library import models
class Command(BaseCommand):
help = "Imports works from a csv file"
def add_arguments(self, parser):
parser.add_argument("collection", type=int, help="Collection ID")
parser.add_argument("source", type=argparse.FileType("r"), help="Source CSV")
def handle(self, *args, **options):
collection = models.Collection.objects.get(pk=options["collection"])
reader = csv.DictReader(options["source"])
for row in reader:
collection.works.create(
name=row["Piece"], composer=row["Composer"], notes=row["Notes"]
)
@@ -1,7 +1,7 @@
from django.core.management.base import BaseCommand, CommandError
from django.core.management.base import BaseCommand
from library import models
from library.indexer import model_search, index_works, indexer
from polyphonic.library import models
from polyphonic.library.indexer import model_search, index_works, indexer
FORMATTER = "{w.name:50s} {w.edition:15s} {w.collection.name:15s}"
@@ -0,0 +1,25 @@
from django.core.management.base import BaseCommand, CommandError
from polyphonic.library.models import Work, Collection
from polyphonic.library.gdrive import sync_work, sync_collection
class Command(BaseCommand):
help = "Synchronizes folders and works"
def add_arguments(self, parser):
parser.add_argument("type", type=str, choices=["work", "collection"])
parser.add_argument("pk", nargs="?", type=int)
def handle(self, *args, **options):
if options["type"] == "work":
work = Work.objects.get(pk=options["pk"])
sync_work(work)
return
if options["type"] == "collection":
collection = Collection.objects.get(pk=options["pk"])
sync_collection(collection)
return
raise CommandError("Unknown object type")
@@ -4,7 +4,7 @@ import byostorage.user
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import library.models
import polyphonic.library.models
class Migration(migrations.Migration):
@@ -35,7 +35,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('doctype', models.PositiveSmallIntegerField(choices=[(1, 'PDF'), (2, 'Audio'), (3, 'Video'), (4, 'Source')], default=1)),
('upload', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=library.models.doc_upload_filename)),
('upload', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=polyphonic.library.models.doc_upload_filename)),
('created', models.DateTimeField(auto_now_add=True)),
('version', models.CharField(blank=True, max_length=30)),
],
@@ -3,7 +3,7 @@
import byostorage.cached
import byostorage.user
from django.db import migrations, models
import library.models
import polyphonic.library.models
class Migration(migrations.Migration):
@@ -26,7 +26,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='document',
name='upload',
field=models.FileField(storage=byostorage.cached.CachedStorage(byostorage.user.BYOStorage()), upload_to=library.models.doc_upload_filename),
field=models.FileField(storage=byostorage.cached.CachedStorage(byostorage.user.BYOStorage()), upload_to=polyphonic.library.models.doc_upload_filename),
),
migrations.AlterField(
model_name='work',
+501
View File
@@ -0,0 +1,501 @@
from django.shortcuts import resolve_url
from django.db import models
from django.utils.text import slugify
from django.utils.timezone import now
from django.utils.functional import cached_property
from django.db.models import Count, Min, Max
import re
import os.path
from byostorage.user import BYOStorage
from byostorage.cached import CachedStorage
from polyphonic.library.music_tags import MusicTag, auto_tag
from polyphonic.interface.utils import sign_data
import logging
# from polyphonic.settings import LIBRARY_STORAGE
logger = logging.getLogger(__name__)
# try:
# library_storage = get_storage_class(settings.LIBRARY_STORAGE)()
# except (ImportError, AttributeError):
# logger.exception("Failed to load library storage")
# library_storage = get_storage_class()()
# logger.info("Library storage: %s", library_storage.__class__.__name__)
# FIXME: move back to settings
library_storage = CachedStorage(BYOStorage())
class Orchestration(models.Model):
"""
Stores a list of instrument codes as a single entry (space delimited).
Can be global or ensemble specific
"""
collection = models.ForeignKey(
"Collection",
on_delete=models.CASCADE,
related_name="custom_orchestrations",
null=True,
blank=True,
)
name = models.CharField(max_length=100)
instruments = models.TextField()
def as_list(self):
tags = [t.strip() for t in self.instruments.split(" ")]
return [(t, MusicTag.from_tag(t)) for t in tags if t]
def tag_order(self):
tags = [t.strip() for t in self.instruments.split(" ") if t]
order = {"score": 0}
for i, t in enumerate(tags):
order.setdefault(t.strip("-0123456789"), i * 2 + 1)
return order
def sorter(self):
tag_order = self.tag_order()
def f(x):
return (tag_order.get(x[0].strip("-0123456789"), 1000), x[0])
return f
def save(self):
self.as_list()
super(Orchestration, self).save()
def __str__(self):
return self.name
class ProjectItem(models.Model):
"""
ProjectItem represents a Work attached to a Project e.g. item in set list or programme
It also allows works to be shared from one ensemble to another on a per-project basis.
"""
project = models.ForeignKey(
"interface.Project", on_delete=models.CASCADE, related_name="items"
)
work = models.ForeignKey(
"Work", on_delete=models.CASCADE, related_name="project_items"
)
checkout = models.DateTimeField()
due = models.DateTimeField(null=True, blank=True)
returned = models.DateTimeField(null=True, blank=True)
approved_by = models.ForeignKey("auth.User", on_delete=models.CASCADE)
order = models.SmallIntegerField(default=0)
section = models.CharField(max_length=100, blank=True)
class Meta:
ordering = ["order", "work"]
def __str__(self):
return f"<{self.project_id}:{slugify(self.work.name)}>"
class Collection(models.Model):
"""
A logical collection of works, typically owned by an organisation or person (physical or virtual)
"""
name = models.CharField(max_length=255, help_text="Name of the collection")
prefix = models.CharField(
max_length=255, default="default", help_text="Folder to store works in"
)
administrators = models.ManyToManyField(
"auth.User",
related_name="collections",
help_text="Administrators for this collection",
)
location = models.CharField(
max_length=100, help_text="Physical location (institution, town...)", blank=True
)
storage = models.ForeignKey(
"byostorage.UserStorage",
on_delete=models.CASCADE,
null=True,
blank=True,
help_text="User storage for documents",
)
notes = models.TextField(
blank=True,
help_text="Publicly visible notes about collection and loans policy (markdown format)",
)
settings = models.JSONField(
default=dict, blank=True, help_text="Storage specific settings"
)
nonce = models.SmallIntegerField(
default=1, help_text="Increment this to reset the authentication links"
)
def meta(self, name):
items = (
WorkMeta.objects.filter(work__collection=self.pk, name=name)
.values_list("value", flat=True)
.distinct()
)
return items
@property
def tags(self):
return self.meta("tag")
@property
def genres(self):
return self.meta("genre")
def has_administrator(self, user):
if not user.is_authenticated:
return False
if user.is_superuser:
return True
return user.pk in self.administrators.values_list("pk", flat=True)
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
class EnsembleAccess(models.Model):
"""
Can have different access levels to a collection
"""
ACCESS_UNLIMITED = 1
ACCESS_APPROVED = 2
ACCESS_TYPES = (
(ACCESS_UNLIMITED, "Unlimited"),
(ACCESS_APPROVED, "Approval required"),
)
ensemble = models.ForeignKey(
"interface.Ensemble",
on_delete=models.CASCADE,
related_name="allowed_collections",
)
collection = models.ForeignKey(
Collection, on_delete=models.CASCADE, related_name="allowed_ensembles"
)
access_type = models.PositiveSmallIntegerField(choices=ACCESS_TYPES, default=2)
class Meta:
verbose_name_plural = "Ensemble access"
class Work(models.Model):
"""
A musical work 'owned' by a collection from a licencing perspective.
"""
LICENCE_PUBLIC = 2
LICENCE_EXPIRED = 4
LICENCE_RECORDING = 5
LICENCE_PERFORMANCE = 6
LICENCE_PERUSAL = 8
LICENCE_NONE = 10
LICENCE_TYPES = (
(LICENCE_PUBLIC, "Public Domain"),
(LICENCE_EXPIRED, "Copyright Expired"),
(LICENCE_RECORDING, "Recording Licence"),
(LICENCE_PERFORMANCE, "Performance Licence"),
(LICENCE_PERUSAL, "Perusal Licence"),
(LICENCE_NONE, "Internal use only"),
)
name = models.CharField(max_length=255, help_text="Original name of the work")
edition = models.CharField(
max_length=255,
blank=True,
help_text="Edition details to distinguish multiple versions",
)
parent = models.ForeignKey(
"Work",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="related_works",
help_text="Arrangement of another work or part of an anthology",
)
composer = models.CharField(
max_length=255,
default="Anon",
help_text="Composer or compilation editor. Use <b>Surname, Initial</b> 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. Will be auto-generated if not supplied",
)
licence = models.PositiveSmallIntegerField(
choices=LICENCE_TYPES, default=6, help_text="Copyright status"
)
max_projects = models.IntegerField(
default=1, help_text="How many active projects can this work be attached to"
)
# Extra info
running_time = models.DurationField(
null=True, blank=True, help_text="Running time in mm:ss format"
)
notes = models.TextField(blank=True)
# Allocation to projects
projects = models.ManyToManyField(
"interface.Project",
through="ProjectItem",
related_name="works",
help_text="Current usage",
)
@property
def folder(self):
return f"{slugify(self.composer)}/{slugify(self.name)}-{self.pk:04d}"
def tagged_sections(self, *tags):
qs = self.docs.filter(sections__tag__in=tags)
qs = qs.annotate(
Count("sections"), end=Min("sections__end"), start=Max("sections__start")
).filter(sections__count=len(tags))
return qs
def list_sections(self, *tags):
return list(self.tagged_sections(*tags).values_list("upload", "start", "end"))
@property
def digital_parts(self):
sections = [(s.tag, s.name) for s in Section.objects.filter(doc__work=self.pk)]
sections.sort(key=self.orchestration.sorter())
# return [ s[1] for s in sections ]
sections = list(dict(sections).items()) # primitive unique()
return sections
def pdfs(self):
return self.docs.filter(doctype=Document.DOCTYPE_PDF)
@property
def physical_parts(self):
if not self.original_parts:
return []
parts = list(self.original_parts.items())
parts.sort(key=self.orchestration.sorter())
return [(MusicTag.from_tag(x[0]), x[1]) for x in parts]
@property
def tags(self):
return self.meta_info.filter(name="tag").values_list("value", flat=True)
@property
def meta(self):
return self.meta_info.exclude(name="tag")
@property
def current_loans(self):
return self.project_items.filter(
checkout__lte=now(), returned=None
).select_related("project")
@cached_property
def loans(self):
try:
return self.loan_count
except AttributeError:
return self.project_items.filter(checkout__lte=now(), returned=None).count()
@property
def is_available(self):
if self.max_projects < 0:
return True
return self.max_projects > self.loans
@property
def available(self):
if self.max_projects < 0:
return "Unlimited"
a = self.max_projects - self.loans
return "{0} of {1}".format(max(a, 0), self.max_projects)
@property
def identifier(self):
if self.code:
return self.code
composer = self.composer or "Anon"
composer = re.sub(r"[^\w]", "", composer)
words = self.name.split()
work = words[0][:3]
return f"{composer[:4]}-{work}-{self.pk:05d}".upper()
def assigned_instruments(self):
return Section.objects.filter(doc__work_id=self.pk).values_list(
"tag", flat=True
)
def unassigned_instruments(self):
assigned = set(self.assigned_instruments())
return [x for x in self.orchestration.as_list() if x[0] not in assigned]
def music_tags(self):
tags = dict(self.orchestration.as_list())
# for section in Section.objects.filter(doc__work_id=self.pk):
# tags.setdefault(section.tag, section.name)
return tags.items()
def __str__(self):
return f"{self.name} ({self.composer})"
class WorkMeta(models.Model):
META_CHOICES = (
("tag", "Tag"),
("arr", "Arranger"),
("lyrics", "Lyracist"),
("genre", "Genre"),
("style", "Style"),
("orchestration", "Orchestration"),
("folderid", "GDrive"),
)
work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name="meta_info")
name = models.SlugField(max_length=20, choices=META_CHOICES)
value = models.CharField(max_length=255)
def doc_upload_filename(doc, filename):
collection = doc.work.collection
storage = collection.storage
if not storage:
raise RuntimeError("Collection has no storage attached")
return f"{storage}:library/{collection.prefix}/{doc.work.folder}/{filename}"
class Document(models.Model):
"""
Document represents a single file stored in the storage backend.
"""
DOCTYPE_PDF = 1
DOCTYPE_AUDIO = 2
DOCTYPE_VIDEO = 3
DOCTYPE_MISC = 4
DOCTYPES = (
(DOCTYPE_PDF, "PDF"),
(DOCTYPE_AUDIO, "Audio"),
(DOCTYPE_VIDEO, "Video"),
(DOCTYPE_MISC, "Misc"),
)
DOCTYPE_MAP = {
".pdf": DOCTYPE_PDF,
".mp3": DOCTYPE_AUDIO,
".mp4": DOCTYPE_VIDEO,
}
work = models.ForeignKey("Work", on_delete=models.CASCADE, related_name="docs")
doctype = models.PositiveSmallIntegerField(choices=DOCTYPES, default=DOCTYPE_PDF)
upload = models.FileField(upload_to=doc_upload_filename, storage=library_storage)
created = models.DateTimeField(auto_now_add=True)
version = models.CharField(max_length=30, blank=True)
def auto_tag(self):
if self.doctype != self.DOCTYPE_PDF:
return
filename = os.path.basename(str(self.upload))
inst = auto_tag(filename)
if inst:
self.sections.get_or_create(tag=inst.abbreviate())
def delete(self, *args, **kwargs):
self.upload.delete(save=False)
return super().delete(*args, **kwargs)
def filename(self):
return os.path.basename(self.upload.name)
def __str__(self):
return self.upload.name
class Section(models.Model):
"""
Section is a tagged portion of a Document
"""
PAGE_AUTO = 0
PAGE_LEFT = 1
PAGE_RIGHT = 2
PAGE_PREFERENCE = (
(PAGE_AUTO, "auto"),
(PAGE_LEFT, "left"),
(PAGE_RIGHT, "right"),
)
doc = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="sections")
tag = models.CharField(max_length=50, blank=True)
start = models.SmallIntegerField(null=True, blank=True)
end = models.SmallIntegerField(null=True, blank=True)
page = models.SmallIntegerField(
default=PAGE_AUTO, choices=PAGE_PREFERENCE
) # NOT CURRENTLY USED
class Meta:
ordering = ["doc", "start", "pk"]
@property
def music_tag(self):
return MusicTag.from_tag(self.tag)
@property
def name(self):
return str(self.music_tag)
@property
def bulma_class(self):
return "success" if self.music_tag.is_general else "info"
@property
def filename(self):
return slugify(f"{self.doc.work.name} - {self.name}").title() + ".pdf"
@property
def pagerange(self):
if self.start:
if self.end:
return f"{self.start}-{self.end}"
return str(self.start)
return "all"
def __str__(self):
return self.name

Some files were not shown because too many files have changed in this diff Show More