Compare commits

..

No commits in common. "master" and "2023-04-Production" have entirely different histories.

131 changed files with 2198 additions and 3786 deletions

8
.gitignore vendored
View File

@ -1,16 +1,10 @@
__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
@ -19,5 +13,3 @@ poetry.lock
/cache /cache
/local_storage /local_storage
/media /media
/index
/dist

View File

@ -1,25 +1,21 @@
FROM alpine:latest FROM alpine:3.14
ENV TARGET=/opt/polyphonic RUN apk add --no-cache python3 git ghostscript sqlite
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
RUN python3 -m venv ${TARGET} COPY app/requirements.txt .
ENV PATH="${TARGET}/bin:$PATH" RUN pip3 install -r requirements.txt --no-cache-dir
COPY dist/${RELEASE} . COPY app /opt/polyphonic
RUN pip3 install ${RELEASE} --no-cache-dir WORKDIR /opt/polyphonic
RUN pip3 install gunicorn whitenoise
WORKDIR ${TARGET} COPY docker_settings.py polyphonic/local_settings.py
RUN SECRET_KEY=_ python3 manage.py collectstatic --noinput
RUN SECRET_KEY=_ poly-tool collectstatic --noinput
VOLUME ["/var/polyphonic"] VOLUME ["/var/polyphonic"]
EXPOSE 8000/tcp
CMD ["gunicorn", "-b", "0.0.0.0", "polyphonic.config.wsgi"] ENTRYPOINT ["python3", "manage.py"]
CMD ["runserver", "0.0.0.0:8000", "--insecure"]

View File

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

View File

@ -6,23 +6,20 @@ 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). Store all your scores on your own cloud account (Amazon S3, Google Files etc). Tag up the scores so you can generate
Tag up the scores so you can generate custom part sets and assign them to custom part sets and assign them to projects so people can easily print just their parts.
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 Accept video/audio submissions direct to your cloud storage. Was developed and used during 2020 lockdown period for
used during 2020 lockdown period for virtual choirs/orchestras but could have more uses. 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/">
@ -43,7 +40,6 @@ 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",
@ -68,4 +64,3 @@ Create with programatic access (copy keys) and an inline policy for the bucket.
``` ```
3. 3.

19
TODO.md
View File

@ -1,19 +0,0 @@
## 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

@ -2,31 +2,26 @@ 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): class ProjectAdmin(admin.ModelAdmin):
list_display = ["name", "ensemble", "event_date", "active"]
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 = "polyphonic.interface" name = 'interface'

View File

@ -1,5 +1,4 @@
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'

62
app/interface/forms.py Normal file
View File

@ -0,0 +1,62 @@
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

@ -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 polyphonic.interface.models import 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=polyphonic.interface.models.generate_code, help_text='Ensemble registration code', max_length=9)), ('code', models.CharField(default=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=polyphonic.interface.models.resource_key)), ('file', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=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,16 +8,17 @@ 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..."
@ -26,22 +27,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
@ -53,35 +54,26 @@ class EnsembleQuerySet(models.QuerySet):
return self.filter(f).distinct() return self.filter(f).distinct()
class Ensemble(models.Model): class Ensemble(models.Model):
"""A group that plays together""" ''' A group that plays together
name = models.CharField(max_length=100, help_text="Display name") '''
slug = models.SlugField( name = models.CharField(max_length=100,
max_length=100, help_text="Display name")
editable=False, slug = models.SlugField(max_length=100, editable=False, unique=True,
unique=True, help_text="Short name for the ensemble - used for folders")
help_text="Short name for the ensemble - used for folders", admins = models.ManyToManyField('auth.User', related_name='ensembles')
) details = models.TextField(blank=True,
admins = models.ManyToManyField("auth.User", related_name="ensembles") help_text="Description of the ensemble (markdown)")
details = models.TextField( storage = models.ForeignKey('byostorage.UserStorage', null=True, on_delete=models.SET_NULL,
blank=True, help_text="Description of the ensemble (markdown)" help_text="Default storage for this ensemble")
) nonce = models.SmallIntegerField(default=1,
storage = models.ForeignKey( help_text="Increment this to reset the authentication links")
"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()
@ -91,7 +83,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:
@ -99,21 +91,17 @@ 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( return self.filter(models.Q(event_date__gte=(timezone.now()-timezone.timedelta(7))) | models.Q(event_date=None))
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)
@ -129,26 +117,23 @@ 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 = models.ForeignKey(Ensemble, related_name='projects', on_delete=models.CASCADE, null=True)
Ensemble, related_name="projects", on_delete=models.CASCADE, null=True description = models.TextField(blank=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( nonce = models.SmallIntegerField(default=1,
default=1, help_text="Increment this to reset the authentication links" 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):
@ -177,74 +162,62 @@ 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( name = models.SlugField(max_length=20, choices=[ (x, x.title()) for x in settings.POLYPHONIC_MODULES ])
max_length=20, choices=[(x, x.title()) for x in settings.POLYPHONIC_MODULES] project = models.ForeignKey(Project, related_name="modules", on_delete=models.CASCADE)
)
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): class WikiPage(models.Model):
"""An editable wiki page for the project in markdown format""" ''' An editable wiki page for the project in markdown format
project = models.ForeignKey( '''
Project, related_name="wiki_pages", on_delete=models.CASCADE 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,8 +11,7 @@
<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>
@ -24,7 +23,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 mx-4"><span class="material-symbols-outlined is-size-1 is-size-3-mobile">groups</span></span> <span class="icon fancy is-size-2 is-size-4-mobile mx-4"><i class="fas fa-random"></i></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">
<span class="material-symbols-outlined">file_upload</span> <i class="fas fa-upload"></i>
</span> </span>
<span class="file-label"> <span class="file-label">
Choose a file… Choose a file…

View File

@ -1,20 +1,19 @@
{% 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">
{{ "add_notes"|icon }} <span class="icon"><i class="fas fa-plus-circle"></i></span>
<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">
{{ "preview_off"|icon }} <span class="icon"><i class="fas fa-archive"></i></span>
<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">
{{ "preview"|icon }} <span class="icon"><i class="fas fa-archive"></i></span>
<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' %}">
{% icon "add_file" %} <span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Register another</span> <span>Register another</span>
</a> </a>
</div> </div>
@ -25,13 +25,10 @@
<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: 100px"> <div class="media-content" style="min-height: 60px">
<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">
{{ "add_notes"|icon }} <span class="icon"><i class="fas fa-file"></i></span>
<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">
{{ "edit"|icon }} <span class="icon"><i class="fas fa-edit"></i></span>
<span>Edit</span> <span>Edit</span>
</a> </a>
{% endblock %} {% endblock %}

View File

@ -1,10 +1,9 @@
{% 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 %}">
{% icon "add_notes" %} <span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add new</span> <span>Add new</span>
</a> </a>
{% endblock %} {% endblock %}
@ -28,11 +27,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 %}" title="Upload"> <a href="{% url 'resource_upload' project=project.pk pk=resource.pk %}" class="icon" title="Upload">
{% icon "upload_file" %} <i class="fas fa-upload"></i>
</a> </a>
<a href="{% url 'resource_edit' project=project.pk pk=resource.pk %}" title="Edit"> <a href="{% url 'resource_edit' project=project.pk pk=resource.pk %}" class="icon" title="Edit">
{% icon "edit" %} <i class="fas fa-edit"></i>
</a> </a>
{% endif %} {% endif %}
</div> </div>

View File

@ -1,10 +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">
{{ "edit"|icon }} <span class="icon"><i class="fas fa-edit"></i></span>
<span>Edit</span> <span>Edit</span>
</a> </a>
{% endblock %} {% endblock %}

View File

@ -3,9 +3,7 @@ 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,14 @@
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

@ -2,7 +2,6 @@ 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 polyphonic.interface import models from interface import models, utils
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,15 +16,16 @@ 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])
@ -32,40 +33,37 @@ 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})
if "admin" in self.users: self.assertAccess({ x: False for x in self.PROTECTED_URLS })
self.client.force_login(self.users["admin"])
self.assertAccess({x: True for x in self.PROTECTED_URLS}) if 'admin' in self.users:
self.client.force_login(self.users['admin'])
self.assertAccess({ x: True for x in self.PROTECTED_URLS })
def login(self, user, passwd): 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( self.assertEqual(response.status_code == 200, expected, f"Expected {expected} for {url} (status: {response.status_code})")
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,183 @@
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,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)

37
app/interface/urls.py Normal file
View File

@ -0,0 +1,37 @@
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,20 +1,18 @@
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)
pos = len(data) + 1 p = len(data) + 1
if length: if l:
length += pos l += p
return sig[pos:length] return sig[p:l]
def signed_url(name, **kwargs): def signed_url(name, **kwargs):
""" """
@ -25,10 +23,9 @@ 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:
@ -36,8 +33,6 @@ 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,27 +1,26 @@
""" """ """
"""
# 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 from django.views.generic.edit import CreateView, UpdateView, FormMixin
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.http.request import HttpRequest from django.db.models import Q
from django.contrib.auth import logout from django.utils import timezone
from markdown2 import markdown from markdown2 import markdown
from . import models, forms from . import models, forms
from .utils import check_signed_url from interface.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:
@ -30,20 +29,16 @@ class AuthorizedResourceMixin(object):
* Admin enforcing * Admin enforcing
""" """
request: HttpRequest SESSION_KEY = 'authorized'
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: # type: ignore if self.request.is_admin:
# 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
@ -56,25 +51,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 # type: ignore self.request.session[self.SESSION_KEY] = self._authorized
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)
@ -85,16 +80,15 @@ 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 # type: ignore self.request.session[self.SESSION_KEY] = self._authorized
return True return True
def request_denied(self): def request_denied(self):
raise Http404( raise Http404("Either the given resource doesn't exist or you dont have access to it.")
"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():
@ -103,18 +97,12 @@ 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) # type: ignore return super().dispatch(request, *args, **kwargs)
# TODO: RevokeResourceView - increment nonce # TODO: RevokeResourceView - increment nonce
def logout_view(request):
logout(request)
return redirect("/")
class ForgetResourceView(AuthorizedResourceMixin, RedirectView): class ForgetResourceView(AuthorizedResourceMixin, RedirectView):
def is_authorized(self): def is_authorized(self):
return True return True
@ -122,9 +110,9 @@ class ForgetResourceView(AuthorizedResourceMixin, RedirectView):
self.del_authorized_key(resource, key) self.del_authorized_key(resource, key)
return "/" return "/"
class EnsembleMixin(AuthorizedResourceMixin): class EnsembleMixin(AuthorizedResourceMixin):
ensemble_slug_kwarg = "ensemble"
ensemble_slug_kwarg = 'ensemble'
limited_project_access = False limited_project_access = False
def is_authorized(self): def is_authorized(self):
@ -134,15 +122,15 @@ class EnsembleMixin(AuthorizedResourceMixin):
if super().is_authorized(): if super().is_authorized():
return True return True
if self.ensemble.has_admin(self.request.user): # type: ignore if self.ensemble.has_admin(self.request.user):
self.request.is_admin = True # type: ignore self.request.is_admin = True
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
@ -150,131 +138,131 @@ class EnsembleMixin(AuthorizedResourceMixin):
return True return True
return False return False
def get_object(self, queryset=None): def get_object(self):
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( self.project = get_object_or_404(models.Project.objects.select_related('ensemble'), pk=project_id)
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): # type: ignore if self.project.ensemble.has_admin(self.request.user):
logger.debug("is_authorized: ensemble admin for project") logger.debug("is_authorized: ensemble admin for project")
self.request.is_admin = True # type: ignore self.request.is_admin = True
return True return True
if self.is_authorized_key( if self.is_authorized_key('ensemble', self.project.ensemble.pk, self.project.ensemble.nonce):
"ensemble", self.project.ensemble.pk, self.project.ensemble.nonce logger.debug('is_authorized: has ensemble link for project')
):
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) # type: ignore return super().get_queryset().filter(project=self.project)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) # type: ignore context = super().get_context_data(**kwargs)
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( return models.Ensemble.objects.for_user(self.request.user,
self.request.user, # type: ignore self.get_authorized_keys('ensemble').keys(),
self.get_authorized_keys("ensemble").keys(), self.get_authorized_keys('project').keys())
self.get_authorized_keys("project").keys(), #ensembles = models.Ensemble.objects.all()
)
#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( return HttpResponseRedirect(resolve_url('ensemble_detail', self.ensemble.slug))
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 # type: ignore inactive = 'inactive' in self.request.GET and self.request.is_admin
projects = self.ensemble.projects.all() projects = self.ensemble.projects.all()
if self.limited_project_access: if self.limited_project_access:
projects = projects.filter( projects = projects.filter(pk__in=self.get_authorized_keys('project').keys())
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: # type: ignore if self.request.is_admin:
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( return models.Project.objects.for_user(self.request.user,
self.request.user, # type: ignore self.get_authorized_keys('project'),
self.get_authorized_keys("project"), self.get_authorized_keys('ensemble'))
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
@ -282,25 +270,26 @@ 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 # type: ignore self.object.owner = self.request.user
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):
@ -308,16 +297,15 @@ 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: # type: ignore if self.request.is_admin:
# 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):
@ -325,26 +313,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'
# #
@ -371,32 +359,29 @@ 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
@ -404,53 +389,46 @@ 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( return redirect('resource_upload', project=self.object.project_id, pk=self.object.pk)
"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: # type: ignore if not self.request.is_admin:
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,77 +2,60 @@ 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 = "polyphonic.library" name = 'library'

33
app/library/forms.py Normal file
View File

@ -0,0 +1,33 @@
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

@ -0,0 +1,20 @@
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

@ -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 polyphonic.library.models import 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=polyphonic.library.models.doc_upload_filename)), ('upload', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=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 polyphonic.library.models import 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=polyphonic.library.models.doc_upload_filename), field=models.FileField(storage=byostorage.cached.CachedStorage(byostorage.user.BYOStorage()), upload_to=library.models.doc_upload_filename),
), ),
migrations.AlterField( migrations.AlterField(
model_name='work', model_name='work',

408
app/library/models.py Normal file
View File

@ -0,0 +1,408 @@
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,5 +1,5 @@
from collections import namedtuple from collections import namedtuple
import re
GENERAL = """ GENERAL = """
mvmt Movement mvmt Movement
@ -19,10 +19,6 @@ cb Double Bass
mall Mallet Percussion mall Mallet Percussion
vln Violin vln Violin
vla Viola vla Viola
kit Drumkit
asax Alto Sax
tsax Tenor Sax
bsax Bari Sax
acc Accordion acc Accordion
afl Alto flute afl Alto flute
@ -162,21 +158,20 @@ zith Zither
MUSIC_TAGS = [] MUSIC_TAGS = []
GENERAL_TAGS = set() GENERAL_TAGS = set()
for i, abbreviations in enumerate((GENERAL, INSTRUMENTS)): for i, abbreviations in enumerate((GENERAL, INSTRUMENTS)):
for line in abbreviations.split("\n"): for line in abbreviations.split('\n'):
parts = line.strip().split(maxsplit=1) parts = line.strip().split(maxsplit=1)
if len(parts) < 2: if len(parts) < 2: continue
continue name, _, _ = parts[1].partition('(')
name, _, _ = parts[1].partition("(")
MUSIC_TAGS.append((parts[0], name)) MUSIC_TAGS.append((parts[0], name))
if i == 0: if i == 0:
GENERAL_TAGS.add(parts[0]) GENERAL_TAGS.add(parts[0])
MUSIC_NAME_BY_TAG = dict(MUSIC_TAGS) MUSIC_NAME_BY_TAG = dict(MUSIC_TAGS)
MUSIC_TAG_BY_NAME = dict(((x[1].lower(), x[0]) for x in MUSIC_TAGS)) MUSIC_TAG_BY_NAME = dict( ( (x[1].lower(), x[0]) for x in MUSIC_TAGS ) )
class MusicTag(namedtuple('MusicTag', ('name', 'variant'), defaults=[None])):
class MusicTag(namedtuple("MusicTag", ("name", "variant"), defaults=[None])):
@classmethod @classmethod
def from_tag(cls, tag): def from_tag(cls, tag):
""" """
@ -191,7 +186,7 @@ class MusicTag(namedtuple("MusicTag", ("name", "variant"), defaults=[None])):
>>> MusicTag.from_tag('pce-A2') >>> MusicTag.from_tag('pce-A2')
MusicTag(name='Piece', variant='A2') MusicTag(name='Piece', variant='A2')
""" """
abbr, _, variant = tag.partition("-") abbr, _, variant = tag.partition('-')
name = MUSIC_NAME_BY_TAG.get(abbr.lower(), abbr) name = MUSIC_NAME_BY_TAG.get(abbr.lower(), abbr)
if variant: if variant:
@ -200,8 +195,8 @@ class MusicTag(namedtuple("MusicTag", ("name", "variant"), defaults=[None])):
@property @property
def tag(self): def tag(self):
lc = self.name.lower() l = self.name.lower()
return MUSIC_TAG_BY_NAME.get(lc, lc) return MUSIC_TAG_BY_NAME.get(l, l)
@property @property
def is_general(self): def is_general(self):
@ -236,16 +231,11 @@ class MusicTag(namedtuple("MusicTag", ("name", "variant"), defaults=[None])):
return f"{self.name} {self.variant}" return f"{self.name} {self.variant}"
return self.name return self.name
import re
PATTERNS = [ PATTERNS = [re.compile('([A-Za-z]+)[_\- ]*(\d+)'), re.compile('([A-Za-z]+)()')]
re.compile(r"(?P<inst>[A-Za-z]+)[_\- ]*(?P<ord>\d+)"),
re.compile(r"(?P<ord>\d+)(st|nd|rd|th)[_\- ]*(?P<inst>[A-Za-z]+)"),
re.compile(r"(?P<inst>[A-Za-z]+)()"),
]
def auto_tag(filename): def auto_tag(filename):
""" '''
>>> auto_tag('Ode to Joy - Violin 1.pdf') >>> auto_tag('Ode to Joy - Violin 1.pdf')
MusicTag(name='Violin', variant=1) MusicTag(name='Violin', variant=1)
@ -257,27 +247,19 @@ def auto_tag(filename):
MusicTag(name='Viola', variant=None) MusicTag(name='Viola', variant=None)
>>> auto_tag('Ode to Joy - fl-2 (piccolo).pdf') >>> auto_tag('Ode to Joy - fl-2 (piccolo).pdf')
MusicTag(name='Flute', variant=2) MusicTag(name='Flute', variant=2)
>>> auto_tag('1st Violin - Ode to Joy.pdf') '''
MusicTag(name='Violin', variant=1)
>>> auto_tag('Ode to Joy - 2nd Violin.pdf')
MusicTag(name='Violin', variant=2)
"""
for pattern in PATTERNS: for pattern in PATTERNS:
for m in pattern.finditer(filename): for inst, ordinal in pattern.findall(filename):
inst = m["inst"].lower() inst = inst.lower()
try: ordinal = int(ordinal) if ordinal else None
ordinal = int(m["ord"])
except IndexError:
ordinal = None
if inst in MUSIC_TAG_BY_NAME: if inst in MUSIC_TAG_BY_NAME:
return MusicTag(inst.title(), ordinal) return MusicTag(inst.title(), ordinal)
if inst in MUSIC_NAME_BY_TAG: if inst in MUSIC_NAME_BY_TAG:
return MusicTag(MUSIC_NAME_BY_TAG[inst], ordinal) return MusicTag(MUSIC_NAME_BY_TAG[inst], ordinal)
if __name__ == "__main__": if __name__ == "__main__":
import doctest import doctest
print(doctest.testmod()) print(doctest.testmod())

View File

@ -5,23 +5,22 @@ import string
SAFECHARS = string.ascii_letters + string.digits + " _-" SAFECHARS = string.ascii_letters + string.digits + " _-"
def extract_pages(source, bookmark, start=None, end=None, count=1): def extract_pages(source, bookmark, start=None, end=None, count=1):
return extract_and_concat([(source, bookmark, start, end, count)]) return extract_and_concat([(source, bookmark, start, end, count)])
def extract_and_concat(items): def extract_and_concat(items):
# create a temporary directory for our sections # create a temporary directory for our sections
d = tempfile.TemporaryDirectory(prefix="polyphonic_") d = tempfile.TemporaryDirectory(prefix="polyphonic_")
pdfmarks = os.path.join(d.name, "pdfmarks.txt") pdfmarks = os.path.join(d.name, 'pdfmarks.txt')
marks = open(pdfmarks, "w") marks = open(pdfmarks, 'w')
sections = [] sections = []
current_page = 1 current_page = 1
for i, (source, bookmark, start, end, count) in enumerate(items): for i, (source, bookmark, start, end, count) in enumerate(items):
if count == 0: if count == 0:
continue continue
@ -29,34 +28,23 @@ def extract_and_concat(items):
sections.append(source) sections.append(source)
else: else:
if not end: if not end:
end = start end = start
dest = os.path.join(d.name, f"section_{i}.pdf") dest = os.path.join(d.name, f'section_{i}.pdf')
cmd = [ cmd = ['gs', '-sDEVICE=pdfwrite', '-dBATCH', '-dNOPAUSE',
"gs", f'-dFirstPage={start}', f'-dLastPage={end}',
"-sDEVICE=pdfwrite", f'-sOutputFile={dest}',
"-dBATCH", source]
"-dNOPAUSE",
f"-dFirstPage={start}",
f"-dLastPage={end}",
f"-sOutputFile={dest}",
source,
]
bookmark = "".join(filter(lambda c: c in SAFECHARS, bookmark)) bookmark = "".join(filter(lambda c: c in SAFECHARS, bookmark))
marks.write(f"[/Title ({bookmark}) /Page {current_page} /OUT pdfmark\n") marks.write(f'[/Title ({bookmark}) /Page {current_page} /OUT pdfmark\n')
p = subprocess.run(cmd, check=True, capture_output=True) p = subprocess.run(cmd, check=True, capture_output=True)
pages = len( pages = len([ x for x in p.stdout.splitlines() if x.decode('utf8').startswith('Page ')])
[
x
for x in p.stdout.splitlines()
if x.decode("utf8").startswith("Page ")
]
)
for j in range(count): for j in range(count):
sections.append(dest) sections.append(dest)
current_page += pages current_page += pages
@ -64,9 +52,10 @@ def extract_and_concat(items):
marks.close() marks.close()
# concat the items # concat the items
output = tempfile.NamedTemporaryFile(prefix="polyphonic_", suffix=".pdf") output = tempfile.NamedTemporaryFile(prefix="polyphonic_", suffix='.pdf')
cmd = ["gs", "-sDEVICE=pdfwrite", "-q", "-dBATCH", "-dNOPAUSE", "-sOutputFile=-"] cmd = ['gs', '-sDEVICE=pdfwrite', '-q', '-dBATCH', '-dNOPAUSE',
'-sOutputFile=-']
cmd.extend(sections) cmd.extend(sections)
cmd.append(pdfmarks) cmd.append(pdfmarks)

View File

@ -1,5 +1,4 @@
{% extends "interface/project_base.html" %} {% extends "interface/project_base.html" %}
{% load polyphonic %}
{% block page %} {% block page %}
<h3 class="title">Library collections for {% firstof request.user.first_name request.user.username %}</h3> <h3 class="title">Library collections for {% firstof request.user.first_name request.user.username %}</h3>
@ -8,12 +7,10 @@
<form method="GET" action="{% url 'work_list' %}"> <form method="GET" action="{% url 'work_list' %}">
<div class="field has-addons"> <div class="field has-addons">
<div class="control is-expanded"> <div class="control is-expanded">
<input class="input" name="q" type="text" placeholder="Find a work" value="{{ request.GET.filter }}"/> <input class="input" name="filter" type="text" placeholder="Find a work" value="{{ request.GET.filter }}"/>
</div> </div>
<div class="control"> <div class="control">
<a class="button" href="?"> <a class="button" href="?"><i class="fas fa-times"></i></a>
{% icon "close" %}
</a>
</div> </div>
</div> </div>
</form> </form>
@ -35,10 +32,10 @@
</p> </p>
<p> <p>
{% for tag in collection.tags %} {% for tag in collection.tags %}
<a href="{% url 'collection_work_list' collection.pk %}?q=tag:{{ tag }}" class="tag is-success">{{ tag }}</a> <a href="{% url 'collection_work_list' collection.pk %}?filter=tag:{{ tag }}" class="tag is-success">{{ tag }}</a>
{% endfor %} {% endfor %}
{% for genre in collection.genres %} {% for genre in collection.genres %}
<a href="{% url 'collection_work_list' collection.pk %}?q=genre:{{ genre }}" class="tag is-warning">{{ genre }}</a> <a href="{% url 'collection_work_list' collection.pk %}?filter=genre:{{ genre }}" class="tag is-warning">{{ genre }}</a>
{% endfor %} {% endfor %}
</p> </p>
</div> </div>
@ -50,5 +47,4 @@
<div> <div>
<small>{{ ensemble.ensemble_code }}</small> <small>{{ ensemble.ensemble_code }}</small>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,13 +1,11 @@
{% extends "interface/project_base.html" %} {% extends "interface/project_base.html" %}
{% load polyphonic %}
{% block admin %} {% block admin %}
<a href="#" onclick="saveTags()" class="button is-link"> <a href="#" onclick="saveTags()" class="button is-link">
{% icon "save" %} <span class="icon"><i class="fas fa-save"></i></span>
<span>Save</span> <span>Save</span>
</a> </a>
<a href="{% url 'work_detail' collection=collection.pk pk=object.work_id %}" class="button is-link is-light"> <a href="{% url 'work_detail' collection=collection.pk pk=object.work_id %}" class="button is-link is-light">
{% icon "backspace" %}
<span>Cancel</span> <span>Cancel</span>
</a> </a>
{% endblock %} {% endblock %}
@ -78,11 +76,7 @@
</div> </div>
<ul id="unassigned-area"> <ul id="unassigned-area">
{% for tag, inst in document.work.music_tags %} {% for tag, inst in document.work.music_tags %}
<li> <li class="is-clickable" onclick="assignInstrument('{{tag}}', this)")>{{ inst }}</li>
<span class="is-clickable" onclick="assignInstrument('{{tag}}', this)")>{{ inst }}</span>
&nbsp;
<span class="is-clickable" onclick="addNumberedInstrument('{{tag}}', this)">...</span>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
<a onclick="document.getElementById('add-modal').classList.add('is-active')">Add Tag</a> <a onclick="document.getElementById('add-modal').classList.add('is-active')">Add Tag</a>
@ -145,7 +139,7 @@
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
{{ json_data|json_script:"data" }} {{ json_data|json_script:"data" }}
<script type="text/javascript"> <script type="text/javascript">
let url = "{% url 'document_download' collection.pk document.pk %}"; let url = "{{ document.upload.url|safe }}";
// Loaded via <script> tag, create shortcut to access PDF.js exports. // Loaded via <script> tag, create shortcut to access PDF.js exports.
let pdfjsLib = window['pdfjs-dist/build/pdf']; let pdfjsLib = window['pdfjs-dist/build/pdf'];
@ -282,16 +276,6 @@
document.getElementById('add-instrument-variant').value = ""; document.getElementById('add-instrument-variant').value = "";
} }
function addNumberedInstrument(tag, e) {
let modal = document.getElementById('add-modal');
document.getElementById("add-instrument-name").value = data.instruments[tag];
document.getElementById("add-instrument-variant").value = 1;
document.getElementById("add-instrument-variant").focus();
modal.classList.add('is-active');
}
function addInstrument() { function addInstrument() {
let name = document.getElementById('add-instrument-name'); let name = document.getElementById('add-instrument-name');
let variant = document.getElementById('add-instrument-variant'); let variant = document.getElementById('add-instrument-variant');
@ -342,7 +326,7 @@
let setStart = document.createElement('span'); let setStart = document.createElement('span');
setStart.className = "icon is-action"; setStart.className = "icon is-action";
setStart.innerHTML = '<span class="material-symbols-outlined" title="Set start page">vertical_align_top</span>'; setStart.innerHTML = '<i class="fas fa-sort-amount-down" title="Set start page"></i>';
setStart.addEventListener('click', () => setTagStart(el)); setStart.addEventListener('click', () => setTagStart(el));
el.appendChild(setStart); el.appendChild(setStart);
@ -352,20 +336,22 @@
let name = document.createElement('b'); let name = document.createElement('b');
name.innerHTML = get_instrument(tag); name.innerHTML = get_instrument(tag);
label.appendChild(name); label.appendChild(name);
el.appendChild(label);
let del = document.createElement('span'); let del = document.createElement('span');
del.className = "icon is-action"; del.className = "icon is-action";
del.innerHTML = '<span class="material-symbols-outlined" title="Remove this tag">delete</span>'; del.innerHTML = '<i class="fas fa-trash-alt" title="Remove this tag"></i>';
del.addEventListener('click', () => { del.addEventListener('click', () => {
el.remove(); el.remove();
dirty=true; dirty=true;
}); });
el.appendChild(del) label.appendChild(del)
el.appendChild(label);
let setEnd = document.createElement('span'); let setEnd = document.createElement('span');
setEnd.className = "icon is-action"; setEnd.className = "icon is-action";
setEnd.innerHTML = '<span class="material-symbols-outlined" title="Set end page">vertical_align_bottom</span>'; setEnd.innerHTML = '<i class="fas fa-sort-amount-up" title="Set end page"></i>';
setEnd.addEventListener('click', () => setTagEnd(el)); setEnd.addEventListener('click', () => setTagEnd(el));
el.appendChild(setEnd); el.appendChild(setEnd);

View File

@ -1,10 +1,7 @@
{% load path_filters %} {% load path_filters %}
{% load polyphonic %}
<tr> <tr>
<td style="white-space: nowrap"> <td><a href="{{ doc.upload.url }}" target="_blank">
<a href="{{ doc.upload.url }}" target="_blank"> {{ doc.upload.name|basename }}</a></td>
{{ doc.upload.name|basename }}</a>
</td>
<td> <td>
{% for section in doc.sections.all %} {% for section in doc.sections.all %}
<a class="tag is-{{ section.bulma_class }}" target="_blank" href="{% url 'part_download' collection.pk section.pk section.filename %}">{{ section.name }}</a> <a class="tag is-{{ section.bulma_class }}" target="_blank" href="{% url 'part_download' collection.pk section.pk section.filename %}">{{ section.name }}</a>
@ -13,13 +10,10 @@
<td class="has-text-right" style="white-space: nowrap;"> <td class="has-text-right" style="white-space: nowrap;">
{% if request.is_admin %} {% if request.is_admin %}
{% if doc.doctype == 1 %} {% if doc.doctype == 1 %}
<a href="{% url 'document_annotate' collection.pk doc.pk %}" title="Annotate"> <a href="{% url 'document_annotate' collection.pk doc.pk %}"><i class="fas fa-tags"
{% icon "toc" %} title="Manage Tags"></i></a>
</a>
{% endif %} {% endif %}
<a href="{% url 'document_delete' collection.pk doc.pk %}" title="Delete"> <a href="{% url 'document_delete' collection.pk doc.pk %}"><i class="fas fa-trash-alt" title="Delete Document"></i></a>
{% icon "delete" %}
</a>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -1,5 +1,4 @@
{% extends "interface/project_base.html" %} {% extends "interface/project_base.html" %}
{% load polyphonic %}
{% block page %} {% block page %}
<form action="" method="post" target="_blank"> <form action="" method="post" target="_blank">
@ -32,6 +31,12 @@
</span> </span>
</span> </span>
</div> </div>
<span class="control">
<button type="submit" class="button is-primary">
<span class="icon"><i class="fas fa-copy"></i></span>
<span>Get My Parts!</span>
</button>
</span>
</div> </div>
</div> </div>
@ -40,7 +45,6 @@
<tr> <tr>
<th/> <th/>
<th>Piece</th> <th>Piece</th>
<th>Composer</th>
<th class="is-hidden-mobile">Running time</th> <th class="is-hidden-mobile">Running time</th>
<th>Part</th> <th>Part</th>
<th/> <th/>
@ -57,12 +61,11 @@
{{ item.work.name }} {{ item.work.name }}
{% endif %} {% endif %}
</td> </td>
<td class="is-hidden-mobile">{{ item.work.composer }}</td>
<td class="is-hidden-mobile">{% firstof item.work.running_time "------" %}</td> <td class="is-hidden-mobile">{% firstof item.work.running_time "------" %}</td>
<td class="select-cell"> <td class="select-cell">
<input type="hidden" name="works" value="{{ item.work.pk }}"/> <input type="hidden" name="works" value="{{ item.work.pk }}"/>
<span class="select is-small" style="width: 100%"> <span class="select is-small" style="width: 100%">
<select name="instrument-selection" id="part-{{ item.work.pk }}" style="width: 100%"> <select name="instruments" id="part-{{ item.work.pk }}" style="width: 100%">
<option value='-'>None</option> <option value='-'>None</option>
{% for tag, name in item.work.digital_parts %} {% for tag, name in item.work.digital_parts %}
<option value='{{ tag }}'>{{ name }}</option> <option value='{{ tag }}'>{{ name }}</option>
@ -71,9 +74,8 @@
</span> </span>
</td> </td>
<td> <td>
<span class="button is-link is-small" onclick="downloadPart({{ item.work.collection_id }}, {{ item.work.pk }})"> <span class="is-action" onclick="downloadPart({{ item.work.collection_id }}, {{ item.work.pk }})">
{% icon "download" %} <i class="fas fa-download" title="Download Part"></i>
<span>Get</span>
</span> </span>
</td> </td>
</tr> </tr>
@ -81,19 +83,15 @@
</tbody> </tbody>
<tfoot> <tfoot>
<tr> <tr>
<td/>
<td/> <td/>
<td/> <td/>
<td>{% firstof running_time "------" %}</td> <td>{% firstof running_time "------" %}</td>
<td colspan="2"> <td/>
<button class="button is-link is-small" type="submit" name="action" value="pdf"> <td>
{% icon "two_pager" %} <!--
<span>&nbsp;Single combined PDF</span> <button class="button is-link is-small" type="submit"><span class="icon"><i class="fas fa-copy"></i></span><span>Single combined PDF</span></button>
</button> <a class="button is-link is-small"><span class="icon"><i class="fas fa-archive"></i></span><span>Individual files (zipped)</span></a>
<button class="button is-link is-small" type="submit" name="action" value="zip"> -->
{% icon "folder_zip" %}
<span>&nbsp;Individual files (zipped)</span>
</button>
</td> </td>
</tr> </tr>
</tfoot> </tfoot>
@ -114,23 +112,16 @@
const INSTRUMENTS = JSON.parse(document.getElementById('instruments').innerText); const INSTRUMENTS = JSON.parse(document.getElementById('instruments').innerText);
function updateParts() { function updateParts() {
var inst = document.getElementById("instrument-name").value.toLowerCase(); var inst = document.getElementById("instrument-name").value;
window.localStorage.setItem('instrument-name', inst); window.localStorage.setItem('instrument-name', inst);
console.log("Changing to", inst);
for (let i of INSTRUMENTS) { for (let i of INSTRUMENTS) {
if (i[1].toLowerCase() === inst) { if (i[1] === inst) inst = i[0];
inst = i[0];
break;
}
} }
console.log("Instrument code:", inst);
let part = document.getElementById("part-preference").value; let part = document.getElementById("part-preference").value;
window.localStorage.setItem('part-preference', part); window.localStorage.setItem('part-preference', part);
console.log("Part preference:", part);
selectParts(inst, part); selectParts(inst, part);
@ -141,11 +132,10 @@ function selectParts(inst, part) {
let prefix = inst + "-" + part; let prefix = inst + "-" + part;
let selections = document.getElementsByName("instrument-selection"); let instruments = document.getElementsByName("instruments");
console.log("Updating selections:", prefix, selections); for(let i=0; i<instruments.length; i++) {
for(let i=0; i<selections.length; i++) {
var result = "-" var result = "-"
let options = selections[i].children; let options = instruments[i].children;
for(let j=0; j<options.length; j++) { for(let j=0; j<options.length; j++) {
let value = options[j].value; let value = options[j].value;
if (value.startsWith(prefix)) { if (value.startsWith(prefix)) {
@ -156,8 +146,7 @@ function selectParts(inst, part) {
result = value; result = value;
} }
} }
console.log("Selected:", result); instruments[i].value = result;
selections[i].value = result;
} }
} }

View File

@ -1,13 +1,12 @@
{% extends "interface/project_base.html" %} {% extends "interface/project_base.html" %}
{% load polyphonic %}
{% block admin %} {% block admin %}
<a href="{% url 'item_list_append' project.pk %}" class="button is-link"> <a href="{% url 'item_list_append' project.pk %}" class="button is-link">
{% icon "add_circle" %} <span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add</span> <span>Add</span>
</a> </a>
<a href="#" onclick="save()" class="button is-link"> <a href="#" onclick="save()" class="button is-link">
{% icon "save" %} <span class="icon"><i class="fas fa-save"></i></span>
<span>Save</span> <span>Save</span>
</a> </a>
{% endblock %} {% endblock %}
@ -27,15 +26,9 @@
<td>{{ item.work.name }}</td> <td>{{ item.work.name }}</td>
<td>{{ item.work.duration }}</td> <td>{{ item.work.duration }}</td>
<td style="text-align: center;"> <td style="text-align: center;">
<span class="clickable" title="Move up" onclick="moveItem({{ item.pk }}, -1)"> <i class="fas fa-arrow-up clickable" title="Move up" onclick="moveItem({{ item.pk }}, -1)"></i>
{% icon "arrow_upward" %} <i class="fas fa-arrow-down clickable" title="Move down" onclick="moveItem({{ item.pk }}, 1)"></i>
</span> <i class="fas fa-trash clickable" title="Remove" onClick="moveItem({{ item.pk }}, 0)"></i>
<span class="clickable" title="Move down" onclick="moveItem({{ item.pk }}, 1)">
{% icon "arrow_downward" %}
</span>
<span class="clickable" title="Remove" onClick="moveItem({{ item.pk }}, 0)">
{% icon "delete" %}
</span>
</td> </td>
</tr> </tr>

View File

@ -1,6 +1,5 @@
{% extends 'interface/project_base.html' %} {% extends 'interface/project_base.html' %}
{% load path_filters %} {% load path_filters %}
{% load polyphonic %}
{% block media %} {% block media %}
<script src="https://unpkg.com/dropzone@5/dist/min/dropzone.min.js"></script> <script src="https://unpkg.com/dropzone@5/dist/min/dropzone.min.js"></script>
@ -9,12 +8,12 @@
{% block admin %} {% block admin %}
<a href="{% url 'work_edit' collection.pk work.pk %}" class="button is-link"> <a href="{% url 'work_edit' collection.pk work.pk %}" class="button is-link">
{% icon "edit" %} <span class="icon"><i class="fas fa-edit"></i></span>
<span>Edit</span> <span>Edit</span>
</a> </a>
<a href="{% url 'work_add_to_project' collection.pk work.pk %}" class="button is-link"> <a href="{% url 'work_add_to_project' collection.pk work.pk %}" class="button is-link">
{% icon "add_ad" %} <span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add to project</span> <span>Add to project</span>
</a> </a>
{% endblock %} {% endblock %}
@ -26,51 +25,51 @@
{% endfor %} {% endfor %}
</h3> </h3>
<p class="subtitle">{% firstof work.composer "Unattributed" %}{% if work.edition %} - {{ work.edition }}{% endif %}</p> <p class="subtitle">{% firstof work.composer "Unattributed" %}{% if work.edition %} - {{ work.edition }}{% endif %}</p>
<section class="block">
<p class="block">{{ work.notes }}</p>
<p class="block">
<table class="table">
<tr>
<th>Location:</th><td><a href="{% url 'collection_work_list' work.collection.pk %}">{{ work.collection }}</a> [{{ work.identifier }}]</td>
<th>Orchestration:</th><td>{{ work.orchestration }}</td>
</tr><tr>
<th>Running time:</th><td>{% firstof work.duration 'Unknown' %}</td>
<th>Licence:</th><td>{{ work.get_licence_display }}</td>
</tr><tr>
<td colspan="4">
{% for meta in work.meta %}
<a href="{% url 'collection_work_list' work.collection.pk %}?filter={{ meta.name}}:{{ meta.value }}" class="tag" >
{{ meta.get_name_display }}:
{{ meta.value }}
</a>
{% endfor %}
</td>
</tr>
</table>
{% if work.parent %}
<p>From <a href="{% url 'work_detail' work.parent.collection.pk work.parent.pk %}">{{ work.parent.name }} - {{ work.parent.composer }}</a>
</p>
{% endif %}
{% if work.related_works.count %}
<h3>Related</h3>
<ul>
{% for related in work.related_works.all %}
<li><a href="{% url 'work_detail' related.collection.pk related.pk %}">{{ related.name }} - {{ related.composer }}</a></li>
{% endfor %}
</ul>
{% endif %}
</section>
<section class="block"> <section class="block">
<div class="columns"> <div class="columns">
<div class="column is-half">
<p class="block">{{ work.notes }}</p>
<p class="block">
<table class="table">
<tr>
<th>Location:</th><td><a href="{% url 'collection_work_list' work.collection.pk %}">{{ work.collection }}</a> [{{ work.identifier }}]</td>
<th>Orchestration:</th><td>{{ work.orchestration }}</td>
</tr><tr>
<th>Running time:</th><td>{% firstof work.duration 'Unknown' %}</td>
<th>Licence:</th><td>{{ work.get_licence_display }}</td>
</tr><tr>
<td colspan="4">
{% for meta in work.meta %}
<a href="{% url 'collection_work_list' work.collection.pk %}?filter={{ meta.name}}:{{ meta.value }}" class="tag" >
{{ meta.get_name_display }}:
{{ meta.value }}
</a>
{% endfor %}
</td>
</tr>
</table>
{% if work.parent %}
<p>From <a href="{% url 'work_detail' work.parent.collection.pk work.parent.pk %}">{{ work.parent.name }} - {{ work.parent.composer }}</a>
</p>
{% endif %}
{% if work.related_works.count %}
<h3>Related</h3>
<ul>
{% for related in work.related_works.all %}
<li><a href="{% url 'work_detail' related.collection.pk related.pk %}">{{ related.name }} - {{ related.composer }}</a></li>
{% endfor %}
</ul>
{% endif %}
</div>
<div class="column is-half"> <div class="column is-half">
<div class="box"> <div class="box">
<h4 class="subtitle is-size-4"> <h4 class="subtitle is-size-4">
{% icon "menu_book" %} <span class="icon"><i class="fas fa-book"></i></span>
<span>Printed Parts</span> Printed Parts
</h4> </h4>
<div class="tags"> <div class="tags">
{% for inst, c in work.physical_parts %} {% for inst, c in work.physical_parts %}
@ -80,10 +79,12 @@
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
</div>
<div class="column is-half">
<div class="box"> <div class="box">
<h4 class="subtitle is-size-4"> <h4 class="subtitle is-size-4">
{% icon "file_copy" %} <span class="icon"><i class="fas fa-print"></i></span>
<span>Digital Parts</span> Digital Parts
</h4> </h4>
<div class="tags"> <div class="tags">
{% with sections=work.digital_parts %} {% with sections=work.digital_parts %}
@ -106,8 +107,8 @@
<div class="box"> <div class="box">
<div class="level"> <div class="level">
<h4 class="subtitle is-size-4"> <h4 class="subtitle is-size-4">
{% icon "file_present" %} <span class="icon"><i class="fas fa-file"></i></span>
<span>Files<span> Files
</h4> </h4>
</div> </div>
<div class="columns"> <div class="columns">
@ -129,20 +130,10 @@
</div> </div>
{% if request.is_admin %} {% if request.is_admin %}
<div class="column is-one-quarter"> <div class="column is-one-quarter">
<h4 class="is-size-5">Add Files</h4> <h4 class="is-size-5">Upload files</h4>
{% if "upload" in methods %}
<form action="{% url 'document_add' collection.pk object.pk %}" class="dropzone" id="doc-upload" style="-moz-user-select: none"> <form action="{% url 'document_add' collection.pk object.pk %}" class="dropzone" id="doc-upload" style="-moz-user-select: none">
{% csrf_token %} {% csrf_token %}
</form> </form>
{% endif %}
{% if "gdrive" in methods %}
<div class="has-text-centered mt-3">
<a class="button button-is-primary is-size-7" href="{% url 'work_gdrive' collection.pk object.pk %}">
{% icon "add_to_drive" %}
<span>Google Drive</span>
</a><br/>
</div>
{% endif %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
@ -153,14 +144,12 @@
<div class="box"> <div class="box">
<div class="level"> <div class="level">
<h4 class="is-size-4"> <h4 class="is-size-4">
{{ "folder_open"| icon }} <span class="icon"><i class="fas fa-book-reader"></i></span>
Projects Loans
</h4> </h4>
<span class="level-right"> <span class="level-right">
<a class="icon-text" href="{% url 'work_add_to_project' collection.pk work.pk %}"> <a class="icon-text" href="{% url 'work_add_to_project' collection.pk work.pk %}"><span class="icon"><i
{% icon "shopping_cart_checkout" %} class="fas fa-plus-circle"></i></span> Checkout</a>
<span>Checkout</span>
</a>
</span> </span>
</div> </div>
<table class="table is-fullwidth"> <table class="table is-fullwidth">
@ -184,7 +173,7 @@
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td>No current assignments</td> <td>No current loans</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@ -0,0 +1,65 @@
{% 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,11 +1,10 @@
<h3 class="subtitle">{{ work.name }}</h3> <h3 class="subtitle">{{ work.name }}</h3>
<div class="block tags"> <div class="block">
{% for tag, name in work.digital_parts %} {% for tag, name in work.digital_parts %}
<a class="tag is-info" href="{% url 'work_download' work.collection_id work.pk %}?tag={{ tag }}" target="polyphonic_parts">{{ name }}</a> <a class="tag is-info" href="{% url 'work_download' work.collection_id work.pk %}?tag={{ tag }}">{{ name }}</a>
{% endfor %} {% endfor %}
</div> </div>
<!--
<h3 class="subtitle">Files</h3> <h3 class="subtitle">Files</h3>
<table class="table is-narrow is-fullwidth"> <table class="table is-narrow is-fullwidth">
<tbody> <tbody>
@ -20,7 +19,6 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
-->
<p> <p>
<a href="{% url 'work_detail' work.collection_id work.pk %}">More details...</a> <a href="{% url 'work_detail' work.collection_id work.pk %}">More details...</a>

View File

@ -1,5 +1,4 @@
{% extends "interface/project_base.html" %} {% extends "interface/project_base.html" %}
{% load polyphonic %}
@ -36,14 +35,13 @@
<div class="field is-grouped"> <div class="field is-grouped">
<div class="control"> <div class="control">
<button class="button is-link"> <button class="button is-link">
{% icon "print" %} <span class="icon"><i class="fas fa-print"></i></span>
<span>Print Set</span> <span>Print Set</span>
</button> </button>
</div> </div>
<div class="control"> <div class="control">
<a class="button is-link is-light" href="{% url 'work_detail' collection.pk object.pk %}"> <a class="button is-link is-light" href="{% url 'work_detail' collection.pk object.pk %}">
{% icon "backspace" %} <span>Cancel</span>
<span>Cancel</span>
</a> </a>
</div> </div>
</div> </div>

176
app/library/tests.py Normal file
View File

@ -0,0 +1,176 @@
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'), [])

44
app/library/urls.py Normal file
View File

@ -0,0 +1,44 @@
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

@ -0,0 +1,482 @@
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,32 +1,14 @@
from polyphonic.interface.views import AuthorizedResourceMixin
from rest_framework import serializers
from rest_framework.exceptions import APIException
from rest_framework import generics
from polyphonic.library.models import Collection, Work, Document, Section, WorkMeta
import requests
import urllib
import shutil
import os.path
from django.db import transaction
from django.core.files.uploadedfile import TemporaryUploadedFile
""" """
Views relating to importing and exporting collection items Views relating to importing and exporting collection items
""" """
""" """
from polyphonic.interface.views import EnsembleMixin from interface.views import EnsembleMixin
from polyphonic.library.views import WorkMixin from library.views import WorkMixin
from django.views.generic import View from django.views.generic import View
from django.http import JsonResponse from django.http import JsonResponse
from djantic import ModelSchema from djantic import ModelSchema
from polyphonic.library.models import Work, Document, Section from library.models import Work, Document, Section
class DocumentSchema(ModelSchema): class DocumentSchema(ModelSchema):
class Config: class Config:
@ -50,24 +32,39 @@ class WorkExportView(EnsembleMixin, WorkMixin, View):
""" """
from interface.views import AuthorizedResourceMixin
from rest_framework import routers, serializers, viewsets
from rest_framework.exceptions import APIException
from library.models import Collection, Work, Document, Section, WorkMeta
import requests
from io import BytesIO
import urllib
import shutil
import os.path
from django.db import transaction
from django.core.files.uploadedfile import TemporaryUploadedFile
class WorkMetaSerializer(serializers.ModelSerializer): class WorkMetaSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = WorkMeta model = WorkMeta
exclude = ["id", "work"] exclude = ['id', 'work']
def to_representation(self, instance): def to_representation(self, instance):
return f"{instance.name}:{instance.value}" return f"{instance.name}:{instance.value}"
def to_internal_value(self, data): def to_internal_value(self, data):
name, _, value = data.partition(":") name, _, value = data.partition(':')
return super().to_internal_value({"name": name, "value": value}) return super().to_internal_value({'name': name, 'value': value})
class SectionSerializer(serializers.ModelSerializer): class SectionSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Section model = Section
exclude = ["id", "doc"] exclude = ['id', 'doc']
def to_representation(self, instance): def to_representation(self, instance):
start = instance.start or 0 start = instance.start or 0
@ -82,14 +79,14 @@ class SectionSerializer(serializers.ModelSerializer):
start = None start = None
if end < 1: if end < 1:
end = None end = None
return super().to_internal_value({"tag": tag, "start": start, "end": end}) return super().to_internal_value({'tag': tag, 'start': start, 'end': end})
class DocumentSerializer(serializers.ModelSerializer): class DocumentSerializer(serializers.ModelSerializer):
upload = serializers.URLField() upload = serializers.URLField()
sections = SectionSerializer(many=True) sections = SectionSerializer(many=True)
# def to_internal_value(self, data): #def to_internal_value(self, data):
# r = requests.get(data['upload'], stream=True) # r = requests.get(data['upload'], stream=True)
# with tempfile.NamedTemporaryFile('wb') as f: # with tempfile.NamedTemporaryFile('wb') as f:
# shutil.copyfileobj(r.raw, f) # shutil.copyfileobj(r.raw, f)
@ -99,7 +96,7 @@ class DocumentSerializer(serializers.ModelSerializer):
def to_representation(self, instance): def to_representation(self, instance):
data = super().to_representation(instance) data = super().to_representation(instance)
data["upload"] = instance.upload.url data['upload'] = instance.upload.url
return data return data
def create(self, validated_data): def create(self, validated_data):
@ -118,40 +115,35 @@ class DocumentSerializer(serializers.ModelSerializer):
model = Document model = Document
exclude = ["id", "work", "version", "created"] exclude = ["id", "work", "version", "created"]
# Serializers define the API representation. # Serializers define the API representation.
class WorkSerializer(serializers.ModelSerializer): class WorkSerializer(serializers.ModelSerializer):
docs = DocumentSerializer(many=True) docs = DocumentSerializer(many=True)
meta_info = WorkMetaSerializer(many=True) meta_info = WorkMetaSerializer(many=True)
class Meta: class Meta:
model = Work model = Work
exclude = ["id", "collection", "projects", "parent"] exclude = ['id', 'collection', 'projects', 'parent']
def create(self, validated): def create(self, validated):
with transaction.atomic(): with transaction.atomic():
docs = validated.pop("docs", []) docs = validated.pop('docs', [])
meta = validated.pop("meta_info", []) meta = validated.pop('meta_info', [])
work = Work.objects.create(**validated) work = Work.objects.create(**validated)
for d in docs: for d in docs:
sections = d.pop("sections", []) sections = d.pop('sections', [])
url = urllib.parse.urlparse(d["upload"]) url = urllib.parse.urlparse(d['upload'])
filename = os.path.basename(url.path) filename = os.path.basename(url.path)
r = requests.get(d["upload"], stream=True) r = requests.get(d['upload'], stream=True)
if r.status_code != 200: if r.status_code != 200:
raise APIException("Failed to download file") raise APIException("Failed to download file")
f = TemporaryUploadedFile( f = TemporaryUploadedFile(filename, r.headers['content-type'], r.headers.get('content-length'), r.encoding)
filename,
r.headers["content-type"],
r.headers.get("content-length"),
r.encoding,
)
shutil.copyfileobj(r.raw, f.file) shutil.copyfileobj(r.raw, f.file)
r.close() r.close()
d["upload"] = f d['upload'] = f
doc = Document.objects.create(work_id=work.pk, **d) doc = Document.objects.create(work_id=work.pk, **d)
for s in sections: for s in sections:
@ -162,21 +154,22 @@ class WorkSerializer(serializers.ModelSerializer):
return work return work
class CollectionSerializer(serializers.Serializer): class CollectionSerializer(serializers.Serializer):
works = WorkSerializer(many=True) works = WorkSerializer(many=True)
def create(self, validated): def create(self, validated):
s = WorkSerializer() s = WorkSerializer()
print(validated) print(validated)
collection = validated["collection_id"] collection = validated['collection_id']
with transaction.atomic(): with transaction.atomic():
for work in validated["works"]: for work in validated['works']:
work["collection_id"] = collection work['collection_id'] = collection
s.create(work) s.create(work)
return Collection.objects.get(pk=collection) return Collection.objects.get(pk=collection)
from rest_framework import generics
class CollectionExportView(AuthorizedResourceMixin, generics.RetrieveAPIView): class CollectionExportView(AuthorizedResourceMixin, generics.RetrieveAPIView):
serializer_class = CollectionSerializer serializer_class = CollectionSerializer
@ -185,26 +178,23 @@ class CollectionExportView(AuthorizedResourceMixin, generics.RetrieveAPIView):
return Collection.objects.all() return Collection.objects.all()
return Collection.objects.filter(administrators=self.request.user) return Collection.objects.filter(administrators=self.request.user)
class WorkExportView(AuthorizedResourceMixin, generics.RetrieveAPIView): class WorkExportView(AuthorizedResourceMixin, generics.RetrieveAPIView):
serializer_class = WorkSerializer serializer_class = WorkSerializer
def get_queryset(self): def get_queryset(self):
works = Work.objects.filter(collection=self.kwargs["collection"]) works = Work.objects.filter(collection=self.kwargs['collection'])
if self.request.user.is_superuser: if self.request.user.is_superuser:
return works return works
return works.filter(collection__administrators=self.request.user) return works.filter(collection__administrators=self.request.user)
class WorkImportView(AuthorizedResourceMixin, generics.CreateAPIView): class WorkImportView(AuthorizedResourceMixin, generics.CreateAPIView):
serializer_class = WorkSerializer serializer_class = WorkSerializer
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(collection_id=self.kwargs["collection"]) serializer.save(collection_id=self.kwargs['collection'])
class CollectionImportView(AuthorizedResourceMixin, generics.CreateAPIView): class CollectionImportView(AuthorizedResourceMixin, generics.CreateAPIView):
serializer_class = CollectionSerializer serializer_class = CollectionSerializer
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(collection_id=self.kwargs["pk"]) serializer.save(collection_id=self.kwargs['pk'])

View File

@ -1,13 +1,12 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Django's command-line utility for administrative tasks.""" """Django's command-line utility for administrative tasks."""
import os import os
import sys import sys
def main(): def main():
"""Run administrative tasks.""" """Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polyphonic.config.settings.base") os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'polyphonic.settings')
try: try:
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
except ImportError as exc: except ImportError as exc:
@ -19,5 +18,5 @@ def main():
execute_from_command_line(sys.argv) execute_from_command_line(sys.argv)
if __name__ == "__main__": if __name__ == '__main__':
main() main()

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,138 @@
"""
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

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

View File

@ -13,20 +13,18 @@ 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, include from django.urls import path, re_path, include
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path('admin/', admin.site.urls),
path("", include("polyphonic.interface.urls")), path('', include('interface.urls')),
# path('', include('submissions.urls')), #path('', include('submissions.urls')),
path("", include("polyphonic.library.urls")), path('', include('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.config.settings.base") os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'polyphonic.settings')
application = get_wsgi_application() application = get_wsgi_application()

View File

@ -1,11 +1,12 @@
asgiref==3.8.1 asgiref==3.4.1
boto3==1.18.34 boto3==1.18.34
botocore==1.21.34 botocore==1.21.34
certifi==2022.12.7 certifi==2022.12.7
charset-normalizer==2.1.1 charset-normalizer==2.1.1
crispy-bulma==0.8.0 crispy-bulma==0.8.0
Django==5.1 Django==3.2.7
django-byostorage @ git+https://gitea.tfconsulting.com.au/tris/django-byostorage.git@9903bb00888f20dfd2d39754e5ee22eeb5f36298 #-e git+ssh://git@gitea.tfconsulting.com.au/tris/django-byostorage.git@227ce5fccb5864657f080c25528263b655c2b6b7#egg=django_byostorage
git+https://gitea.tfconsulting.com.au/tris/django-byostorage.git
django-crispy-forms==1.14.0 django-crispy-forms==1.14.0
django-markdown2==0.3.1 django-markdown2==0.3.1
django-rest-framework==0.1.0 django-rest-framework==0.1.0
@ -13,14 +14,11 @@ django-storages==1.13.1
django_debug_toolbar==3.8.1 django_debug_toolbar==3.8.1
djangorestframework==3.14.0 djangorestframework==3.14.0
djantic==0.7.0 djantic==0.7.0
greenlet==3.0.3
idna==3.4 idna==3.4
jmespath==0.10.0 jmespath==0.10.0
markdown2==2.4.1 markdown2==2.4.1
msgpack==1.0.8
pydantic==1.10.2 pydantic==1.10.2
pydantic-django==0.1.1 pydantic-django==0.1.1
pynvim==0.5.0
python-dateutil==2.8.2 python-dateutil==2.8.2
pytz==2021.1 pytz==2021.1
requests==2.28.1 requests==2.28.1

View File

@ -1,14 +0,0 @@
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

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