Compare commits

...

50 Commits

Author SHA1 Message Date
0e69cdeca4 Tweeked deployment (again!) 2026-06-19 14:03:50 +10:00
e797876c16 Merge pull request 'Material Icons' (#17) from material into master
Reviewed-on: #17
2026-06-19 13:58:45 +10:00
1301d19c08 Switched to material icons 2026-06-16 09:18:21 +10:00
ca3effcad1 Fixed my music page 2026-05-29 22:47:33 +10:00
5468f6d3e7 Added handling of gdrive resource_keys - fixes #16 2026-05-27 22:46:08 +10:00
e46d8145a7 Install from git 2026-05-27 09:35:15 +10:00
3444cdbc59 Merge pull request 'gdrive' (#15) from gdrive into master
Reviewed-on: #15
2026-05-24 16:45:13 +10:00
6e77474d15 Couple of fixes 2026-05-24 16:42:51 +10:00
72bad08a61 Merge branch 'master' into gdrive 2026-05-24 14:24:34 +10:00
b217fbea5e Sorted out linting 2026-05-24 14:23:59 +10:00
cec64ecd2f Working with manual sync 2026-05-24 13:51:23 +10:00
d30005d5b6 Indexing bug fix 2026-05-24 11:48:42 +10:00
ef981e06e8 Shared gdrive access 2026-05-24 11:35:18 +10:00
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
78441dc142 Version bump 2026-05-24 11:07:49 +10:00
c96a2ad80b Refactored docker 2026-05-24 11:00:05 +10:00
7e0c77f260 Refactored to polyphonic package 2026-05-24 10:51:43 +10:00
ff17114514 Minor test change 2026-05-24 10:07:05 +10:00
533d6f09e8 Fixed compose launch 2026-05-23 22:10:37 +10:00
eeb35ce4f6 Modify PYTHONPATH 2026-05-23 21:46:51 +10:00
947626c2af Added indexer tests 2026-05-23 11:38:40 +10:00
c1f0e48f80 Another go at sorting the settings mess 2026-05-23 10:00:09 +10:00
ab7d32d46e Renamed to poly-tool 2026-05-23 00:05:29 +10:00
5116247ae8 Simplified settings 2026-05-22 23:51:25 +10:00
93c4926dfd Updated docker build 2026-05-22 14:11:03 +10:00
27d1b03c3c Added fuzzy matching 2026-05-14 11:02:55 +10:00
4e102c07ac Skip migrations 2026-05-13 11:08:03 +10:00
b1ea75cec0 Merge pull request 'GdriveFolder code' (#12) from gdrive into master
Reviewed-on: #12
2026-05-13 09:35:48 +10:00
7d041e1fd0 GdriveFolder code 2026-05-13 09:32:22 +10:00
504c2ee56b Moving makefile to poetry 2026-05-12 23:24:46 +10:00
cfd6d45189 Added linting and got tests passing 2026-05-12 13:26:56 +10:00
ca62ed693a Code cleanup 2026-05-12 11:10:21 +10:00
5e0e165037 Interface cleanup 2026-05-12 11:04:22 +10:00
4164d56dea Library cleanup 2026-05-12 11:03:11 +10:00
75dced77b8 Fixed logout + cleanup 2026-05-11 22:53:31 +10:00
b3675e28af Cleanup 2026-05-11 22:06:05 +10:00
b86c867bd2 Merge pull request 'whoosh_search' (#11) from whoosh_search into master
Reviewed-on: #11
2026-05-11 21:55:52 +10:00
9248692e5d Merge branch 'master' into whoosh_search 2026-05-11 21:51:15 +10:00
406609262d Cleanup 2026-05-11 21:50:57 +10:00
ffb31cc004 Implemented proper search engine 2026-05-11 21:50:21 +10:00
b1ef2b9dac Switch to Django 6.0 2026-04-30 17:09:05 +10:00
5c56d40bf8 Cleanup 2026-04-30 16:01:53 +10:00
a1341d1edc Cleanup 2025-08-29 16:40:36 +10:00
4d964291b2 Fixed poetry deps 2025-08-29 12:51:01 +10:00
ee5305ba6c Merge branch 'master' of gitea.tfconsulting.com.au:projects/polyphonic 2025-08-29 11:39:58 +10:00
147c84550c Switching to poetry 2025-08-29 11:37:57 +10:00
Tris Forster
596445061f Upgraded to Django 5.1 2024-08-26 12:06:18 +10:00
Tris Forster
02858a76c0 Cleanup 2024-08-05 15:49:39 +10:00
Tris Forster
1bcec919cf PEP Cleanup 2024-08-05 12:53:58 +10:00
Tris Forster
78789c02ed Minor UI Tweaks 2024-06-21 15:42:09 +10:00
131 changed files with 3787 additions and 2199 deletions

8
.gitignore vendored
View File

@ -1,10 +1,16 @@
__pycache__ __pycache__
*.pyc *.pyc
*.sqlite3 *.sqlite3
*.swp
credentials.json credentials.json
credentials credentials
local_settings.py local_settings.py
local.mk
.coverage .coverage
.lint
.deploy
Session.vim
poetry.lock
/env /env
/data /data
/old /old
@ -13,3 +19,5 @@ local_settings.py
/cache /cache
/local_storage /local_storage
/media /media
/index
/dist

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 WORKDIR /root
RUN python3 -m ensurepip
RUN pip3 install -U pip --no-cache-dir
COPY app/requirements.txt . RUN python3 -m venv ${TARGET}
RUN pip3 install -r requirements.txt --no-cache-dir ENV PATH="${TARGET}/bin:$PATH"
COPY app /opt/polyphonic COPY dist/${RELEASE} .
WORKDIR /opt/polyphonic RUN pip3 install ${RELEASE} --no-cache-dir
RUN pip3 install gunicorn whitenoise
COPY docker_settings.py polyphonic/local_settings.py WORKDIR ${TARGET}
RUN SECRET_KEY=_ python3 manage.py collectstatic --noinput
RUN SECRET_KEY=_ poly-tool collectstatic --noinput
VOLUME ["/var/polyphonic"] VOLUME ["/var/polyphonic"]
EXPOSE 8000/tcp
ENTRYPOINT ["python3", "manage.py"] CMD ["gunicorn", "-b", "0.0.0.0", "polyphonic.config.wsgi"]
CMD ["runserver", "0.0.0.0:8000", "--insecure"]

View File

@ -1,20 +1,42 @@
PYTHON=env/bin/python PYTHON=env/bin/python
DROPZONE=5.7.0 DROPZONE=5.7.0
test: VERSION=0.8.4
coverage run --include "app/*" --omit "*/migrations/*" app/manage.py test app
coverage html export DJANGO_SETTINGS_MODULE=polyphonic.config.settings.dev
coverage report
-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: dev-setup:
env/bin/pip install -r requirements.txt poetry install --with=dev
env/bin/pip install -r dev-requirements.txt poetry run manage migrate
${PYTHON} manage.py migrate poetry run manage createsuperuser --username admin --email admin@localhost
${PYTHON} manage.py createsuperuser --username admin --email admin@localhost
upgrade: upgrade:
${PYTHON} manage.py migrate poetry run manage migrate
${PYTHON} manage.py collectstatic poetry run manage collectstatic
${MAKE} libraries ${MAKE} libraries
libraries: static/dropzone static/fonts/Quicksand_Book.otf libraries: static/dropzone static/fonts/Quicksand_Book.otf

View File

@ -6,20 +6,23 @@ No registration required for ensemble participants - just a one time code and pa
### Library App ### Library App
Store all your scores on your own cloud account (Amazon S3, Google Files etc). Tag up the scores so you can generate Store all your scores on your own cloud account (Amazon S3, Google Files etc).
custom part sets and assign them to projects so people can easily print just their parts. Tag up the scores so you can generate custom part sets and assign them to
projects so people can easily print just their parts.
### Submissions App ### Submissions App
Accept video/audio submissions direct to your cloud storage. Was developed and used during 2020 lockdown period for Accept video/audio submissions direct to your cloud storage. Was developed and
virtual choirs/orchestras but could have more uses. used during 2020 lockdown period for virtual choirs/orchestras but could have more uses.
### S3 Setup ### S3 Setup
#### Bucket setup [virtual-orchestra] #### Bucket setup [virtual-orchestra]
Default block public access Default block public access
Permissions -> CORS Permissions -> CORS
```xml ```xml
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> <CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
@ -40,6 +43,7 @@ Permissions -> CORS
User User
Create with programatic access (copy keys) and an inline policy for the bucket. Create with programatic access (copy keys) and an inline policy for the bucket.
```json ```json
{ {
"Version": "2012-10-17", "Version": "2012-10-17",
@ -64,3 +68,4 @@ Create with programatic access (copy keys) and an inline policy for the bucket.
``` ```
3. 3.

19
TODO.md Normal file
View File

@ -0,0 +1,19 @@
## Polyphonic TODO
## Core interface
* Shift from crispy forms to native component templates
* Make long running calls async (Django 5)
* Deprecate Django 4 portions
### Library App
* Remove music tags and replace with strings vn1 -> 'Violin 1'
* GDrive selector
* Move upload to modal from 'Upload' button
* Tagging app - migrate to AlpineJS
* Allow other tags (movements, sections, pieces)
### Submissions App
* None currently pending

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

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)

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

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'}))

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

View File

@ -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'])

View File

@ -1,408 +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.core.files.storage import get_storage_class
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.SlugField(max_length=30, 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)")
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

View File

@ -1,65 +0,0 @@
{% extends "interface/project_base.html" %}
{% load url_tools %}
{% block admin %}
{% if collection %}
<a href="{% url 'work_add' collection.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add a work</span>
</a>
{% endif %}
{% endblock %}
{% block page %}
<h3 class="title">{{ title }}</h3>
<form method="GET">
<div class="field has-addons">
<div class="control is-expanded">
<input class="input" name="filter" type="text" placeholder="Filter" value="{{ request.GET.filter }}"/>
</div>
<div class="control">
<a class="button" href="?"><i class="fas fa-times"></i></a>
</div>
</div>
</form>
<table class="table is-striped is-fullwidth">
<thead>
<tr>
<th>Work</th>
<th>Composer</th>
<th class="is-hidden-mobile">Edition</th>
{% if not collection %}<th class="is-hidden-touch">Collection</th>{% endif %}
</tr>
</thead>
<tbody>
{% for work in object_list %}
<tr>
<td><a href="{% url 'work_detail' collection=work.collection.pk pk=work.pk %}">{{ work.name }}</a></td>
<td title="{{ work.composer }}">{{ work.composer|truncatewords:3 }}</td>
<td class="is-hidden-mobile" title="{{ work.edition }}">{{ work.edition|truncatewords:2 }}</td>
{% if not collection %}<td class="is-hidden-touch">{{ work.collection.name }}</td>{% endif %}
</tr>
{% empty %}
<tr><td colspan="4">No works found</td></tr>
{% endfor %}
</tbody>
</table>
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
{% if page_obj.has_previous %}
<a class="pagination-previous" href="{% url_update page=page_obj.prev_page_number %}">Previous</a>
{% endif %}
{% if page_obj.has_next %}
<a class="pagination-next" href="{% url_update page=page_obj.next_page_number %}">Next page</a>
{% endif %}
<ul class="pagination-list">
{% for page in page_obj.paginator.page_range %}
<li>
<a class="pagination-link {% if forloop.counter == page_obj.number %}is-current{% endif %}" href="{% url_update page=forloop.counter %}" aria-label="Goto page {{ forloop.counter }}">{{ forloop.counter }}</a>
</li>
{% endfor %}
</ul>
</nav>
{% endblock %}

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'), [])

View File

@ -1,44 +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('api/', include(router.urls))
path('api/collections/<int:pk>', api.CollectionExportView.as_view(), name="collection_export"),
path('api/collections/<int:collection>/works/<int:pk>', api.WorkExportView.as_view(), name="work_export"),
path('api/collections/<int:collection>/import', api.WorkImportView.as_view(), name="work_import"),
path('api/collections/<int:collection>/bulk_import', api.CollectionImportView.as_view(), name="collection_import"),
]

View File

@ -1,482 +0,0 @@
from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.views.generic.detail import DetailView, SingleObjectMixin, View
from django.views.generic.list import ListView, MultipleObjectMixin
from django.views.generic.edit import CreateView, FormView, UpdateView, DeleteView
from django.http import FileResponse, HttpResponse, JsonResponse
from django.db import IntegrityError
from django.db.models import Q, Count, Sum
from django.db import transaction
from django.utils.timezone import now
from django.urls import reverse
from django.template.loader import render_to_string
from django.core.exceptions import SuspiciousOperation
from django.http import Http404, HttpResponseRedirect
import json
import os.path
import re
from interface.views import EnsembleMixin, ProjectMixin, AuthorizedResourceMixin
from interface.models import Project
from library.models import Collection, Work, Document, Section
from library.music_tags import MUSIC_TAGS, MusicTag, auto_tag
from library import forms, models
from library.pdf_utils import extract_pages, extract_and_concat
import logging
logger = logging.getLogger(__name__)
class ProjectItemListView(ProjectMixin, ListView):
template_name = "library/item_list.html"
model = models.ProjectItem
def post(self, request, **kwargs):
project_works = self.project.works.all()
instruments = request.POST.getlist('instruments')
works = request.POST.getlist('works')
self.request.session['part'] = request.POST.get('part', '')
self.request.session['instrument'] = request.POST.get('instrument')
valid_pks = [ x.pk for x in project_works ]
sections = []
for i, pk in enumerate(works):
if int(pk) not in valid_pks:
raise Exception(f"Not a valid work pk: {pk}")
tag = instruments[i]
if tag == '-':
continue
part = Section.objects.filter(tag=tag, doc__work=pk).select_related('doc').get()
sections.append((part.doc.upload.path, part.doc.work.name, part.start, part.end, 1))
result = extract_and_concat(sections)
download_name = f'{self.project.name}.pdf'
response = FileResponse(result, content_type="application/pdf")
response['Content-Disposition'] = f'inline; filename="{download_name}"'
return response
def get_queryset(self):
return super(ProjectItemListView, self).get_queryset().select_related('project', 'work')
def get_context_data(self, **kwargs):
data = super(ProjectItemListView, self).get_context_data(**kwargs)
data['instruments'] = MUSIC_TAGS
data['instrument'] = self.request.session.get('instrument', 'Score')
data['part'] = self.request.session.get('part', '0')
data['running_time'] = self.get_queryset().aggregate(Sum('work__running_time'))['work__running_time__sum']
return data
class ProjectItemManageView(ProjectMixin, ListView):
template_name = "library/item_list_manage.html"
model = models.ProjectItem
def post(self, request, **kwargs):
self.request = request
self.kwargs = kwargs
data = json.loads(request.body)
q = self.get_queryset()
for pk, order in data.items():
order = int(order)
if order == -1:
q.filter(pk=pk).delete()
else:
i = q.filter(pk=pk).update(order=order)
return HttpResponse(status=204)
def get_queryset(self):
return super(ProjectItemManageView, self).get_queryset().select_related('project', 'work')
class ProjectItemAddView(ProjectMixin, UpdateView):
form_class = forms.PlaylistAddForm
template_name = "interface/default_form.html"
def get_success_url(self):
return resolve_url('item_list_manage', project=self.kwargs['project'])
def get_object(self):
return self.get_project()
""" COLLECTION VIEWS """
class CollectionMixin(AuthorizedResourceMixin):
collection = None
def is_authorized(self):
collection_id = self.kwargs['collection']
self.collection = get_object_or_404(models.Collection, pk=collection_id)
if super().is_authorized():
return True
if self.collection.has_administrator(self.request.user):
self.request.is_admin = True
return True
if self.is_authorized_key('collection', collection_id, self.collection.nonce):
return True
return False
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
if self.collection:
data['collection'] = self.collection
return data
def get_queryset(self):
return super().get_queryset().filter(collection=self.collection)
class CollectionListView(ListView):
paginate_by = 20
def get_queryset(self):
collections = models.Collection.objects.order_by('name')
if self.request.user.is_anonymous:
return models.Collection.objects.none()
if self.request.user.is_staff:
return collections
return collections.filter(Q(administrators=self.request.user) | Q(allowed_ensembles__ensemble__admins=self.request.user))
class WorkListView(CollectionMixin, ListView):
paginate_by = 20
def get_context_data(self, *args, **kwargs):
data = super(WorkListView, self).get_context_data(*args, **kwargs)
#data['title'] = f'Music available to {self.ensemble.name}'
data['title'] = "My Library"
return data
def get_queryset(self):
works = self.get_works()
q = self.request.GET.get('filter')
if q:
if ":" in q:
name, _, value = q.partition(":")
works = works.filter(meta_info__name=name, meta_info__value__contains=value)
else:
works = works.filter(Q(name__contains=q) | Q(composer__contains=q) | Q(meta_info__value__contains=q))
return works.order_by('name', 'composer', 'edition', 'pk').distinct()
class LibraryWorkListView(WorkListView):
def is_authorized(self):
return True
def get_works(self):
collections = models.Collection.objects.all()
if not self.request.user.is_superuser:
collections = collections.filter(administrators=self.request.user)
return Work.objects.filter(collection__in=collections).select_related('collection')
class CollectionWorkListView(WorkListView):
def request_denied(self):
if 'auth' in self.request.GET:
if self.request.GET['auth'] != self.collection.auth():
raise SuspiciousOperation("Bad collection link")
self.add_authorized_key('collection', self.collection.pk, self.collection.nonce)
return HttpResponseRedirect(self.request.path)
return super().request_denied()
def get_works(self):
works = Work.objects.filter(collection=self.kwargs['collection'])
#if self.request.is_admin:
# loan_count = Count('project_items', Q(project_items__checkout__lte=now(), project_items__returned=None))
# works = works.annotate(loan_count=loan_count)
return works
def get_context_data(self, *args, **kwargs):
data = super(CollectionWorkListView, self).get_context_data(*args, **kwargs)
data['title'] = self.collection.name
return data
class WorkAddView(CollectionMixin, FormView):
template_name = "interface/default_form.html"
form_class = forms.WorkCreateForm
title = "Add a new work"
def get_form(self, form_class=None):
form = super().get_form(form_class)
qs = models.Orchestration.objects.filter(Q(collection=None) | Q(collection=self.collection))
form.fields['orchestration'].queryset = qs.order_by('-collection_id', 'pk')
return form
def form_valid(self, form):
work = form.save(commit=False)
#work.ensemble_id = self.request.ensemble_id
work.collection_id = self.collection.pk
work.save()
# handle the files
uploads = self.request.FILES.getlist('uploads')
docs = []
for f in uploads:
docs.append(work.docs.create(upload=f).pk)
if len(docs) == 1:
return redirect('document_annotate', docs[0])
else:
return redirect('work_detail', collection=self.collection.pk, pk=work.pk)
class WorkDetailView(CollectionMixin, DetailView):
model = models.Work
class WorkUpdateView(CollectionMixin, UpdateView):
model = models.Work
form_class = forms.WorkCreateForm
template_name = 'interface/default_form.html'
def get_success_url(self):
return resolve_url('work_detail', self.collection.pk, self.kwargs['pk'])
class WorkAddToProject(CollectionMixin, FormView):
admin_required = True
form_class = forms.ProjectSelectForm
template_name = "interface/default_form.html"
title = "Select project to add work to"
def get_object(self):
return Work.objects.get(pk=self.kwargs['pk'])
def get_form(self):
f = super(WorkAddToProject, self).get_form()
qs = f.fields['project'].queryset.select_related('ensemble')
# Limit to projects for ensembles where we are an admin and they haven't occured yet
qs = qs.for_user(self.request.user).current()
# dont show projects already added to
work = self.get_object()
qs = qs.exclude(pk__in=work.projects.all())
f.fields['project'].queryset = qs.order_by('ensemble__name', 'name')
return f
def form_valid(self, form):
work = self.get_object()
project = form.cleaned_data['project']
work.project_items.create(project=project, approved_by=self.request.user, checkout=now())
return redirect('item_list', project=project.pk)
class WorkPartsView(CollectionMixin, DetailView):
model = models.Work
template_name = "library/work_parts_fragment.html"
class WorkPartSetView(CollectionMixin, DetailView):
template_name = "library/work_partset.html"
def post(self, request, *args, **kwargs):
work = self.get_object()
parts = request.POST.getlist('parts')
copies = request.POST.getlist('copies')
sections = []
for i, tag in enumerate(parts):
c = int(copies[i])
if c > 0:
for part in models.Section.objects.select_related('doc').filter(tag=tag, doc__work=work):
sections.append((part.doc.upload.path, part.name, part.start, part.end, c))
result = extract_and_concat(sections)
download_name = f'{work.name}.pdf'
response = FileResponse(result, content_type="application/pdf")
response['Content-Disposition'] = f'inline; filename="{download_name}"'
return response
def get_queryset(self):
works = Work.objects.all()
if not self.request.is_admin:
works = works.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id)
return works
class WorkDownloadView(CollectionMixin, SingleObjectMixin, View):
model = models.Work
def get(self, request, *args, **kwargs):
self.object = self.get_object()
tags = request.GET.getlist('tag')
if not tags:
raise Http404("No tags given")
sections = list(self.object.tagged_sections(*tags))
print(sections)
if len(sections) == 0:
raise Http404("No matching sections")
if len(sections) == 1 and sections[0].start == 0:
# bypass extraction and redirect to the url
logger.debug("Redirecting to url")
return redirect(sections[0].upload.url)
result = extract_and_concat([ (s.upload.path, s.upload.name, s.start, s.end, 1) for s in sections ])
tag_names = " - ".join([ str(MusicTag.from_tag(tag)) for tag in tags ])
download_name = f'{self.object.name} - {tag_names}.pdf'
response = FileResponse(result, content_type="application/pdf")
response['Content-Disposition'] = f'inline; filename="{download_name}"'
return response
class WorkAddDocumentView(CollectionMixin, CreateView):
template_name = "interface/default_form.html"
model = Document
fields = ['upload']
def title(self):
work = Work.objects.get(pk=self.kwargs['pk'])
return f"Add a document to {work.name}"
def form_invalid(self, form):
if self.request.headers['Accept'] == 'application/json':
return HttpResponse(status=400)
return super().form_invalid(form)
def form_valid(self, form):
orig_name, ext = os.path.splitext(form.cleaned_data['upload'].name)
logger.info("Uploaded: %s", orig_name)
doc = form.save(commit=False)
doc.doctype = models.Document.DOCTYPE_MAP.get(ext.lower(), models.Document.DOCTYPE_MISC)
doc.work_id = self.kwargs['pk']
doc.save()
# auto tag the document
#name, ext = os.path.splitext(os.path.basename(doc.upload.name))
if doc.doctype == models.Document.DOCTYPE_PDF:
inst = auto_tag(orig_name)
if inst:
doc.sections.create(tag=inst.abbreviate())
if self.request.headers['Accept'] == 'application/json':
filename = os.path.basename(doc.upload.name)
return JsonResponse({
"message": "created",
"id": doc.pk,
"entry": render_to_string('library/document_entry.html',
{'collection': self.collection, 'doc': doc, 'request': self.request}
)
}, status=201)
return redirect('document_annotate', self.collection.pk, doc.pk)
class DocumentMixin(CollectionMixin):
model = models.Document
def get_queryset(self):
qs = models.Document.objects.select_related('work')
if self.request.is_admin:
return qs
return qs.filter(work__collection=self.collection)
class DocumentDetailView(DocumentMixin, DetailView):
pass
class DocumentDownloadView(DocumentMixin, SingleObjectMixin, View):
def get(self, request, **args):
self.request = request
self.args = args
self.object = self.get_object()
#response = FileResponse(self.object.upload, content_type="application/pdf")
#return response
return redirect(self.object.upload.url)
class DocumentAnnotateView(DocumentMixin, DetailView):
template_name = 'library/document_annotate.html'
def post(self, request, **args):
self.request = request
self.args = args
self.object = self.get_object()
data = json.loads(request.body)
with transaction.atomic():
self.object.sections.all().delete()
for tag, start, end in data:
#pages.sort()
#end = pages[-1] if len(pages) > 1 else None
o = self.object.sections.create(tag=tag, start=start, end=end)
return HttpResponse(status=204)
def get_context_data(self, **kwargs):
data = super(DocumentAnnotateView, self).get_context_data(**kwargs)
pages = []
for part in data['document'].sections.all():
pages.append((part.tag, part.start, part.end))
data['json_data'] = {'pageTags': pages, 'instruments': dict(MUSIC_TAGS)}
return data
class DocumentDeleteView(DocumentMixin, DeleteView):
#def get_template_names(self):
# return ["interface/default_form.html"]
def get_success_url(self):
return resolve_url('work_detail', self.collection.pk, self.object.work_id)
class PartDownloadView(CollectionMixin, SingleObjectMixin, View):
pk_url_kwarg = 'section'
def get(self, request, **args):
self.request = request
self.args = args
self.object = self.get_object()
if self.object.start is None:
return redirect(self.object.doc.upload.url)
result = extract_pages(self.object.doc.upload.path, self.object.doc.work.name, self.object.start, self.object.end)
#download_name = f'{self.object.doc.work.name}_{self.object.instrument}.pdf'
response = FileResponse(result, content_type="application/pdf")
response['Content-Disposition'] = f'inline; filename="{self.args["filename"]}"'
return response
def get_object(self):
return Section.objects.filter(doc__work__collection=self.collection).select_related('doc', 'doc__work').get(pk=self.kwargs['section'])

View File

@ -1,138 +0,0 @@
"""
Django settings for polyphonic project.
Generated by 'django-admin startproject' using Django 3.1.1.
For more information on this file, see
https://docs.djangoproject.com/en/3.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
from pathlib import Path
from os import environ
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY=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']
# Application definition
POLYPHONIC_MODULES = [
'library'
]
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django_markdown2',
'rest_framework',
'crispy_forms',
'crispy_bulma',
'byostorage',
'interface',
]
CRISPY_ALLOWED_TEMPLATE_PACKS = ("bulma",)
CRISPY_TEMPLATE_PACK = "bulma"
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'polyphonic.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'polyphonic.wsgi.application'
# Database
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
# Password validation
# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
LOGIN_REDIRECT_URL = "/"
# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
# Localisation (localization?)
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 = ''

View File

@ -1,7 +0,0 @@
try:
from .local_settings import *
except ImportError:
from .default_settings import *
INSTALLED_APPS += POLYPHONIC_MODULES

14
docker-compose.yml Normal file
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

View File

@ -1,8 +0,0 @@
from .default_settings import *
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': '/var/polyphonic/db.sqlite3',
}
}

View File

@ -11,6 +11,6 @@ import os
from django.core.asgi import get_asgi_application 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() application = get_asgi_application()

View File

@ -0,0 +1,142 @@
"""
Django settings for polyphonic project.
Generated by 'django-admin startproject' using Django 3.1.1.
For more information on this file, see
https://docs.djangoproject.com/en/3.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
from pathlib import Path
import os
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent
# 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", "127.0.0.1"]
# Application definition
POLYPHONIC_MODULES = ["polyphonic.library"]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django_markdown2",
"rest_framework",
"crispy_forms",
"crispy_bulma",
"byostorage",
"polyphonic.interface",
]
INSTALLED_APPS += POLYPHONIC_MODULES
CRISPY_ALLOWED_TEMPLATE_PACKS = ("bulma",)
CRISPY_TEMPLATE_PACK = "bulma"
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "polyphonic.config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "polyphonic.config.wsgi.application"
# Database
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(WORK_DIR, "db.sqlite3"),
}
}
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Password validation
# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
LOGIN_REDIRECT_URL = "/"
# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
# Localisation (localization?)
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"
# 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"]

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"),
}
},
}

View File

@ -0,0 +1,4 @@
from .base import * # noqa
# Enable WhiteNoise for static files
MIDDLEWARE.insert(1, "whitenoise.middleware.WhiteNoiseMiddleware") # noqa

View File

@ -13,18 +13,20 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.contrib import admin from django.contrib import admin
from django.urls import path, re_path, include from django.urls import path, include
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path("admin/", admin.site.urls),
path('', include('interface.urls')), path("", include("polyphonic.interface.urls")),
#path('', include('submissions.urls')), # path('', include('submissions.urls')),
path('', include('library.urls')), path("", include("polyphonic.library.urls")),
] ]
try: try:
import debug_toolbar import debug_toolbar
urlpatterns.append(path('__debug__', include(debug_toolbar.urls)))
urlpatterns.append(path("__debug__", include(debug_toolbar.urls)))
except ImportError: except ImportError:
pass pass

View File

@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application 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() application = get_wsgi_application()

View File

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

View File

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

View File

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

View File

@ -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()

View File

@ -4,7 +4,7 @@ import byostorage.user
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import interface.models import polyphonic.interface.models
class Migration(migrations.Migration): 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')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Display name', max_length=100)), ('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)), ('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)), ('passphrase', models.CharField(help_text='Used to register ensembles', max_length=100)),
('details', models.TextField(blank=True, help_text='Description of the ensemble (markdown)')), ('details', models.TextField(blank=True, help_text='Description of the ensemble (markdown)')),
('admins', models.ManyToManyField(related_name='ensembles', to=settings.AUTH_USER_MODEL)), ('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')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)), ('name', models.CharField(max_length=100)),
('description', models.TextField(blank=True)), ('description', models.TextField(blank=True)),
('file', models.FileField(storage=byostorage.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)), ('media_type', models.CharField(choices=[('audio', 'Audio'), ('video', 'Video'), ('general', 'General')], default='*', max_length=10)),
('visible', models.BooleanField(default=True)), ('visible', models.BooleanField(default=True)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='resources', to='interface.project')), ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='resources', to='interface.project')),

View File

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

View File

Before

Width:  |  Height:  |  Size: 426 KiB

After

Width:  |  Height:  |  Size: 426 KiB

View File

Before

Width:  |  Height:  |  Size: 258 B

After

Width:  |  Height:  |  Size: 258 B

View File

@ -11,7 +11,8 @@
<script src="{% static 'interface/js/interface.js' %}"></script> <script src="{% static 'interface/js/interface.js' %}"></script>
<script src="//unpkg.com/alpinejs" defer></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="//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> <title>{% block title %}Polyphonic{% endblock %}</title>
{% block media %}{% endblock %} {% block media %}{% endblock %}
<style>{% block style %}{% endblock %}</style> <style>{% block style %}{% endblock %}</style>
@ -23,7 +24,7 @@
<nav class="navbar" role="navigation"> <nav class="navbar" role="navigation">
<div class="navbar-brand has-text-primary"> <div class="navbar-brand has-text-primary">
<a class="navbar-item" href="/"> <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> <span class="fancy is-size-2 is-size-4-mobile">Polyphonic</span>
</a> </a>
<span class="navbar-item is-hidden-mobile fancy is-size-5">Musical Ensemble Manager</span> <span class="navbar-item is-hidden-mobile fancy is-size-5">Musical Ensemble Manager</span>

View File

@ -6,7 +6,7 @@
{% crispy_field field 'class' 'file-input'%} {% crispy_field field 'class' 'file-input'%}
<span class="file-cta"> <span class="file-cta">
<span class="file-icon"> <span class="file-icon">
<i class="fas fa-upload"></i> <span class="material-symbols-outlined">file_upload</span>
</span> </span>
<span class="file-label"> <span class="file-label">
Choose a file… Choose a file…

View File

@ -1,19 +1,20 @@
{% extends "interface/project_base.html" %} {% extends "interface/project_base.html" %}
{% load md2 %} {% load md2 %}
{% load polyphonic %}
{% block admin %} {% block admin %}
<a href="{% url 'project_create' object.slug %}" class="button is-link"> <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> <span>Add project</span>
</a> </a>
{% if inactive %} {% if inactive %}
<a href="?" class="button is-link"> <a href="?" class="button is-link">
<span class="icon"><i class="fas fa-archive"></i></span> {{ "preview_off"|icon }}
<span>Hide old</span> <span>Hide old</span>
</a> </a>
{% else %} {% else %}
<a href="?inactive" class="button is-link"> <a href="?inactive" class="button is-link">
<span class="icon"><i class="fas fa-archive"></i></span> {{ "preview"|icon }}
<span>Show all</span> <span>Show all</span>
</a> </a>
{% endif %} {% endif %}

View File

@ -5,7 +5,7 @@
{% comment %} {% comment %}
<div class="admin-tools is-pulled-right"> <div class="admin-tools is-pulled-right">
<a class="button is-link" href="{% url 'register' %}"> <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> <span>Register another</span>
</a> </a>
</div> </div>
@ -25,10 +25,13 @@
<img src="https://www.gravatar.com/avatar/{{ ensemble.email }}?d=mp" alt="Placeholder image"> <img src="https://www.gravatar.com/avatar/{{ ensemble.email }}?d=mp" alt="Placeholder image">
</figure> </figure>
</div> </div>
<div class="media-content" style="min-height: 60px"> <div class="media-content" style="min-height: 100px">
<a href="{% url 'ensemble_detail' ensemble.slug %}"> <a href="{% url 'ensemble_detail' ensemble.slug %}">
<p class="title is-4">{{ ensemble.name }}</p> <p class="title is-4">{{ ensemble.name }}</p>
</a> </a>
<div class="mt-3">
{{ ensemble.details|markdown }}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -4,11 +4,11 @@
{% block admin %} {% block admin %}
<a href="{% url 'wiki_create' project=project.pk %}" class="button is-link"> <a href="{% url 'wiki_create' project=project.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-file"></i></span> {{ "add_notes"|icon }}
<span>Add Page</span> <span>Add Page</span>
</a> </a>
<a href="{% url 'project_edit' project=project.pk %}" class="button is-link"> <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> <span>Edit</span>
</a> </a>
{% endblock %} {% endblock %}

View File

@ -1,9 +1,10 @@
{% extends "interface/project_base.html" %} {% extends "interface/project_base.html" %}
{% load md2 %} {% load md2 %}
{% load polyphonic %}
{% block admin %} {% block admin %}
<a class="button is-link" href="{% url 'resource_create' project=project.pk %}"> <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> <span>Add new</span>
</a> </a>
{% endblock %} {% endblock %}
@ -27,11 +28,11 @@
<div class="card-header-icon"> <div class="card-header-icon">
{% if request.is_admin %} {% if request.is_admin %}
<a href="{% url 'resource_upload' project=project.pk pk=resource.pk %}" class="icon" title="Upload"> <a href="{% url 'resource_upload' project=project.pk pk=resource.pk %}" title="Upload">
<i class="fas fa-upload"></i> {% icon "upload_file" %}
</a> </a>
<a href="{% url 'resource_edit' project=project.pk pk=resource.pk %}" class="icon" title="Edit"> <a href="{% url 'resource_edit' project=project.pk pk=resource.pk %}" title="Edit">
<i class="fas fa-edit"></i> {% icon "edit" %}
</a> </a>
{% endif %} {% endif %}
</div> </div>

View File

@ -1,8 +1,9 @@
{% extends "interface/project_base.html" %} {% extends "interface/project_base.html" %}
{% load polyphonic %}
{% block admin %} {% block admin %}
<a href="{% url 'wiki_edit' project=project.pk pk=wikipage.pk %}" class="button is-link"> <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> {{ "edit"|icon }}
<span>Edit</span> <span>Edit</span>
</a> </a>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

@ -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})

View File

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

View File

@ -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"})
)

View File

@ -1,18 +1,20 @@
from django.shortcuts import resolve_url from django.shortcuts import resolve_url
from django.core.signing import Signer from django.core.signing import Signer
from django.core.exceptions import SuspiciousOperation from django.core.exceptions import SuspiciousOperation
import logging
signer = Signer() signer = Signer()
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def sign_data(data, l=None):
def sign_data(data, length=None):
sig = signer.sign(data) sig = signer.sign(data)
p = len(data) + 1 pos = len(data) + 1
if l: if length:
l += p length += pos
return sig[p:l] return sig[pos:length]
def signed_url(name, **kwargs): def signed_url(name, **kwargs):
""" """
@ -23,9 +25,10 @@ def signed_url(name, **kwargs):
sep = "&" if "?" in url else "?" sep = "&" if "?" in url else "?"
return sig.replace(":", f"{sep}auth=") return sig.replace(":", f"{sep}auth=")
def check_signed_url(full_path): def check_signed_url(full_path):
p = full_path.rfind('auth') p = full_path.rfind("auth")
url = full_path[:p-1] url = full_path[: p - 1]
logger.debug("check_signed_url: %s", url) logger.debug("check_signed_url: %s", url)
signed = signed_url(url) signed = signed_url(url)
if signed != full_path: if signed != full_path:
@ -33,6 +36,8 @@ def check_signed_url(full_path):
signed = "_HIDDEN_" signed = "_HIDDEN_"
raise SuspiciousOperation("Bad auth code") raise SuspiciousOperation("Bad auth code")
if __name__ == '__main__':
if __name__ == "__main__":
import doctest import doctest
print(doctest.testmod()) print(doctest.testmod())

View File

@ -1,26 +1,27 @@
""" """ """
"""
# pyright: basic
from django.shortcuts import get_object_or_404, redirect, resolve_url from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.views.generic import RedirectView from django.views.generic import RedirectView
from django.views.generic.detail import DetailView, SingleObjectMixin from django.views.generic.detail import DetailView, SingleObjectMixin
from django.views.generic.list import ListView from django.views.generic.list import ListView
from django.views.generic.edit import CreateView, UpdateView, FormMixin from django.views.generic.edit import CreateView, UpdateView
from django.core.exceptions import SuspiciousOperation from django.core.exceptions import SuspiciousOperation
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
from django.db.models import Q from django.http.request import HttpRequest
from django.utils import timezone from django.contrib.auth import logout
from markdown2 import markdown from markdown2 import markdown
from . import models, forms from . import models, forms
from interface.utils import check_signed_url from .utils import check_signed_url
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class AuthorizedResourceMixin(object): class AuthorizedResourceMixin(object):
""" """
Handles these parts of the permission system: Handles these parts of the permission system:
@ -29,16 +30,20 @@ class AuthorizedResourceMixin(object):
* Admin enforcing * Admin enforcing
""" """
SESSION_KEY = 'authorized' request: HttpRequest
kwargs: dict
_authorized: dict
SESSION_KEY = "authorized"
admin_required = False admin_required = False
def is_authorized(self): def is_authorized(self):
"By default check if superuser or a signed request" "By default check if superuser or a signed request"
if self.request.is_admin: if self.request.is_admin: # type: ignore
#logger.debug("is_authorized: superuser") # logger.debug("is_authorized: superuser")
return True return True
if 'sig' in self.request.GET: if "sig" in self.request.GET:
check_signed_url(self.request.get_full_path()) check_signed_url(self.request.get_full_path())
self.on_signed_request() self.on_signed_request()
return True return True
@ -51,25 +56,25 @@ class AuthorizedResourceMixin(object):
def is_authorized_key(self, resource, key, auth): def is_authorized_key(self, resource, key, auth):
current = self.get_authorized_keys(resource).get(str(key), None) current = self.get_authorized_keys(resource).get(str(key), None)
if current is None: if current is None:
#logger.debug("is_authorized_key: %s %s not in session", resource, key) # logger.debug("is_authorized_key: %s %s not in session", resource, key)
return False return False
if auth == current: if auth == current:
return True return True
#logger.info("Authorisation revoked") # logger.info("Authorisation revoked")
self.del_authorized_key(resource, key) self.del_authorized_key(resource, key)
return False return False
def get_authorized_keys(self, resource): def get_authorized_keys(self, resource):
'Returns a set of authorized keys for this resource' "Returns a set of authorized keys for this resource"
return self._authorized.get(resource, {}) return self._authorized.get(resource, {})
def add_authorized_key(self, resource, key, auth): def add_authorized_key(self, resource, key, auth):
'Adds a key to the authorized list for this resource' "Adds a key to the authorized list for this resource"
current = self.get_authorized_keys(resource) current = self.get_authorized_keys(resource)
current[str(key)] = auth current[str(key)] = auth
self._authorized[resource] = current self._authorized[resource] = current
self.request.session[self.SESSION_KEY] = self._authorized self.request.session[self.SESSION_KEY] = self._authorized # type: ignore
def del_authorized_key(self, resource, key): def del_authorized_key(self, resource, key):
logger.info("Revoking authorization for %s %s", resource, key) logger.info("Revoking authorization for %s %s", resource, key)
@ -80,15 +85,16 @@ class AuthorizedResourceMixin(object):
self._authorized[resource] = current self._authorized[resource] = current
else: else:
self._authorized.pop(resource) self._authorized.pop(resource)
self.request.session[self.SESSION_KEY] = self._authorized self.request.session[self.SESSION_KEY] = self._authorized # type: ignore
return True return True
def request_denied(self): def request_denied(self):
raise Http404("Either the given resource doesn't exist or you dont have access to it.") raise Http404(
"Either the given resource doesn't exist or you dont have access to it."
)
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
self._authorized = request.session.get("authorized", {})
self._authorized = request.session.get('authorized', {})
request.is_admin = request.user.is_superuser request.is_admin = request.user.is_superuser
if not self.is_authorized(): if not self.is_authorized():
@ -97,12 +103,18 @@ class AuthorizedResourceMixin(object):
if self.admin_required and not request.is_admin: if self.admin_required and not request.is_admin:
return self.request_denied() return self.request_denied()
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs) # type: ignore
# TODO: RevokeResourceView - increment nonce # TODO: RevokeResourceView - increment nonce
class ForgetResourceView(AuthorizedResourceMixin, RedirectView):
def logout_view(request):
logout(request)
return redirect("/")
class ForgetResourceView(AuthorizedResourceMixin, RedirectView):
def is_authorized(self): def is_authorized(self):
return True return True
@ -110,9 +122,9 @@ class ForgetResourceView(AuthorizedResourceMixin, RedirectView):
self.del_authorized_key(resource, key) self.del_authorized_key(resource, key)
return "/" return "/"
class EnsembleMixin(AuthorizedResourceMixin):
ensemble_slug_kwarg = 'ensemble' class EnsembleMixin(AuthorizedResourceMixin):
ensemble_slug_kwarg = "ensemble"
limited_project_access = False limited_project_access = False
def is_authorized(self): def is_authorized(self):
@ -122,15 +134,15 @@ class EnsembleMixin(AuthorizedResourceMixin):
if super().is_authorized(): if super().is_authorized():
return True return True
if self.ensemble.has_admin(self.request.user): if self.ensemble.has_admin(self.request.user): # type: ignore
self.request.is_admin = True self.request.is_admin = True # type: ignore
return True return True
if self.is_authorized_key('ensemble', ensemble_slug, self.ensemble.nonce): if self.is_authorized_key("ensemble", ensemble_slug, self.ensemble.nonce):
return True return True
authorized = set([ int(x) for x in self.get_authorized_keys('project').keys() ]) authorized = set([int(x) for x in self.get_authorized_keys("project").keys()])
projects = set(self.ensemble.projects.values_list('pk', flat=True)) projects = set(self.ensemble.projects.values_list("pk", flat=True))
logger.debug("is_authorized: %r & %r", authorized, projects) logger.debug("is_authorized: %r & %r", authorized, projects)
if authorized & projects: if authorized & projects:
self.limited_project_access = True self.limited_project_access = True
@ -138,131 +150,131 @@ class EnsembleMixin(AuthorizedResourceMixin):
return True return True
return False return False
def get_object(self): def get_object(self, queryset=None):
return self.ensemble return self.ensemble
class ProjectMixin(AuthorizedResourceMixin): class ProjectMixin(AuthorizedResourceMixin):
project_kwarg = "project"
project_kwarg = 'project'
def is_authorized(self): def is_authorized(self):
project_id = self.kwargs[self.project_kwarg] project_id = self.kwargs[self.project_kwarg]
self.project = get_object_or_404(models.Project.objects.select_related('ensemble'), pk=project_id) self.project = get_object_or_404(
models.Project.objects.select_related("ensemble"), pk=project_id
)
if super().is_authorized(): if super().is_authorized():
return True return True
# check if the current user is an admin on the ensemble # check if the current user is an admin on the ensemble
if self.project.ensemble.has_admin(self.request.user): if self.project.ensemble.has_admin(self.request.user): # type: ignore
logger.debug("is_authorized: ensemble admin for project") logger.debug("is_authorized: ensemble admin for project")
self.request.is_admin = True self.request.is_admin = True # type: ignore
return True return True
if self.is_authorized_key('ensemble', self.project.ensemble.pk, self.project.ensemble.nonce): if self.is_authorized_key(
logger.debug('is_authorized: has ensemble link for project') "ensemble", self.project.ensemble.pk, self.project.ensemble.nonce
):
logger.debug("is_authorized: has ensemble link for project")
return True return True
if self.is_authorized_key('project', project_id, self.project.nonce): if self.is_authorized_key("project", project_id, self.project.nonce):
logger.debug('is_authorized: has project link') logger.debug("is_authorized: has project link")
return True return True
return False return False
# filter any generated querysets # filter any generated querysets
def get_queryset(self): def get_queryset(self):
return super().get_queryset().filter(project=self.project) return super().get_queryset().filter(project=self.project) # type: ignore
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs) # type: ignore
if 'project' in self.kwargs: if "project" in self.kwargs:
context['project'] = self.project context["project"] = self.project
context['modules'] = self.project.active_modules context["modules"] = self.project.active_modules
return context return context
class CrispyFormMixin(object):
class CrispyFormMixin(object):
cancel_url = None cancel_url = None
def get_cancel_url(self): def get_cancel_url(self):
return self.cancel_url return self.cancel_url
""" ENSEMBLE VIEWS """ """ ENSEMBLE VIEWS """
class EnsembleListView(AuthorizedResourceMixin, ListView):
class EnsembleListView(AuthorizedResourceMixin, ListView):
def is_authorized(self): def is_authorized(self):
return True return True
def get_queryset(self): def get_queryset(self):
return models.Ensemble.objects.for_user(self.request.user, return models.Ensemble.objects.for_user(
self.get_authorized_keys('ensemble').keys(), self.request.user, # type: ignore
self.get_authorized_keys('project').keys()) self.get_authorized_keys("ensemble").keys(),
#ensembles = models.Ensemble.objects.all() self.get_authorized_keys("project").keys(),
)
#if self.request.is_admin:
# return ensembles
# limit to registered ensembles
#f = Q(slug__in=self.get_authorized_keys('ensemble').keys()) | Q(projects__in=self.get_authorized_keys('project').keys())
# or ensembles where the user is admin
#if self.request.user.is_authenticated:
# f |= Q(admins=self.request.user.pk)
#return ensembles.filter(f).distinct()
class EnsembleDetailView(EnsembleMixin, DetailView): class EnsembleDetailView(EnsembleMixin, DetailView):
def request_denied(self): def request_denied(self):
if 'auth' in self.request.GET: if "auth" in self.request.GET:
if self.request.GET['auth'] != self.ensemble.auth(): if self.request.GET["auth"] != self.ensemble.auth():
raise SuspiciousOperation("Bad ensemble link") raise SuspiciousOperation("Bad ensemble link")
self.add_authorized_key('ensemble', self.ensemble.slug, self.ensemble.nonce) self.add_authorized_key("ensemble", self.ensemble.slug, self.ensemble.nonce)
return HttpResponseRedirect(resolve_url('ensemble_detail', self.ensemble.slug)) return HttpResponseRedirect(
resolve_url("ensemble_detail", self.ensemble.slug)
)
return super().request_denied() return super().request_denied()
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs) data = super().get_context_data(**kwargs)
inactive = 'inactive' in self.request.GET and self.request.is_admin inactive = "inactive" in self.request.GET and self.request.is_admin # type: ignore
projects = self.ensemble.projects.all() projects = self.ensemble.projects.all()
if self.limited_project_access: if self.limited_project_access:
projects = projects.filter(pk__in=self.get_authorized_keys('project').keys()) projects = projects.filter(
pk__in=self.get_authorized_keys("project").keys()
)
if inactive: if inactive:
projects = projects.order_by('-pk') projects = projects.order_by("-pk")
else: else:
projects = projects.active().current() projects = projects.active().current()
data['inactive'] = inactive data["inactive"] = inactive
data['object_list'] = projects data["object_list"] = projects
if self.request.is_admin: if self.request.is_admin: # type: ignore
data['ensemble_link'] = self.request.path + "?auth=" + self.ensemble.auth() data["ensemble_link"] = self.request.path + "?auth=" + self.ensemble.auth()
return data return data
class EnsembleRevokeView(SingleObjectMixin, RedirectView):
class EnsembleRevokeView(SingleObjectMixin, RedirectView):
def get_redirect_url(self): def get_redirect_url(self):
return return
""" PROJECT VIEWS """ """ PROJECT VIEWS """
class ProjectListView(ProjectMixin, ListView):
class ProjectListView(ProjectMixin, ListView):
def is_authorized(self): def is_authorized(self):
return True return True
def get_project_queryset(self): def get_project_queryset(self):
return models.Project.objects.for_user(self.request.user, return models.Project.objects.for_user(
self.get_authorized_keys('project'), self.request.user, # type: ignore
self.get_authorized_keys('ensemble')) self.get_authorized_keys("project"),
self.get_authorized_keys("ensemble"),
)
def get_queryset(self): def get_queryset(self):
return self.get_project_queryset().current().active() return self.get_project_queryset().current().active()
class ProjectCreateView(EnsembleMixin, CreateView): class ProjectCreateView(EnsembleMixin, CreateView):
admin_required = True admin_required = True
model = models.Project model = models.Project
@ -270,26 +282,25 @@ class ProjectCreateView(EnsembleMixin, CreateView):
title = "Add a new project" title = "Add a new project"
form_class = forms.ProjectForm form_class = forms.ProjectForm
def form_valid(self, form): def form_valid(self, form):
self.object = form.save(commit=False) self.object = form.save(commit=False)
self.object.ensemble = self.ensemble self.object.ensemble = self.ensemble
self.object.owner = self.request.user self.object.owner = self.request.user # type: ignore
self.object.save() self.object.save()
for module in form.cleaned_data['modules']: for module in form.cleaned_data["modules"]:
self.object.modules.create(name=module) self.object.modules.create(name=module)
return redirect('project_detail', project=self.object.pk) return redirect("project_detail", project=self.object.pk)
class ProjectDetailView(ProjectMixin, DetailView): class ProjectDetailView(ProjectMixin, DetailView):
def request_denied(self): def request_denied(self):
if 'auth' in self.request.GET: if "auth" in self.request.GET:
if self.request.GET['auth'] != self.project.auth(): if self.request.GET["auth"] != self.project.auth():
raise SuspiciousOperation("Bad project link") raise SuspiciousOperation("Bad project link")
self.add_authorized_key('project', self.project.pk, self.project.nonce) self.add_authorized_key("project", self.project.pk, self.project.nonce)
return HttpResponseRedirect(resolve_url('project_detail', self.project.pk)) return HttpResponseRedirect(resolve_url("project_detail", self.project.pk))
return super().request_denied() return super().request_denied()
def get_object(self, queryset=None): def get_object(self, queryset=None):
@ -297,15 +308,16 @@ class ProjectDetailView(ProjectMixin, DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs) data = super().get_context_data(**kwargs)
if self.request.is_admin: if self.request.is_admin: # type: ignore
#data['project_link'] = signed_url(f'{self.request.path}?nonce={self.project.nonce}') # data['project_link'] = signed_url(f'{self.request.path}?nonce={self.project.nonce}')
data['project_link'] = self.request.path + "?auth=" + self.project.auth() data["project_link"] = self.request.path + "?auth=" + self.project.auth()
return data return data
class ProjectUpdateView(ProjectMixin, UpdateView): class ProjectUpdateView(ProjectMixin, UpdateView):
admin_required = True admin_required = True
template_name = "interface/default_form.html" template_name = "interface/default_form.html"
pk_url_kwarg = 'project' pk_url_kwarg = "project"
form_class = forms.ProjectForm form_class = forms.ProjectForm
def get_object(self): def get_object(self):
@ -313,26 +325,26 @@ class ProjectUpdateView(ProjectMixin, UpdateView):
def get_initial(self): def get_initial(self):
data = super().get_initial() data = super().get_initial()
data['modules'] = self.object.active_modules data["modules"] = self.object.active_modules
print(data) print(data)
return data return data
def form_valid(self, form): def form_valid(self, form):
self.object = form.save() self.object = form.save()
current = set(self.object.active_modules) current = set(self.object.active_modules)
desired = set(form.cleaned_data['modules']) desired = set(form.cleaned_data["modules"])
self.object.modules.exclude(name__in=desired).delete() self.object.modules.exclude(name__in=desired).delete()
for module in desired-current: for module in desired - current:
self.object.modules.create(name=module) self.object.modules.create(name=module)
return redirect('project_detail', self.kwargs['project']) return redirect("project_detail", self.kwargs["project"])
@property @property
def cancel_url(self): def cancel_url(self):
return resolve_url('project_detail', self.kwargs['project']) return resolve_url("project_detail", self.kwargs["project"])
# Old Makefile from submission module # Old Makefile from submission module
#class ProjectMakefileView(EnsembleMixin, DetailView): # class ProjectMakefileView(EnsembleMixin, DetailView):
# template_name = 'interface/project_submissions.mk' # template_name = 'interface/project_submissions.mk'
# content_type = 'text/plain' # content_type = 'text/plain'
# #
@ -359,29 +371,32 @@ class ProjectUpdateView(ProjectMixin, UpdateView):
""" WIKI VIEWS """ """ WIKI VIEWS """
class WikiView(ProjectMixin, DetailView): class WikiView(ProjectMixin, DetailView):
template_name = 'interface/wiki.html' template_name = "interface/wiki.html"
model = models.WikiPage model = models.WikiPage
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs) data = super().get_context_data(**kwargs)
data['wiki_html'] = markdown(self.object.markdown) data["wiki_html"] = markdown(self.object.markdown)
return data return data
class WikiCreateView(ProjectMixin, CreateView): class WikiCreateView(ProjectMixin, CreateView):
admin_required = True admin_required = True
model = models.WikiPage model = models.WikiPage
template_name = 'interface/default_form.html' template_name = "interface/default_form.html"
form_class = forms.WikiForm form_class = forms.WikiForm
def cancel_url(self): def cancel_url(self):
return resolve_url('project_detail', self.kwargs['project']) return resolve_url("project_detail", self.kwargs["project"])
def form_valid(self, form): def form_valid(self, form):
self.object = form.save(commit=False) self.object = form.save(commit=False)
self.object.project = self.project self.object.project = self.project
self.object.save() self.object.save()
return redirect('wiki', project=self.object.project_id, pk=self.object.pk) return redirect("wiki", project=self.object.project_id, pk=self.object.pk)
class WikiEditView(ProjectMixin, UpdateView): class WikiEditView(ProjectMixin, UpdateView):
admin_required = True admin_required = True
@ -389,46 +404,53 @@ class WikiEditView(ProjectMixin, UpdateView):
form_class = forms.WikiForm form_class = forms.WikiForm
def cancel_url(self): def cancel_url(self):
return resolve_url('wiki', self.kwargs['project'], self.kwargs['pk']) return resolve_url("wiki", self.kwargs["project"], self.kwargs["pk"])
""" RESOURCE VIEWS """ """ RESOURCE VIEWS """
class ResourceCreateView(ProjectMixin, CreateView): class ResourceCreateView(ProjectMixin, CreateView):
admin_required = True admin_required = True
model = models.Resource model = models.Resource
form_class = forms.ResourceForm form_class = forms.ResourceForm
template_name = 'interface/default_form.html' template_name = "interface/default_form.html"
title = "Add a new resource" title = "Add a new resource"
def form_valid(self, form): def form_valid(self, form):
self.object = form.save(commit=False) self.object = form.save(commit=False)
self.object.project = self.project self.object.project = self.project
self.object.save() self.object.save()
return redirect('resource_upload', project=self.object.project_id, pk=self.object.pk) return redirect(
"resource_upload", project=self.object.project_id, pk=self.object.pk
)
class ResourceUploadView(ProjectMixin, UpdateView): class ResourceUploadView(ProjectMixin, UpdateView):
admin_required = True admin_required = True
model = models.Resource model = models.Resource
fields = ['file'] fields = ["file"]
template_name = 'interface/default_form.html' template_name = "interface/default_form.html"
def get_success_url(self): def get_success_url(self):
return resolve_url('resource_list', project=self.kwargs['project']) return resolve_url("resource_list", project=self.kwargs["project"])
class ResourceListView(ProjectMixin, ListView): class ResourceListView(ProjectMixin, ListView):
model = models.Resource model = models.Resource
def get_queryset(self): def get_queryset(self):
qs = super().get_queryset() qs = super().get_queryset()
if not self.request.is_admin: if not self.request.is_admin: # type: ignore
qs = qs.filter(visible=True) qs = qs.filter(visible=True)
return qs return qs
class ResourceEditView(ProjectMixin, UpdateView): class ResourceEditView(ProjectMixin, UpdateView):
admin_required = True admin_required = True
model = models.Resource model = models.Resource
fields = ['name', 'description', 'visible'] fields = ["name", "description", "visible"]
template_name = 'interface/default_form.html' template_name = "interface/default_form.html"
def get_success_url(self): def get_success_url(self):
return resolve_url('resource_list', project=self.kwargs['project']) return resolve_url("resource_list", project=self.kwargs["project"])

View File

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

View File

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

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

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)

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
"""

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"])

View File

@ -0,0 +1,103 @@
from typing import Protocol, Any, Iterable, Generator
from django.conf import settings
from django.utils.module_loading import import_module
from django.db.models import QuerySet
from polyphonic.library.models import Work, Collection
instance = getattr(settings, "INDEXER", "polyphonic.library.indexer.whoosh")
class Indexer(Protocol):
def create_index(self) -> None: ...
def get_index(self) -> None: ...
def reset_index(self) -> None: ...
def search(
self,
query: str,
collections: list[int],
page: int = 1,
pagesize: int = 20,
) -> tuple[list[dict], dict[str, Any]]: ...
def index_docs(self, docs: Iterable[dict]): ...
class PartialResultSet(object):
"""
Implements enough of QuerySet to fool the Paginator!
"""
def __init__(self, works, meta):
self.works = works
self.meta = meta
self.start = (meta["page"] - 1) * meta["pagesize"]
def __getitem__(self, key):
if isinstance(key, slice):
if key.start != self.start:
raise KeyError(f"Expected {self.start}, got {key.start}")
return self.works
return self.works[key]
def count(self):
return self.meta["total"]
# make the given module available as indexer
indexer: Indexer = import_module(instance) # type: ignore
def work_to_doc(work: Work) -> dict[str, str]:
tags = ",".join(work.meta_info.filter(name="tag").values_list("value", flat=True)) # type: ignore
meta = ", ".join(work.meta_info.values_list("value", flat=True)) # type: ignore
description = f"""{work.name}
{work.composer}
{work.edition}
{work.notes}
{meta}
"""
return dict(
work=str(work.pk),
collection=str(work.collection_id), # type: ignore
name=work.name,
composer=work.composer,
edition=work.edition,
tag=tags.lower(),
text=description,
)
def doc_set(works: QuerySet) -> Generator:
for work in works:
yield work_to_doc(work)
def index_works(works: QuerySet):
indexer.index_docs(doc_set(works))
def model_search(
query: str, collections: list[int], page: int = 1, pagesize: int = 20
) -> PartialResultSet:
hits, meta = indexer.search(query.lower(), collections, page, pagesize)
meta["pagesize"] = pagesize
meta["page"] = page
works = [Work(**hit) for hit in hits]
collection_names = dict(Collection.objects.values_list("pk", "name"))
for work in works:
work.collection = Collection( # type: ignore
pk=work.collection_id, name=collection_names[work.collection_id]
)
return PartialResultSet(works, meta)

View File

@ -0,0 +1,97 @@
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, TEXT, KEYWORD, NUMERIC
from whoosh.qparser import QueryParser
from whoosh.query import Term, NullQuery, FuzzyTerm
from typing import Any
from django.conf import settings
import os.path
import shutil
stemming_analyzer = StemmingAnalyzer() | CharsetFilter(accent_map)
schema = Schema(
work=NUMERIC(stored=True, unique=True),
collection=NUMERIC(stored=True),
name=TEXT(stored=True),
composer=TEXT(stored=True),
edition=TEXT(stored=True),
tag=KEYWORD(commas=True),
text=TEXT(analyzer=stemming_analyzer),
)
index_path = settings.WHOOSH_INDEX
def create_index() -> Index:
if not os.path.exists(index_path):
os.mkdir(index_path)
ix = create_in(index_path, schema)
return ix
def get_index() -> Index:
try:
return open_dir(index_path)
except EmptyIndexError:
return create_index()
def reset_index() -> Index:
shutil.rmtree(index_path)
return create_index()
def index_docs(works: list[dict]):
ix = get_index()
with ix.writer() as writer:
for work in works:
writer.update_document(**work)
def search(
query: str,
collections: list[int] = [],
page: int = 1,
pagesize: int = 20,
) -> tuple[list[dict], dict[str, Any]]:
meta = {}
query = query.lower()
qp = QueryParser("text", schema=schema)
q = qp.parse(query)
limit = NullQuery
for c in collections:
limit |= Term("collection", c)
hits = []
ix = get_index()
with ix.searcher() as searcher:
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(
pk=result["work"],
name=result["name"],
composer=result["composer"],
edition=result["edition"],
collection_id=int(result["collection"]),
)
)
meta["query"] = str(q & limit)
meta["total"] = len(results)
return hits, meta

View File

@ -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"]
)

View File

@ -0,0 +1,36 @@
from django.core.management.base import BaseCommand
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}"
class Command(BaseCommand):
help = "Imports works from a csv file"
def add_arguments(self, parser):
parser.add_argument("action", choices=("run", "search", "terms"))
parser.add_argument("query", nargs="*")
parser.add_argument("--collection", "-c", nargs="*", type=int)
# parser.add_argument("collection", type=int, help="Collection ID")
# parser.add_argument("source", type=argparse.FileType("r"), help="Source CSV")
def handle(self, action, query, *args, **options):
try:
method = getattr(self, f"handle_{action}")
except AttributeError:
raise RuntimeError(f"Unknown handler: {action}")
return method("".join(query), options["collection"] or [])
def handle_run(self, query, collections=[]):
index_works(models.Work.objects.all())
def handle_search(self, query, collections=[]):
for result in model_search(query, collections):
print(FORMATTER.format(w=result))
def handle_terms(self, query, collections=[]):
ix = indexer.get_index()
with ix.searcher() as searcher:
print(b", ".join(searcher.lexicon(query or "text")))

View File

@ -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")

View File

@ -4,7 +4,7 @@ import byostorage.user
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import library.models import polyphonic.library.models
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -35,7 +35,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('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)), ('created', models.DateTimeField(auto_now_add=True)),
('version', models.CharField(blank=True, max_length=30)), ('version', models.CharField(blank=True, max_length=30)),
], ],

View File

@ -3,7 +3,7 @@
import byostorage.cached import byostorage.cached
import byostorage.user import byostorage.user
from django.db import migrations, models from django.db import migrations, models
import library.models import polyphonic.library.models
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -26,7 +26,7 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='document', model_name='document',
name='upload', 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( migrations.AlterField(
model_name='work', model_name='work',

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