Refactored into /app

This commit is contained in:
Tris Forster
2022-11-28 14:11:39 +11:00
parent 94bba3769a
commit 53ec846f98
71 changed files with 9 additions and 12 deletions
+2
View File
@@ -0,0 +1,2 @@
pylint==2.6.0
django-debug-toolbar
View File
+29
View File
@@ -0,0 +1,29 @@
from django.contrib import admin
from . import models
class EnsembleAdmin(admin.ModelAdmin):
list_display = ['name', 'ensemble_code', 'slug']
class ModuleInline(admin.StackedInline):
model = models.Module
extra = 0
class ProjectAdmin(admin.ModelAdmin):
list_display = ['name', 'ensemble', 'event_date', 'active']
list_filter = ['ensemble', 'active']
inlines = [ModuleInline]
class ResourceAdmin(admin.ModelAdmin):
list_display = ['name', 'media_type', 'project']
list_filter = ['project']
class WikiPageAdmin(admin.ModelAdmin):
list_display = ['title', 'project']
list_filter = ['project']
admin.site.register(models.Ensemble, EnsembleAdmin)
admin.site.register(models.Project, ProjectAdmin)
admin.site.register(models.Resource, ResourceAdmin)
admin.site.register(models.WikiPage, WikiPageAdmin)
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class InterfaceConfig(AppConfig):
name = 'interface'
+4
View File
@@ -0,0 +1,4 @@
from crispy_forms.layout import Field
class BulmaFileUpload(Field):
template = 'bulma/file_upload.html'
+46
View File
@@ -0,0 +1,46 @@
from django import forms
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit
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'))
return helper
class ProjectForm(forms.ModelForm, BaseForm):
class Meta:
model = models.Project
fields = ['name', 'description', 'event_date']
widgets = {
'event_date': forms.DateTimeInput(attrs={'type': 'date'})
}
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 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()
+80
View File
@@ -0,0 +1,80 @@
# Generated by Django 3.2.7 on 2022-11-19 01:24
import byostorage.user
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import interface.models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('byostorage', '0006_alter_userstorage_settings_data'),
]
operations = [
migrations.CreateModel(
name='Ensemble',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Display name', max_length=100)),
('slug', models.SlugField(editable=False, help_text='Short name for the ensemble - used for folders', max_length=100, unique=True)),
('code', models.CharField(default=interface.models.generate_code, help_text='Ensemble registration code', max_length=9)),
('passphrase', models.CharField(help_text='Used to register ensembles', max_length=100)),
('details', models.TextField(blank=True, help_text='Description of the ensemble (markdown)')),
('admins', models.ManyToManyField(related_name='ensembles', to=settings.AUTH_USER_MODEL)),
('storage', models.ForeignKey(help_text='Default storage for this ensemble', null=True, on_delete=django.db.models.deletion.SET_NULL, to='byostorage.userstorage')),
],
),
migrations.CreateModel(
name='Project',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('description', models.TextField(blank=True, help_text='Markdown format')),
('active', models.BooleanField(default=True)),
('event_date', models.DateTimeField(blank=True, null=True)),
('owner', models.CharField(blank=True, max_length=255)),
('ensemble', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='interface.ensemble')),
],
options={
'ordering': ['active', '-pk'],
},
),
migrations.CreateModel(
name='WikiPage',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
('markdown', models.TextField()),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='wiki_pages', to='interface.project')),
],
),
migrations.CreateModel(
name='Resource',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('description', models.TextField(blank=True)),
('file', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=interface.models.resource_key)),
('media_type', models.CharField(choices=[('audio', 'Audio'), ('video', 'Video'), ('general', 'General')], default='*', max_length=10)),
('visible', models.BooleanField(default=True)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='resources', to='interface.project')),
],
options={
'ordering': ['-visible', '-pk'],
},
),
migrations.CreateModel(
name='Module',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.SlugField(choices=[('library', 'Library')], max_length=20)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modules', to='interface.project')),
],
),
]
+157
View File
@@ -0,0 +1,157 @@
from django.db import models
from django.utils.text import slugify
from django.utils import timezone
from django.conf import settings
from django.shortcuts import resolve_url
from byostorage.user import BYOStorage
import random
from datetime import datetime
from urllib.parse import urlparse
import os.path
MEDIA_TYPES = [
('audio', "Audio"),
('video', "Video"),
('general', "General"),
]
def rough_date(d):
if not d:
return False, "sometime..."
days = (d - timezone.now()).days
in_past = days < 0
if in_past:
days = abs(days)
if days ==0:
return "today!"
if days >= 7:
return in_past, "{0:d} weeks, {1:d} days".format(int(days / 7), int(days % 7))
return in_past, f"{days} days"
def generate_code(length=9):
return "".join([ random.choice('0123456789') for _ in range(length) ])
class Ensemble(models.Model):
''' A group that plays together
'''
name = models.CharField(max_length=100,
help_text="Display name")
slug = models.SlugField(max_length=100, editable=False, unique=True,
help_text="Short name for the ensemble - used for folders")
code = models.CharField(max_length=9, default=generate_code,
help_text="Ensemble registration code")
passphrase = models.CharField(max_length=100,
help_text="Used to register ensembles")
admins = models.ManyToManyField('auth.User', related_name='ensembles')
details = models.TextField(blank=True,
help_text="Description of the ensemble (markdown)")
storage = models.ForeignKey('byostorage.UserStorage', null=True, on_delete=models.SET_NULL,
help_text="Default storage for this ensemble")
def active_projects(self):
return self.projects.filter(active=True).order_by('event_date')
def ensemble_code(self):
code = str(self.code)
return "{}-{}-{}".format(code[:3], code[3:6], code[6:])
def save(self, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super(Ensemble, self).save(**kwargs)
def __str__(self):
return self.name
class Project(models.Model):
''' A Project linked to an ensemble
'''
name = models.CharField(max_length=100)
ensemble = models.ForeignKey(Ensemble, related_name='projects', on_delete=models.CASCADE, null=True)
description = models.TextField(blank=True,
help_text="Markdown format")
active = models.BooleanField(default=True)
event_date =models.DateTimeField(null=True, blank=True)
owner = models.CharField(max_length=255, blank=True)
class Meta:
ordering = ['active', '-pk']
@property
def days(self):
return (self.event_date - timezone.now().date()).days
@property
def has_happened(self):
if not self.event_date:
return False
return self.event_date < timezone.now()
@property
def rough_date(self):
in_past, s = rough_date(self.event_date)
if in_past:
return f"{s} ago"
return f"In {s}"
@property
def folder(self):
print(f"{self.ensemble.storage_id}:{self.ensemble.slug}/{self.slug}")
return f"{self.ensemble.storage_id}:{self.ensemble.slug}/{self.slug}"
def __str__(self):
return self.name
class Module(models.Model):
''' Enable modules on a oriject
'''
name = models.SlugField(max_length=20, choices=[ (x, x.title()) for x in settings.POLYPHONIC_MODULES ])
project = models.ForeignKey(Project, related_name="modules", on_delete=models.CASCADE)
def resource_key(resource, filename):
return f'{resource.project.folder}/resources/{filename}'
class Resource(models.Model):
''' A viewable file resource attached to a project
e.g PDF instructions, MP3 backing track
'''
project = models.ForeignKey(Project, related_name='resources', on_delete=models.CASCADE)
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
file = models.FileField(storage=BYOStorage(), upload_to=resource_key)
media_type = models.CharField(max_length=10, choices=MEDIA_TYPES, default='*')
visible = models.BooleanField(default=True)
class Meta:
ordering = ['-visible', '-pk']
def accept(self):
if self.media_type == 'general':
return ".*"
return f"{self.media_type}/*"
def __str__(self):
return self.name
class WikiPage(models.Model):
''' An editable wiki page for the project in markdown format
'''
project = models.ForeignKey(Project, related_name='wiki_pages', on_delete=models.CASCADE)
title = models.CharField(max_length=255)
markdown = models.TextField()
def get_absolute_url(self):
return resolve_url('wiki', project=self.project_id, pk=self.pk)
def __str__(self):
return self.title
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

@@ -0,0 +1,63 @@
@font-face {
font-family: MartinHand;
src: url('/static/fonts/Martinhand3.ttf');
}
:root {
--primary: #485fc7;
}
.fancy {
font-family: MartinHand;
color: var(--primary);
}
.has-text-shadow {
text-shadow: 1px 1px 2px #000;
}
.is-form-group {
max-width: 600px;
align-self: center;
}
.is-centered {
margin: auto;
}
.is-action {
cursor: pointer;
}
.menu-label {
color: var(--primary);
}
.button.is-primary, .button.is-primary:hover {
background-color: var(--primary);
}
A.admin-link:after {
content: "*";
}
TEXTAREA.input {
height: 400px;
}
.control INPUT[type='file'] {
border: none;
width: 80%;
text-align: center;
margin: auto 10%;
}
.project-footer {
position: fixed;
bottom: 0px;
right: 0px;
background-color: #EEE;
padding: 10px 10px;
margin-top: 30px;
border-top-left-radius: 6px;
}
+55
View File
@@ -0,0 +1,55 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="{% static 'interface/icon.png' %}" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css">
<link rel="stylesheet" href="{% static 'interface/css/polyphonic.css' %}"></link>
<script src="{% static 'interface/js/interface.js' %}"></script>
<title>{% block title %}Polyphonic{% endblock %}</title>
{% block media %}{% endblock %}
</head>
<body>
{% block navigation %}
<nav class="navbar" role="navigation">
<div class="navbar-brand has-text-primary">
<a class="navbar-item" href="/">
<span class="icon fancy is-size-2 mx-4"><i class="fas fa-random"></i></span>
<span class="fancy is-size-2">Polyphonic</span>
</a>
<span class="navbar-item is-hidden-mobile fancy is-size-5">Music Ensemble Manager</span>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="projectMenu">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarMain" class="navbar-menu">
<div class="navbar-end">
<span class="navbar-item is-size-4">{% firstof ensemble project.ensemble %}</span>
</div>
</div>
</nav>
{% endblock %}
{% block content %}
<h1>No content!</h1>
{% endblock %}
<!-- late load scripts -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="https://kit.fontawesome.com/c837098e5b.js" crossorigin="anonymous"></script>
{% block scripts %}
{% endblock %}
</body>
</html>
@@ -0,0 +1,28 @@
{% load crispy_forms_field %}
<div class="field">
<div id="div_id_{{ field.name }}" class="file has-name is-fullwidth">
<label class="file-label">
{% crispy_field field 'class' 'file-input'%}
<span class="file-cta">
<span class="file-icon">
<i class="fas fa-upload"></i>
</span>
<span class="file-label">
Choose a file…
</span>
</span>
<span class="file-name"></span>
</label>
</div>
</div>
<script>
const fileInput = document.querySelector('#div_id_{{ field.name }} input[type=file]');
fileInput.onchange = () => {
if (fileInput.files.length > 0) {
const fileName = document.querySelector('#div_id_{{ field.name }} .file-name');
fileName.textContent = fileInput.files[0].name;
}
}
</script>
@@ -0,0 +1,15 @@
{% extends "interface/project_base.html" %}
{% load crispy_forms_tags %}
{% block media %}
{{ form.media }}
{% endblock %}
{% block page %}
<h3 class="subtitle">{% firstof title view.title %}</h3>
<div class="columns is-centered">
<div class="column is-form-group">
{% crispy form %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block content %}
<h2>{{ ensemble.name }}</h2>
<p>{{ ensemble.details }}</p>
<h4>Administrators</h4>
<ul>
{% for admin in ensemble.admins.all %}
<li><a href="mailto:{{ admin.email }}">{% firstof admin.get_full_name admin.get_username %}</a></li>
{% endfor %}
</ul>
{% endblock %}
@@ -0,0 +1,41 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% block page %}
<div class="admin-tools is-pulled-right">
<a class="button is-link" href="{% url 'register' %}">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Register another</span>
</a>
</div>
<h3 class="title">My Ensembles</h3>
<div class="columns is-multiline">
{% for ensemble in object_list %}
<div class="column is-half">
<div class="card">
<header class="card-header{% if ensemble.pk == ensemble_id %} has-background-link-light{% endif %}">
<a class="card-header-title" href="{% url 'register' %}?code={{ ensemble.code }}">
{{ ensemble.name }}
</a>
<a class="card-header-icon" href="{% url 'ensemble_forget' pk=ensemble.id %}">
<span class="delete"></span>
</a>
</header>
<div class="card-content">
{% if ensemble.details %}
<div class="content">
{{ ensemble.details | markdown }}
</div>
{% endif %}
<div>
{% with projects=ensemble.active_projects.count %}
<p>{{ projects }} active project{{ projects|pluralize }}</p>
{% endwith %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endblock %}
@@ -0,0 +1,73 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% block admin %}
<a href="{% url 'project_create' %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add new</span>
</a>
{% endblock %}
{% block page %}
<h3 class="title">Projects for {{ ensemble.name }}</h3>
<div class="columns is-multiline">
{% for project in ensemble.active_projects %}
<div class="column is-half">
<div class="card">
<a class="" href="{% url 'project_detail' project=project.id %}">
<header class="card-header">
<p class="card-header-title">{{ project.name }}</p>
<p class="card-header-icon" style="color: black;">{{ project.rough_date }}</p>
</header>
</a>
<div class="card-content">
<div class="content">
{{ project.description | markdown }}
</div>
<p><small>
{% if project.deadline %}In {{ project.deadline|timeuntil }}<br/>{% endif %}
{% if project.works.count %}
<a href="{% url 'item_list' project=project.pk %}">
{{ project.works.count }} works
</a>
<br/>
{% endif %}
{% if project.submissions.count %}{{ project.submissions.count }} submissions<br/>{% endif %}
</small></p>
</div>
</div>
</div>
{% empty %}
<div class="hero">
<div class="hero-body">
<p class="title">No projects currently planned</p>
<p class="subtitle">Go put your feet up!</p>
</div>
</div>
{% endfor %}
</div>
{% if request.is_admin %}
<div class="">
<div class="card">
<header class="card header">
<p class="card-header-title">Admin Details</p>
</header>
<div class="card-content">
<p>
Joining instructions for participants<br/><br/>
URL: <a href="{{ ensemble_url }}">{{ ensemble_url }}</a><br/>
Code: {{ ensemble.ensemble_code }}<br/>
Passphrase: {{ ensemble.passphrase }}
</p>
</div>
</div>
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block content %}
<div class="narrow">
<h3>Manage {{ ensemble.name }}</h3>
<p>
Joining instructions for participants<br/><br/>
URL: <a href="{{ ensemble_url }}">{{ ensemble_url }}</a><br/>
Passphrase: {{ ensemble.passphrase }}
</p>
<ul>
<li><a href="{% url 'work_list' %}">Library</a></li>
</ul>
<p>
Sorry, not much you can do here yet.
<ul>
<li>Should be able to create new projects.</li>
{% if request.user.is_superuser %}
<li><a href="/admin">Django Admin</a></li>
{% endif %}
</ul>
<br/>
Logged in as {{ request.user }} [<a href="{% url 'logout' %}">Logout</a>]
</p>
</div>
{% endblock %}
@@ -0,0 +1,110 @@
{% extends "base.html" %}
{% block content %}
<div class="columns">
<div class="column is-narrow is-hidden-touch" id="projectMenu">
<div style="margin: auto 2em;">
<aside class="menu">
<p class="menu-label">My Things</p>
<ul class="menu-list">
<li><a href="{% url 'ensemble_list' %}">Ensembles</a></li>
<li><a href="{% url 'ensemble_detail' %}">Projects</a></li>
<li><a href="{% url 'work_list' %}">Library</a></li>
</ul>
{% if project %}
<p class="menu-label">This Project</p>
<ul class="menu-list">
<li><a role="tab" href="{% url 'project_detail' project=project.id %}">Project Info</a></li>
{% for page in project.wiki_pages.all %}
<li><a class="nav-link {% if page.id == wiki_id %}active{% endif %}"
href="{% url 'wiki' project=project.id pk=page.id %}">{{ page.title }}</a></li>
{% endfor %}
<li><a role="tab" href="{% url 'resource_list' project=project.pk %}">Resources</a></li>
{% if 'library' in modules %}
<li><a href="{% url 'item_list' project=project.id %}">My Music</a></li>
{% endif %}
{% if 'submission' in modules %}
<!--a role="tab" href="">Record a submission</a-->
{% if request.is_admin %}
<li><a role="tab" class="admin-link" href="{% url 'submission_list' project=project.id %}">Submissions</a></li>
{% endif %}
<li><a role="tab" href="{% url 'submission_create' project=project.id %}">Send a File</a></li>
{% endif %}
</ul>
{% endif %}
<p class="menu-label">Admin</p>
{% if request.is_admin %}
<ul class="menu-list">
<li><a class="admin-link" href="{% url 'collection_list' %}">Collections</a></li>
{% if request.user.is_superuser %}
<li><a class="admin-link" href="/admin" target="polyphonic_admin" rel="noopener noreferrer">Django Admin</a></li>
{% endif %}
</ul>
{% endif %}
<ul class="menu-list">
{% if request.user.is_authenticated %}
<li><a href="{% url 'logout' %}">Logout</a></li>
{% else %}
<li><a href="{% url 'login' %}">Login</a></li>
{% endif %}
</ul>
</aside>
</div>
</div>
<div class="column">
{% if project %}
<div class="tabs is-centered is-hidden-desktop">
<ul>
<li><a href="">Info</a></li>
{% if project.wiki_pages.count %}
<li><a href="">Pages</a></li>
{% endif %}
{% if project.resources.count %}
<li><a href="{% url 'resource_list' project=project.pk %}">Resources</a></li>
{% endif %}
{% if project.enable_library %}
<li><a href="{% url 'item_list' project=project.pk %}">My Music</a></li>
{% endif %}
{% if project.enable_submissions %}
<li><a href="{% url 'submission_create' project=project.pk %}">Send File</a></li>
{% endif %}
</ul>
</div>
{% endif %}
<section class="section">
{% if request.is_admin %}
<div class="admin-tools is-pulled-right">
{% block admin %}
{% endblock %}
</div>
{% endif %}
{% if project %}<h3 class="title">{{ project.name }}</h3>{% endif %}
{% block page %}
No content
{% endblock %}
</section>
</div>
</div>
{% if ensemble %}
<div class="project-footer">
{{ ensemble.name }}
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,62 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% load polyphonic %}
{% block admin %}
<a href="{% url 'wiki_create' project=project.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-file"></i></span>
<span>Add Page</span>
</a>
<a href="{% url 'project_edit' project=project.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-edit"></i></span>
<span>Edit</span>
</a>
{% endblock %}
{% block page %}
<div class="box">
{% if project.event_date %}
<h3 class="subtitle is-centered">
<b>{{ project.event_date|date:"l jS F Y, g:i A" }}</b>
{% if project.has_happened %}
({{ project.event_date|roughtimesince }} ago)
{% else %}
(in {{ project.event_date|roughtimeuntil }}...)
{% endif %}
</h3>
{% endif %}
<div class="block">
{% if project.description %}
<p class="content">{{ project.description|markdown }}</p>
{% else %}
<p>No description</p>
{% endif %}
</div>
{% if 'library' in modules %}
<div class="block">
{% include 'library/project_detail.html' %}
</div>
{% endif %}
{% if 'submission' in modules %}
<div class="block">
{% include 'submissions/project_detail.html' %}
</div>
{% endif %}
{% if project.owner %}
<div class="block">
{% if project.owner.email %}
The project owner is <a href="mailto:{{ project.owner.email }}">{{ project.owner }}</a>
{% else %}
The project owner is {{ project.owner }}.
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,18 @@
{% extends "interface/project_base.html" %}
{% load crispy_forms_tags %}
{% block media %}
{{ form.media }}
{% endblock %}
{% block page %}
<h3 class="subtitle">{% firstof title view.title %}</h3>
<div class="columns is-centered">
<div class="column is-form-group">
{% if instructions %}
<p>{{ instructions }}</p>
{% endif %}
{% crispy form %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<section class="section">
{% if not request.user.is_authenticated %}
<a href="{% url 'login' %}" style="float: right"><i class="fa fa-key"></i></a>
{% endif %}
<div class="columns is-centered">
<div class="box is-half">
<h3 class="title">Join an ensemble</h3>
<form action="" method="POST">
{% csrf_token %}
{{ form | crispy }}
<div class="field is-grouped">
<div class="control">
<button class="button is-link">Register</button>
</div>
</div>
</form>
</div>
</div>
</section>
{% endblock %}
@@ -0,0 +1,58 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% block admin %}
<a class="button is-link" href="{% url 'resource_create' project=project.pk %}">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add new</span>
</a>
{% endblock %}
{% block page %}
<div class="columns is-multiline">
{% for resource in object_list %}
{% with download=resource.file.url %}
<div class="column is-half">
<div class="card {% if not object.visible %}disabled{% endif %}">
<div class="card-header">
<div class="card-header-title">
{% if download %}
<a href="{{ download }}">{{ resource.name }}</a>
{% else %}
{{ resource.name }}
{% endif %}
</div>
<div class="card-header-icon">
{% if request.is_admin %}
<a href="{% url 'resource_upload' project=project.pk pk=resource.pk %}" class="icon" title="Upload">
<i class="fas fa-upload"></i>
</a>
<a href="{% url 'resource_edit' project=project.pk pk=resource.pk %}" class="icon" title="Edit">
<i class="fas fa-edit"></i>
</a>
{% endif %}
</div>
</div>
<div class="card-content">
<p>
<small>{{ resource.description|markdown }}</small>
{% if not resource.visible %}
<br/>(This resource is hidden from participants)
{% endif %}
</p>
{% if download and resource.media_type == 'audio' %}
<audio class="resource-player" controls src="{{ download }}" style="width: 100%;"></audio>
{% endif %}
</div>
</div>
</div>
{% endwith %}
{% empty %}
<div class="column">
<p>There are resources for this project</p>
</div>
{% endfor %}
</div>
{% endblock %}
@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="narrow">
<h3 id="status">File upload</h3>
<form method="POST" action="{{ upload.url }}" enctype="multipart/form-data" id="item-upload" class="dropzone">
<div class="fallback">
{% for field, value in upload.fields.items %}
<input type="hidden" name="{{ field }}" value="{{ value }}" />
{% endfor %}
<input name="file" type="file" accept="{{ accept_files }}/*" id="fallback-file"/>
<input type="submit" value="Send" id="fallback-upload"/>
</div>
</form>
<div class="form-actions text-right">
<a class=""
href="{{ cancel_url }}">Cancel</a>
&nbsp;
<button type="submit" id="upload" disabled>Upload</button>
</div>
</div>
{% endblock %}
{% block scripts %}
<link rel="stylesheet" href="{% static 'dropzone/dropzone.css' %}"/>
<script src="{% static 'dropzone/dropzone.js' %}"></script>
{{ ajax_upload|json_script:"dropzone-data" }}
<script type="text/javascript">
const dzData = JSON.parse(document.getElementById('dropzone-data').textContent);
console.log(dzData);
let status = $('#status');
let uploadBtn = $('#upload');
$('#fallback-upload').on('click', function() {
status.html('Uploading - please wait...');
}).attr('disabled', true);
$('#fallback-file').on('change', function() {
status.html('Ready to upload');
$('#fallback-upload').attr('disabled', false);
});
let fallback = {{ request.GET.fallback|yesno:"true,false,false" }};
Dropzone.options.itemUpload = {
params: dzData['fields'],
forceFallback: fallback,
autoProcessQueue: true,
createImageThumbnails: false,
//acceptedFiles: acceptFiles,
timeout: 36000000,
maxFiles: 1,
addRemoveLinks: true,
maxFilesize: 500,
dictDefaultMessage: "Click to select a file to upload (max 500Mb)",
dictFallbackMessage: "Your browser is old!, using alternative method",
init: function() {
let dz = this;
uploadBtn.on('click', function() {
dz.processQueue();
});
this.on('addedfile', function(f) {
status.html('Ready to upload...');
uploadBtn.attr('disabled', false);
});
this.on('sending', function(f) {
status.html('Uploading - please wait...');
uploadBtn.attr('disabled', true);
});
this.on('success', function(f) {
status.html("Upload successfull");
//if (! confirm("Upload complete - redirect?")) return;
let s3_location = f.xhr.getResponseHeader("Location");
location.href = "{{ success_url }}?location=" + s3_location;
});
this.on('error', function(file, msg) {
status.html('There was an issue - please see troubleshooting');
});
}
};
</script>
{% endblock %}
@@ -0,0 +1,15 @@
{% extends "interface/project_base.html" %}
{% block admin %}
<a href="{% url 'wiki_edit' project=project.pk pk=wikipage.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-edit"></i></span>
<span>Edit</span>
</a>
{% endblock %}
{% block page %}
<h3 class="subtitle">{{ wikipage.title }}</h3>
<div class="box content wiki-page">
{{ wiki_html|safe }}
</div>
{% endblock %}
@@ -0,0 +1,21 @@
{% extends "interface/project_base.html" %}
{% load crispy_forms_tags %}
{% block page %}
<style>
TEXTAREA {
height: 200px;
}
FORM.vertical {
max-width: 90%;
}
</style>
<div>
<h3>{{ title }}</h3>
<p>{{ instructions }}</p>
<form class="vertical" method="POST">
{% csrf_token %}
{{ form | crispy }}
</form>
</div>
{% endblock %}
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<section class="section">
<div class="columns is-centered">
<div class="box is-half">
<p class="block">
Login is only required to administer a project.<br/>
If you have an ensemble code <a href="{% url 'register' %}">enter it here</a> instead.
</p>
<form method="POST" class="vertical">
{% csrf_token %}
{{ form | crispy }}
<div class="field is-grouped">
<div class="control">
<button class="button is-link">Login</button>
<a href="{% url 'ensemble_detail' %}" class="button is-light">Cancel</a>
</div>
</div>
</form>
</div>
</div>
</section>
{% endblock %}
@@ -0,0 +1,9 @@
from django import template
import os.path
register = template.Library()
def basename(value):
return os.path.basename(value)
register.filter('basename', basename)
+14
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)
+10
View File
@@ -0,0 +1,10 @@
from django import template
register = template.Library()
@register.simple_tag(takes_context=True)
def url_update(context, **kwargs):
params = context.request.GET.copy()
for k in kwargs:
params[k] = kwargs[k]
return "?" + params.urlencode()
View File
+6
View File
@@ -0,0 +1,6 @@
from django.test import TestCase
class IntegrationTestCase(TestCase):
def test_runs(self):
self.assertTrue(True)
+63
View File
@@ -0,0 +1,63 @@
from django.test import TestCase, Client
from interface import models
class RegisterTestCase(TestCase):
def setUp(self):
self.client = Client()
@staticmethod
def setUpTestData():
e1 = models.Ensemble.objects.create(name='The Be Sharps', code="1234", passphrase='Homer')
e1.projects.create(name='Baby on Board')
e2 = models.Ensemble.objects.create(name='Lisa and the Bleeding Gums', code="2345", passphrase="Maggie")
e2.projects.create(name='Baker St')
def test_redirect(self):
response = self.client.get('/')
self.assertRedirects(response, '/register?')
def test_redirect_project(self):
response = self.client.get('/projects/1')
self.assertRedirects(response, '/register?')
def test_redirect_with_code(self):
response = self.client.get('/?code=123-456-789')
self.assertRedirects(response, '/register?code=123-456-789')
def test_register(self):
response = self.client.post('/register', {'code': '123-456-789', })
self.assertFormError(response, 'form', 'passphrase', 'This field is required.')
response = self.client.post('/register', {'code': '123-456-789', 'passphrase': 'Foo'})
self.assertFormError(response, 'form', None, 'Incorrect code or passphrase')
response = self.client.post('/register', {'code': '12-34', 'passphrase': 'Homer'})
self.assertRedirects(response, '/')
response = self.client.get(response.url)
self.assertEqual(response.context['object'].pk, 1)
# revisting original url get redirected back to homepage
response = self.client.get('/?code=12-34')
response = self.client.get(response.url)
response = self.client.get(response.url)
self.assertEqual(response.context['object'].pk, 1)
# providing a new code
response = self.client.get('/?code=23-45')
self.assertRedirects(response, '/register?code=23-45')
response = self.client.get(response.url)
#self.assertQuerysetEqual(response.context['current'], ['<Ensemble: The Be Sharps>'])
#self.assertEqual(response.context['form'].code.initial, 'foo')
response = self.client.post('/register', {'code': '23-45', 'passphrase': 'maggie'})
self.assertRedirects(response, '/')
response = self.client.get('/')
self.assertEqual(response.context['object'].pk, 2)
# can use previous link to switch back without passphrase
response = self.client.get('/?code=12-34')
response = self.client.get(response.url)
response = self.client.get(response.url)
self.assertEqual(response.context['object'].pk, 1)
+30
View File
@@ -0,0 +1,30 @@
from django.urls import path
from django.contrib.auth import views as auth_views
from . import views
urlpatterns = [
path('login', auth_views.LoginView.as_view(), name='login'),
path('logout', views.logout, name='logout'),
path('register', views.register, name="register"),
path('', views.EnsembleProjectListView.as_view(), name='ensemble_detail'),
path('ensembles', views.EnsembleListView.as_view(), name="ensemble_list"),
path('ensembles/<int:pk>', views.EnsembleDetailView.as_view(), name='ensemble_detail'),
path('ensembles/<int:pk>/forget', views.EnsembleForgetView.as_view(), name='ensemble_forget'),
path('projects/create', views.ProjectCreateView.as_view(), name="project_create"),
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"),
]
+334
View File
@@ -0,0 +1,334 @@
from django.shortcuts import render, get_object_or_404, redirect, resolve_url
from django.views.generic import TemplateView, RedirectView
from django.views.generic.detail import DetailView
from django.views.generic.list import ListView
from django.views.generic.edit import CreateView, UpdateView
from django.core.exceptions import SuspiciousOperation
from django.contrib import auth
from markdown2 import markdown
from . import models, forms
import logging
logger = logging.getLogger(__name__)
#from django.core.signing import Signer
#signer = Signer()
#def signed_url(name, **kwargs):
# url = resolve_url(name, **kwargs)
# sig = signer.sign(url)
# return sig.replace(":", "?auth=")
class EnsembleMixin(object):
admin_required = False
def dispatch(self, request, *args, **kwargs):
request.ensemble_id = request.session.get('ensemble')
request.is_admin = request.user.is_superuser
if request.is_admin:
if request.ensemble_id is None:
return redirect('ensemble_list')
return super().dispatch(request, *args, **kwargs)
# if 'auth' in request.GET:
# sig = signer.sign(request.path)
# if sig[len(request.path)+1:] == request.GET['auth']:
# logger.info("Allowing auth key")
# request.is_admin = True
# return super().dispatch(request, *args, **kwargs)
# else:
# raise SuspiciousOperation("Bad auth code")
if request.user.is_authenticated:
try:
request.user.ensembles.get(pk=request.ensemble_id)
request.is_admin = True
except models.Ensemble.DoesNotExist:
pass
if not request.ensemble_id:
return redirect('register')
if self.admin_required and not request.is_admin:
return redirect('login')
return super().dispatch(request, *args, **kwargs)
@property
def ensemble(self):
return models.Ensemble.objects.get(pk=self.request.ensemble_id)
#def get_context_data(self, **kwargs):
# context = super().get_context_data(**kwargs)
# context['ensemble'] = self.ensemble
# return context
class ProjectMixin(EnsembleMixin):
def get_project(self):
if not hasattr(self, '_project'):
if self.request.is_admin: # can access any ensemble
self._project = get_object_or_404(models.Project, pk=self.kwargs['project'])
else:
self._project = get_object_or_404(models.Project,
pk=self.kwargs['project'], ensemble=self.request.ensemble_id)
return self._project
def get_queryset(self):
return super().get_queryset().filter(project=self.get_project())
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['project'] = self.get_project()
context['modules'] = context['project'].modules.values_list('name', flat=True)
return context
def register(request):
if 'clear' in request.GET:
request.session.clear()
request.ensemble_id = request.session.get('ensemble')
registered = request.session.setdefault('registered', [])
code = request.GET.get('code', '').replace('-', '')
logger.debug("Registering with code %s", code)
# check if already joined
if code in registered or request.user.is_superuser:
request.session['ensemble'] = models.Ensemble.objects.get(code=code).pk
return redirect('ensemble_detail')
if request.method == "POST":
form = forms.CodeForm(request.POST)
if form.is_valid():
data = form.cleaned_data
try:
ensemble = models.Ensemble.objects.get(code=data['code'].replace('-', ''))
if ensemble.passphrase.lower() == data['passphrase'].lower():
request.session['ensemble'] = ensemble.pk
registered.append(ensemble.code)
return redirect('ensemble_detail')
except models.Ensemble.DoesNotExist:
form.add_error(None, "Incorrect code or passphrase")
else:
form = forms.CodeForm(initial=request.GET)
if request.user.is_superuser:
current = models.Ensemble.objects.all()
else:
current = models.Ensemble.objects.filter(pk__in=registered)
return render(request, 'interface/register.html', {'form': form, 'current': current})
def on_login(sender, **kwargs):
user = kwargs['user']
request = kwargs['request']
registered = request.session.get('registered', [])
for e in user.ensembles.all():
if not e.code in registered:
registered.append(e.code)
request.session['registered'] = registered
auth.signals.user_logged_in.connect(on_login)
def logout(request):
ensemble = request.session.get('ensemble')
registered = request.session.get('registered', {})
auth.logout(request)
request.session['ensemble'] = ensemble
request.session['registered'] = registered
return redirect('/')
class EnsembleForgetView(EnsembleMixin, RedirectView):
def get_redirect_url(self, *args, **kwargs):
registered = self.request.session.setdefault('registered', [])
ensemble = models.Ensemble.objects.get(pk=self.kwargs['pk'])
try:
registered.remove(ensemble.code)
self.request.session['registered'] = registered
except KeyError:
pass
if self.request.ensemble_id == ensemble.pk:
del(self.request.session['ensemble'])
return resolve_url('ensemble_detail')
class EnsembleListView(ListView):
def get_queryset(self):
registered = self.request.session.get('registered', [])
if self.request.user.is_superuser:
current = models.Ensemble.objects.all()
else:
current = models.Ensemble.objects.filter(code__in=registered)
return current
#def get_context_data(self, **kwargs):
# context = super().get_context_data(**kwargs)
# context['ensemble_id'] = self.request.session.get('ensemble')
# return context
class EnsembleProjectListView(EnsembleMixin, DetailView):
template_name = 'interface/ensemble_project_list.html'
def dispatch(self, request, *args, **kwargs):
# capture provided urls
if 'code' in request.GET:
return redirect('/register?code={0}'.format(request.GET['code']))
return super().dispatch(request, *args, **kwargs)
def get_object(self):
return models.Ensemble.objects.get(pk=self.request.ensemble_id)
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
if self.request.is_admin:
data['ensemble_url'] = self.request.build_absolute_uri('/?code={0}'.format(self.ensemble.ensemble_code()))
return data
class EnsembleDetailView(DetailView):
model = models.Ensemble
class ProjectDetailView(ProjectMixin, DetailView):
def get_object(self):
return self.get_project()
class ProjectCreateView(EnsembleMixin, CreateView):
model = models.Project
template_name = "interface/default_form.html"
title = "Add a new project"
form_class = forms.ProjectForm
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.ensemble_id = self.request.ensemble_id
self.object.owner = self.request.user
self.object.save()
return redirect('project_detail', project=self.object.pk)
class ProjectUpdateView(EnsembleMixin, UpdateView):
model = models.Project
template_name = "interface/default_form.html"
#fields = ['name', 'description', 'event_date', 'enable_library', 'enable_submissions', 'active']
pk_url_kwarg = 'project'
form_class = forms.ProjectForm
def get_success_url(self):
return resolve_url('project_detail', project=self.kwargs['project'])
#class ProjectMakefileView(EnsembleMixin, DetailView):
# template_name = 'interface/project_submissions.mk'
# content_type = 'text/plain'
#
# def get_queryset(self):
# if self.request.is_admin:
# return models.Project.objects.all()
#
# return models.Project.objects.filter(ensemble=self.request.ensemble_id)
#
# def get_context_data(self, **kwargs):
# data = super().get_context_data(**kwargs)
#
# data['submissions'] = []
# data['targets'] = []
# for s in self.object.submissions:
# name = s.short_name
# data['targets'].append(name)
# data['submissions'].append({
# 'url': self.request.build_absolute_uri(signed_url('submission_download', project=self.kwargs['pk'], pk=s.pk)),
# 'name': name,
# })
#
# return data
class WikiView(ProjectMixin, DetailView):
template_name = 'interface/wiki.html'
model = models.WikiPage
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
data['wiki_html'] = markdown(self.object.markdown)
return data
class WikiCreateView(ProjectMixin, CreateView):
admin_required = True
model = models.WikiPage
fields = ['title', 'markdown']
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.project = self.get_project()
self.object.save()
return redirect('wiki', project=self.object.project_id, pk=self.object.pk)
class WikiEditView(ProjectMixin, UpdateView):
admin_required = True
model = models.WikiPage
fields = ['title', 'markdown']
class ResourceCreateView(ProjectMixin, CreateView):
model = models.Resource
form_class = forms.ResourceForm
template_name = 'interface/project_form.html'
title = "Add a new resource"
admin_required = True
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.project = self.get_project()
self.object.save()
return redirect('resource_upload', project=self.object.project_id, pk=self.object.pk)
class ResourceUploadView(ProjectMixin, UpdateView):
admin_required = True
model = models.Resource
fields = ['file']
template_name = 'interface/default_form.html'
def get_success_url(self):
return resolve_url('resource_list', project=self.kwargs['project'])
class ResourceListView(ProjectMixin, ListView):
model = models.Resource
def get_queryset(self):
qs = super().get_queryset()
if not self.request.is_admin:
qs = qs.filter(visible=True)
return qs
class ResourceEditView(ProjectMixin, UpdateView):
admin_required = True
model = models.Resource
fields = ['name', 'description', 'visible']
template_name = 'interface/default_form.html'
def get_success_url(self):
return resolve_url('resource_list', project=self.kwargs['project'])
class ManageView(EnsembleMixin, TemplateView):
template_name = 'interface/manage.html'
admin_required = True
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['ensemble'] = models.Ensemble.objects.get(pk=self.request.ensemble_id)
context['ensemble_url'] = self.request.build_absolute_uri('/?code={0}'.format(context['ensemble'].ensemble_code()))
return context
+13
View File
@@ -0,0 +1,13 @@
# Model overview
Collections contain Works
Only the collection's administrators can add/remove/edit items
Collections can be enabled for Ensembles which makes everything availble in the Library
## How do loans work?
1. Project admins need to be able to search for available music - list with access type
2. Item can be attached to Project
View File
+55
View File
@@ -0,0 +1,55 @@
from django.contrib import admin
from . import models
class EnsembleAccessInline(admin.StackedInline):
model = models.EnsembleAccess
extra = 0
class CollectionAdmin(admin.ModelAdmin):
list_display = ['name', 'location', 'storage', 'prefix']
inlines = [EnsembleAccessInline]
admin.site.register(models.Collection, CollectionAdmin)
class ItemInline(admin.TabularInline):
model = models.ProjectItem
extra = 0
class DocInline(admin.TabularInline):
model = models.Document
extra = 0
class MetaInline(admin.TabularInline):
model = models.WorkMeta
extra = 0
class WorkAdmin(admin.ModelAdmin):
list_display = ['name', 'composer', 'edition', 'identifier', 'running_time']
list_filter = ['collection']
inlines = [MetaInline, DocInline, ItemInline]
admin.site.register(models.Work, WorkAdmin)
class SectionInline(admin.TabularInline):
model = models.Section
fields = ['tag', 'start', 'end']
class DocumentAdmin(admin.ModelAdmin):
list_display = ['work', '__str__']
list_filter = ['work__collection']
inlines = [SectionInline]
admin.site.register(models.Document, DocumentAdmin)
class ItemAdmin(admin.ModelAdmin):
list_display = ['project', 'work', 'order']
list_filter = ['project']
admin.site.register(models.ProjectItem, ItemAdmin)
class EnsembleAccessAdmin(admin.ModelAdmin):
list_display = ['ensemble', 'collection', 'access_type']
list_filter = ['ensemble']
admin.site.register(models.EnsembleAccess, EnsembleAccessAdmin)
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class LibraryConfig(AppConfig):
name = 'library'
+30
View File
@@ -0,0 +1,30 @@
from django import forms
from .models import Work
from interface.models import Project
from interface.forms import BaseForm
class WorkCreateForm(forms.ModelForm, BaseForm):
#uploads = forms.FileField(label="PDFs to upload", widget=forms.ClearableFileInput(attrs={'multiple': True}), required=False)
class Meta:
model = Work
fields = ['name', 'code', '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 ProjectSelectForm(forms.Form):
project = forms.ModelChoiceField(queryset=Project.objects.all())
+197
View File
@@ -0,0 +1,197 @@
from collections import namedtuple
# taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_Instruments
ABBREVIATIONS = """
score Score
acc Accordion
afl Alto flute
alt Alto (voice) (contralto)
arp Arpeggione
bag Bagpipe
bar Baritone (voice)
bass Bass (voice)
bbar Bass baritone (voice)
bc Continuo (Basso continuo)
bcl Bass clarinet
bell Bell (Chimes)
bfl Bass flute
bgtr Bass guitar
bjo Banjo
bn Bassoon
bob Bass oboe (Baritone oboe)
br Brass instruments
bryt Baryton
bstcl Basset clarinet
bsthn Basset horn
bug Bugle
cbcl Contrabass clarinet
cbn Contrabassoon
cch Children's chorus
cel Celesta
ch Mixed chorus
cimb Cimbalom
cit Cittern
cl Clarinet
clvd Clavichord
cm Chalumeau
conc Concertina
crh Crumhorn
crt Cornet
crtt Cornett (Zink)
cv Child's voice
db Double Bass
dlcn Dulcian
dom Domra
dulc Dulcimer
egtr Electric guitar
eh English horn (Cor anglais)
elec Electronic Instruments
epf Electric piano
eq Equal voices
erhu Erhu
euph Euphonium
fch Female chorus
fda Flute d'amore (Tenor flute)
fgh Flugelhorn
fife Fife
fl Flute
flag Flageolet
ghca Glass harmonica (Bowl organ)
gl Glockenspiel
gtr Guitar
harm Harmonium
hca Harmonica (Mouth Organ)
heck Heckelphone
hn Horn
hp Harp
hpd Harpsichord
kbd Keyboard instrument
lute Lute
lyre Lyre
mand Mandolin
mar Marimba
mch Male chorus
mez Mezzo-soprano
mus Musette
nar Narrator (Reciter)
ob Oboe
oca Ocarina
oda Oboe d'amore
om Ondes Martenot
oph Ophicleide
orch Orchestra
org Organ
oud Oud
pan Pan flute (Pan-pipes)
perc Percussion
pf Piano
pf3h Piano 3 hands
pf4h Piano 4 hands
pf5h Piano 5 hands
pf6h Piano 6 hands
pflh Piano left hand
pfped Pedal piano
pfrh Piano right hand
picc Piccolo
pipa Pipa
pk Timpani
ptpt Piccolo trumpet
reb Rebec
rec Recorder
sar Sarrusophone
sax Saxophone
sheng Sheng
shw Shawm
sit Sitar
skbt Sackbut
sop Soprano (voice)
srp Serpent
stpt Slide trumpet
str String instruments
sxh Saxhorn
syn Synthesizer
tba Tuba
tbn Trombone
ten Tenor
thrm Theremin
timp Timpani
tpt Trumpet
uch Unison chorus
uke Ukelele (Ukulele)
v Voice (solo)
va Viola
vap Viola pomposa
vc Cello
vda Viola d'amore
vib Vibraphone
vie Vielle (Hurdy-Gurdy)
viol Viol (Viola da gamba)
vlne Violone
vn Violin
vuv Vuvuzela
vv Voices (multiple soloists)
wag Wagner tuba
ww Woodwind instruments
xiao Xiao
xyl Xylophone
zith Zither
"""
INSTRUMENTS = []
for line in ABBREVIATIONS.split('\n'):
parts = line.strip().split(maxsplit=1)
if len(parts) < 2: continue
name, _, _ = parts[1].partition('(')
INSTRUMENTS.append((parts[0], name))
INSTRUMENT_LOOKUP = dict(INSTRUMENTS)
TAG_LOOKUP = dict( ( (x[1].lower(), x[0]) for x in INSTRUMENTS ) )
class Instrument(namedtuple('Instrument', ('name', 'variant'), defaults=[None])):
@classmethod
def from_tag(cls, tag):
"""
>>> Instrument.from_tag('vn-1')
Instrument(name='Violin', variant='1')
>>> Instrument.from_tag('db')
Instrument(name='Double Bass', variant=None)
>>> Instrument.from_tag('Jaws Harp')
Instrument(name='Jaws Harp', variant=None)
"""
abbr, _, variant = tag.partition('-')
name = INSTRUMENT_LOOKUP.get(abbr.lower(), abbr)
if variant:
return cls(name, variant)
return cls(name, None)
def abbreviate(self):
"""
>>> Instrument('Violin', 1).abbreviate()
'vn-1'
>>> Instrument('Double Bass').abbreviate()
'db'
"""
tag = TAG_LOOKUP.get(self.name.lower())
if self.variant:
tag = f"{tag}-{self.variant}"
return tag
def __str__(self):
"""
>>> str(Instrument('Violin', 1))
'Violin 1'
>>> str(Instrument('Double Bass'))
'Double Bass'
"""
if self.variant:
return f"{self.name} {self.variant}"
return self.name
if __name__ == "__main__":
import doctest
print(doctest.testmod())
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'])
+122
View File
@@ -0,0 +1,122 @@
# Generated by Django 3.2.7 on 2022-11-19 01:24
import byostorage.user
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import library.models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('byostorage', '0006_alter_userstorage_settings_data'),
('interface', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Collection',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Name of the collection', max_length=255)),
('prefix', models.SlugField(default='default', help_text='Folder to store works in', max_length=30)),
('location', models.CharField(blank=True, help_text='Physical location (institution, town...)', max_length=100)),
('notes', models.TextField(blank=True, help_text='Publicly visible notes about collection and loans policy (markdown format)')),
('administrators', models.ManyToManyField(help_text='Administrators for this collection', related_name='collections', to=settings.AUTH_USER_MODEL)),
('storage', models.ForeignKey(blank=True, help_text='User storage for documents', null=True, on_delete=django.db.models.deletion.CASCADE, to='byostorage.userstorage')),
],
),
migrations.CreateModel(
name='Document',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('doctype', models.PositiveSmallIntegerField(choices=[(1, 'PDF'), (2, 'Audio'), (3, 'Video'), (4, 'Source')], default=1)),
('upload', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=library.models.doc_upload_filename)),
('created', models.DateTimeField(auto_now_add=True)),
('version', models.CharField(blank=True, max_length=30)),
],
),
migrations.CreateModel(
name='ProjectItem',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('checkout', models.DateTimeField()),
('due', models.DateTimeField(blank=True, null=True)),
('returned', models.DateTimeField(blank=True, null=True)),
('order', models.SmallIntegerField(default=0)),
('section', models.CharField(blank=True, max_length=100)),
('approved_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='interface.project')),
],
options={
'ordering': ['order', 'work'],
},
),
migrations.CreateModel(
name='Work',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(editable=False, help_text='Used as folder name', max_length=100)),
('name', models.CharField(help_text='Original name of the work', max_length=255)),
('edition', models.CharField(blank=True, help_text='Edition details to distinguish multiple versions', max_length=255)),
('composer', models.CharField(blank=True, help_text='Surname, First Name/Initials', max_length=255)),
('original_parts', models.JSONField(blank=True, default=dict, help_text='Original printed parts (IMSLP format)')),
('code', models.CharField(blank=True, help_text='Collection specific code or number', max_length=100)),
('licence', models.PositiveSmallIntegerField(choices=[(2, 'Public Domain'), (4, 'Copyright Expired'), (6, 'Copyrighted'), (10, 'Internal use only')], default=6, help_text='Copyright status')),
('max_projects', models.IntegerField(default=1, help_text='How many active projects can this work be attached to')),
('running_time', models.DurationField(blank=True, help_text='Running time in seconds', null=True)),
('notes', models.TextField(blank=True)),
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='works', to='library.collection')),
('parent', models.ForeignKey(blank=True, help_text='Arrangement of another work or part of an anthology', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_works', to='library.work')),
('projects', models.ManyToManyField(help_text='Current usage', related_name='works', through='library.ProjectItem', to='interface.Project')),
],
),
migrations.CreateModel(
name='WorkMeta',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.SlugField(choices=[('tag', 'Tag'), ('arr', 'Arranger'), ('lyrics', 'Lyracist'), ('genre', 'Genre'), ('style', 'Style'), ('orchestration', 'Orchestration')], max_length=20)),
('value', models.CharField(max_length=255)),
('work', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meta_info', to='library.work')),
],
),
migrations.CreateModel(
name='Section',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('tag', models.CharField(max_length=50)),
('start', models.SmallIntegerField(blank=True, null=True)),
('end', models.SmallIntegerField(blank=True, null=True)),
('doc', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='library.document')),
],
options={
'ordering': ['doc', 'start', 'pk'],
},
),
migrations.AddField(
model_name='projectitem',
name='work',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_items', to='library.work'),
),
migrations.CreateModel(
name='EnsembleAccess',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('access_type', models.PositiveSmallIntegerField(choices=[(1, 'Unlimited'), (2, 'Approval required')], default=2)),
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_ensembles', to='library.collection')),
('ensemble', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_collections', to='interface.ensemble')),
],
options={
'verbose_name_plural': 'Ensemble access',
},
),
migrations.AddField(
model_name='document',
name='work',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='docs', to='library.work'),
),
]
View File
+303
View File
@@ -0,0 +1,303 @@
from os import SCHED_OTHER
from django.conf import settings
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
from byostorage.user import BYOStorage
from .imslp import Instrument
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__)
'''
ORCHESTRATIONS = {
'SATB': ('S', 'A', 'T', 'B'),
'String Quartet': ('Vln1', 'Vln2', 'Vla', 'Vc'),
'String Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb'),
'Chamber Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb',
'Fl1', 'Fl2', 'Cl1', 'Cl2', 'Hn1', 'Hn2',
'Tpt1', 'Tpt2', 'Tbn1', 'Tbn2', 'Tuba',
'Timp', 'Drum', 'Perc'),
'Custom': (),
}
'''
DOCTYPES = [
(1, 'PDF'),
(2, 'Audio'),
(3, 'Video'),
(4, 'Source'),
]
LICENCE_TYPES = [
(2, 'Public Domain'),
(4, 'Copyright Expired'),
(6, 'Copyrighted'),
(10, 'Internal use only'),
]
ACCESS_TYPES = [
(1, 'Unlimited'),
(2, 'Approval required'),
]
'''
class Orchestration(models.Model):
"""
Stores a list of instrument codes as a single entry (space delimited).
Can be global or ensemble specific
"""
ensemble = models.ForeignKey('interface.Ensemble', on_delete=models.CASCADE, related_name="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, tag_to_instrument(t)) for t in tags if t ]
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)
#version = models.CharField(max_length=30, blank=True, help_text="Limited to specific version tag")
class Meta:
ordering = ['order', 'work']
def __str__(self):
return f"<{self.project_id}:{self.work.slug}>"
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)")
def __str__(self):
return self.name
class EnsembleAccess(models.Model):
"""
Can have different access levels to a collection
"""
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"
META_TAGS = (
('tag', 'Tag'),
('arr', 'Arranger'),
('lyrics', 'Lyracist'),
('genre', 'Genre'),
('style', 'Style'),
('orchestration', 'Orchestration'),
)
class Work(models.Model):
"""
A musical work 'owned' by a collection from a licencing perspective.
"""
slug = models.SlugField(max_length=100, editable=False,
help_text="Used as folder name")
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, blank=True,
help_text="Surname, First Name/Initials")
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")
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 seconds")
notes = models.TextField(blank=True)
# Allocation to projects
projects = models.ManyToManyField('interface.Project', through='ProjectItem', related_name="works", help_text="Current usage")
def extract(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 list(qs.values_list('upload', 'start', 'end'))
@property
def digital_parts(self):
return Section.objects.filter(doc__work=self.pk)
@property
def physical_parts(self):
if not self.original_parts:
return []
return [ (Instrument.from_tag(k), v) for (k, v) in self.original_parts.items() ]
@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')
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super(Work, self).save(*args, **kwargs)
@property
def active_projects(self):
return self.projects.filter(active=True)
@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"
words = self.name.split()
if len(words) > 2:
work = ''.join([ x[0] for x in self.name.split() ])
else:
work = words[0][:3]
return f"{composer[:4]}-{work}-{self.pk:03d}".upper()
def __str__(self):
return f"{self.name} ({self.composer})"
class WorkMeta(models.Model):
work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name='meta_info')
name = models.SlugField(max_length=20, choices=META_TAGS)
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.slug}-{doc.work.pk}/{filename}'
class Document(models.Model):
"""
Document represents a single file stored in the storage backend.
"""
work = models.ForeignKey('Work', on_delete=models.CASCADE, related_name="docs")
doctype = models.PositiveSmallIntegerField(choices=DOCTYPES, default=1)
upload = models.FileField(upload_to=doc_upload_filename, storage=BYOStorage())
created = models.DateTimeField(auto_now_add=True)
version = models.CharField(max_length=30, blank=True)
def __str__(self):
return self.upload.name
class Section(models.Model):
"""
Section is a tagged portion of a Document
"""
doc = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="sections")
tag = models.CharField(max_length=50)
start = models.SmallIntegerField(null=True, blank=True)
end = models.SmallIntegerField(null=True, blank=True)
class Meta:
ordering = ['doc', 'start', 'pk']
@property
def instrument(self):
return Instrument.from_tag(self.tag)
@property
def filename(self):
return slugify(f'{self.doc.work.name}_{self.instrument}') + '.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 f'{self.doc.upload} [{self.pagerange}]'
+65
View File
@@ -0,0 +1,65 @@
import tempfile
import subprocess
import os.path
import string
SAFECHARS = string.ascii_letters + string.digits + " _-"
def extract_pages(source, bookmark, start=None, end=None, count=1):
return extract_and_concat([(source, bookmark, start, end, count)])
def extract_and_concat(items):
# create a temporary directory for our sections
d = tempfile.TemporaryDirectory(prefix="polyphonic_")
pdfmarks = os.path.join(d.name, 'pdfmarks.txt')
marks = open(pdfmarks, 'w')
sections = []
current_page = 1
for i, (source, bookmark, start, end, count) in enumerate(items):
if count == 0:
continue
if start is None:
sections.append(source)
else:
if end is None:
end = start
dest = os.path.join(d.name, f'section_{i}.pdf')
cmd = ['gs', '-sDEVICE=pdfwrite', '-dBATCH', '-dNOPAUSE',
f'-dFirstPage={start}', f'-dLastPage={end}',
f'-sOutputFile={dest}',
source]
bookmark = "".join(filter(lambda c: c in SAFECHARS, bookmark))
marks.write(f'[/Title ({bookmark}) /Page {current_page} /OUT pdfmark\n')
p = subprocess.run(cmd, check=True, capture_output=True)
pages = len([ x for x in p.stdout.splitlines() if x.decode('utf8').startswith('Page ')])
for j in range(count):
sections.append(dest)
current_page += pages
marks.close()
# concat the items
output = tempfile.NamedTemporaryFile(prefix="polyphonic_", suffix='.pdf')
cmd = ['gs', '-sDEVICE=pdfwrite', '-q', '-dBATCH', '-dNOPAUSE',
'-sOutputFile=-']
cmd.extend(sections)
cmd.append(pdfmarks)
subprocess.run(cmd, stdout=output)
output.seek(0)
return output
@@ -0,0 +1,26 @@
{% extends "interface/project_base.html" %}
{% block page %}
<h3 class="title">Library collections for {{ request.user }}</h3>
<div class="columns is-multiline">
{% for collection in object_list %}
<div class="column is-half">
<div class="card">
<header class="card-header">
<a class="" href="{% url 'collection_work_list' pk=collection.id %}">
<p class="card-header-title">{{ collection.name }}</p>
</a>
</header>
<div class="card-content">
<p>{{ collection.location }}, {{ collection.works.count }} items.</p>
</div>
</div>
</div>
{% endfor %}
</div>
<div>
<small>{{ ensemble.ensemble_code }}</small>
</div>
{% endblock %}
@@ -0,0 +1,389 @@
{% extends "interface/project_base.html" %}
{% block admin %}
<a href="#" onclick="saveTags()" class="button is-link">
<span class="icon"><i class="fas fa-save"></i></span>
<span>Save</span>
</a>
<a href="{% url 'work_detail' pk=object.work.pk %}" class="button is-link is-light">
<span>Cancel</span>
</a>
{% endblock %}
{% block media %}
<style>
.tag-grid {
display: flex;
}
.grid-column {
margin-left: 1em;
margin-right: 1em;
}
.grid-page {
height: 25px;
margin-bottom: 10px;
border: 1px solid #999;
border-radius: 5px;
text-align: center;
width: 50px;
cursor: pointer;
}
.grid-page.is-active {
background-color: var(--primary);
color: white;
}
.grid-tag {
border: 2px solid var(--primary);
background-color: rgba(240, 240, 240, 0.5);
border-radius: 5px;
padding-left: 1em;
padding-right: 1em;
margin-bottom: 5px;
min-width: 200px;
position: absolute;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
#tag-area {
min-width: 220px;
}
</style>
{% endblock %}
{% block page %}
<h3 class="subtitle"><a href="{% url 'work_detail' document.work.pk %}">{{ document.work.name }}</a></h3>
<div id="annotation-area" class="columns is-centered">
<div class="column is-narrow">
<div class="has-text-centered">
<div class="level">
<div class="level-left">
<div class="level-item">
<p>Page <span id="page-num">-</span> / <span id="page-count">-</span></p>
</div>
</div>
<div class="level-right">
<div class="level-item">
<div class="field has-addons">
<span class="control">
<input type="text" class="input" list="instrument-list" id="add-instrument-name"/>
<datalist id="instrument-list">
{% for inst in json_data.instruments.values %}
<option value="{{inst}}"/>
{% endfor %}
</datalist>
</span>
<span class="control">
<input type="number" class="input" max="4" min="1" size="3" id="add-instrument-variant"/>
</span>
<span class="control">
<button class="button is-primary" onclick="addInstrument()">Add</button>
</span>
</div>
</div>
</div>
</div>
<div class="box" style="display: inline-block;">
<canvas id="inline-viewer" style="width: 500px;"></canvas>
</div>
</div>
</div>
<div class="column is-narrow">
<div class="tag-grid">
<div class="grid-column" id="page-list">
</div>
<div class="grid-column" id="tag-area">
</div>
</div>
</div>
</div>
<p>{{ document.upload.name }}</p>
{% endblock %}
{% block scripts %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.7.570/pdf.min.js"
integrity="sha512-g4FwCPWM/fZB1Eie86ZwKjOP+yBIxSBM/b2gQAiSVqCgkyvZ0XxYPDEcN2qqaKKEvK6a05+IPL1raO96RrhYDQ=="
crossorigin="anonymous"></script>
{{ json_data|json_script:"data" }}
<script type="text/javascript">
let url = "{{ document.upload.url|safe }}";
// Loaded via <script> tag, create shortcut to access PDF.js exports.
let pdfjsLib = window['pdfjs-dist/build/pdf'];
// The workerSrc property shall be specified.
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.7.570/pdf.worker.min.js';
// get current page tags
let data = JSON.parse(document.getElementById('data').textContent);
let tagArea = document.getElementById('tag-area');
var dirty = false;
//document.getElementById('tag-list').onclick = (e) => setTag(e.target.dataset.tag);
var pdfDoc = null,
pageNum = 1,
pageRendering = false,
pageNumPending = null,
scale = 1,
canvas = document.getElementById('inline-viewer'),
ctx = canvas.getContext('2d');
/**
* Get page info from document, resize canvas accordingly, and render page.
* @param num Page number.
*/
function renderPage(num) {
pageRendering = true;
// Using promise to fetch the page
pdfDoc.getPage(num).then(function (page) {
var viewport = page.getViewport({ scale: scale });
canvas.height = viewport.height;
canvas.width = viewport.width;
// Render PDF page into canvas context
var renderContext = {
canvasContext: ctx,
viewport: viewport
};
var renderTask = page.render(renderContext);
try {
document.getElementById('grid-page-' + pageNum).classList.remove('is-active');
} catch(e) {}
pageNum = num;
document.getElementById('grid-page-' + pageNum).classList.add('is-active');
// Wait for rendering to finish
renderTask.promise.then(function () {
pageRendering = false;
if (pageNumPending !== null) {
// New page rendering is pending
renderPage(pageNumPending);
pageNumPending = null;
}
});
});
// Update page counters
document.getElementById('page-num').textContent = num;
}
/**
* If another page rendering in progress, waits until the rendering is
* finised. Otherwise, executes rendering immediately.
*/
function queueRenderPage(num) {
if (pageRendering) {
pageNumPending = num;
} else {
renderPage(num);
}
}
/**
* Displays previous page.
*/
function onPrevPage() {
if (pageNum <= 1) {
return;
}
pageNum--;
queueRenderPage(pageNum);
}
//document.getElementById('prev').addEventListener('click', onPrevPage);
/**
* Displays next page.
*/
function onNextPage() {
if (pageNum >= pdfDoc.numPages) {
return;
}
pageNum++;
queueRenderPage(pageNum);
}
//document.getElementById('next').addEventListener('click', onNextPage);
/**
* Asynchronously downloads PDF.
*/
pdfjsLib.getDocument(url).promise.then(function (pdfDoc_) {
pdfDoc = pdfDoc_;
const pageList = document.getElementById('page-list');
pageList.innerHTML = '';
for (var i=0; i<pdfDoc.numPages; i++) {
let page = i+1;
let el = document.createElement('div');
el.className = 'grid-page';
el.id = 'grid-page-' + page;
el.innerHTML = page;
el.addEventListener('click', (evt) => {
queueRenderPage(page);
});
pageList.appendChild(el);
}
document.getElementById('page-count').textContent = pdfDoc.numPages;
// Initial/first page rendering
renderPage(pageNum);
tagArea.innerHTML = '';
for (let pageTag of data.pageTags) {
addTag(pageTag[0], pageTag[1], pageTag[2]);
}
dirty = false;
});
function addInstrument() {
let name = document.getElementById('add-instrument-name');
let variant = document.getElementById('add-instrument-variant');
let inst_name = name.value;
var tag = null;
for (let key in data.instruments) {
if (data.instruments[key] == inst_name) {
tag = key;
break;
}
}
if (!tag) {
alert("Unknown tag: " + name);
return;
}
if (variant.value) {
tag += "-" + variant.value;
}
variant.value = '';
name.value = '';
addTag(tag, pageNum, pageNum);
}
function addTag(tag, start, end) {
console.log("addTag", tag, start, end);
const el = document.createElement('div');
el.className = 'grid-tag';
el.dataset.tag = tag;
el.dataset.start = start;
el.dataset.end = end;
let setStart = document.createElement('span');
setStart.className = "icon is-action";
setStart.innerHTML = '<i class="fas fa-sort-amount-down" title="Set start page"></i>';
setStart.addEventListener('click', () => setTagStart(el));
el.appendChild(setStart);
let label = document.createElement('span');
let name = document.createElement('b');
name.innerHTML = get_instrument(tag);
label.appendChild(name);
let del = document.createElement('span');
del.className = "icon is-action";
del.innerHTML = '<i class="fas fa-trash-alt" title="Remove this tag"></i>';
del.addEventListener('click', () => {el.remove(), dirty=true});
label.appendChild(del)
el.appendChild(label);
let setEnd = document.createElement('span');
setEnd.className = "icon is-action";
setEnd.innerHTML = '<i class="fas fa-sort-amount-up" title="Set end page"></i>';
setEnd.addEventListener('click', () => setTagEnd(el));
el.appendChild(setEnd);
updateTag(el);
tagArea.appendChild(el);
}
function updateTag(tag) {
let start = tag.dataset.start;
let end = tag.dataset.end;
let span = end-start+1;
let height = span * 25 + (span-1) * 10;
let top = (start-1) * 35;
tag.style.height = height + 'px';
tag.style.marginTop = top + 'px';
dirty = true;
}
function setTagStart(el) {
if (pageNum > el.dataset.end) {
alert("Select the first page and click extend");
return;
}
el.dataset.start = pageNum;
updateTag(el);
}
function setTagEnd(el) {
if (pageNum < el.dataset.start) {
alert("Select the last page and click extend");
return;
}
el.dataset.end = pageNum;
updateTag(el);
}
function saveTags() {
const pageTags = [];
for (let pageTag of tagArea.children ) {
let start = pageTag.dataset.start;
let end = pageTag.dataset.end;
if (start == 1 && end == pdfDoc.numPages) {
start = null;
end = null;
}
pageTags.push([pageTag.dataset.tag, start, end])
}
console.log(pageTags);
fetch("", {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRFToken": "{{ csrf_token }}" },
body: JSON.stringify(pageTags)
}
).then((response) => {
if (response.ok) {
window.location = "{% url 'work_detail' document.work.pk %}"
} else {
alert("Failed: " + response.statusText)
}
});
dirty = false;
}
function get_instrument(s) {
let parts = s.split('-');
let instrument = data.instruments[parts[0]];
if (parts.length == 2) {
return instrument + " " + parts[1];
}
return instrument;
}
function checkSaved(e) {
if (dirty) {
e.preventDefault();
}
}
window.addEventListener('beforeunload', checkSaved);
</script>
{% endblock %}
@@ -0,0 +1,11 @@
{% extends 'interface/project_base.html' %}
{% block page %}
<h2>{{ folder.name }}</h2>
<h3>Works</h3>
<ul>
{% for work in folder.works.all %}
<li>{{ work.name }}</li>
{% endfor %}
</ul>
{% endblock %}
@@ -0,0 +1,163 @@
{% extends "interface/project_base.html" %}
{% block admin %}
<a class="button is-link" href="{% url 'item_list_manage' project.pk %}">
<span class="icon"><i class="fas fa-list"></i></span>
<span>Change items</span>
</a>
{% endblock %}
{% block page %}
<form action="" method="post" target="_blank">
{% csrf_token %}
<div>
<div class="field is-grouped is-grouped-centered">
<div class="field has-addons control">
<span class="control">
<a class="button is-static">Your Instrument</a>
</span>
<span class="control">
<input type="text" class="input" list="instrument-list" id="instrument-name" onchange="updateParts()"/>
<datalist id="instrument-list">
{% for inst in instruments %}
<option value="{{inst.1}}"/>
{% endfor %}
</datalist>
</span>
<span class="control">
<span class="select" onchange="updateParts()">
<select id="part-preference">
<option value="0">Any</option>
<option value="1">1st</option>
<option value="2">2nd</option>
<option value="3">3rd</option>
<option value="4">4th</option>
</select>
</span>
</span>
</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>
<table class="table is-striped is-fullwidth">
<thead>
<tr>
<th/>
<th>Piece</th>
<th>Running time</th>
<th>Part</th>
<th/>
</tr>
</thead>
<tbody>
{% for item in object_list %}
<tr>
<td>{{ forloop.counter }}.</td>
<td>
{% if request.is_admin %}
<a href="{% url 'work_detail' item.work.pk %}">{{ item.work.name }}</a>
{% else %}
{{ item.work.name }}
{% endif %}
</td>
<td>{% firstof item.work.running_time "------" %}</td>
<td class="select-cell">
<input type="hidden" name="works" value="{{ item.work.pk }}"/>
<span class="select is-small">
<select name="instruments">
<option value='-'>None</option>
{% for part in item.work.digital_parts %}
<option value='{{ part.tag }}'>{{ part.instrument }}</option>
{% endfor %}
</select>
</span>
</td>
<td>
<a href=""><i class="fas fa-download"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td/>
<td/>
<td>{{ running_time }}</td>
<td/>
<td>
<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>
<a class="button is-link is-small"><span class="icon"><i class="fas fa-archive"></i></span><span>Individual files (zipped)</span></a>
</td>
</tr>
</tfoot>
</table>
</form>
<p class="content">
This page lets you download a complete set of music for your part by selecting your instrument
and optionally a part (e.g. Flute 1 or 2).<br/>
You can also tweak which parts you get for each piece, or click on a piece for more download options.
</p>
{% endblock %}
{% block scripts %}
{{ instruments|json_script:'instruments' }}
<script type="text/javascript">
const INSTRUMENTS = JSON.parse(document.getElementById('instruments').innerText);
function updateParts() {
var inst = document.getElementById("instrument-name").value;
window.localStorage.setItem('instrument-name', inst);
for (let i of INSTRUMENTS) {
if (i[1] === inst) inst = i[0];
}
let part = document.getElementById("part-preference").value;
window.localStorage.setItem('part-preference', part);
selectParts(inst, part);
}
function selectParts(inst, part) {
let prefix = inst + "-" + part;
let instruments = document.getElementsByName("instruments");
for(let i=0; i<instruments.length; i++) {
var result = "-"
let options = instruments[i].children;
for(let j=0; j<options.length; j++) {
let value = options[j].value;
if (value.startsWith(prefix)) {
result = value;
break;
}
if (result==="-" && value.startsWith(inst)) {
result = value;
}
}
instruments[i].value = result;
}
}
document.getElementById("instrument-name").value = localStorage.getItem('instrument-name', 'All');
document.getElementById("part-preference").value = localStorage.getItem('part-preference', 'Any');
updateParts();
</script>
{% endblock %}
@@ -0,0 +1,109 @@
{% extends "interface/project_base.html" %}
{% block admin %}
<a href="{% url 'item_list_append' project.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add</span>
</a>
<a href="#" onclick="save()" class="button is-link">
<span class="icon"><i class="fas fa-save"></i></span>
<span>Save</span>
</a>
{% endblock %}
{% block page %}
<table style="max-width: 600px; margin: 10pt auto;" class="zebra">
<thead>
<tr>
<th>Item</th>
<th>Time</th>
<th/>
</tr>
</thead>
<tbody id="work-list">
{% for item in object_list %}
<tr data-pk="{{ item.pk }}" data-order="{{ forloop.counter }}">
<td>{{ item.work.name }}</td>
<td>{{ item.work.duration }}</td>
<td style="text-align: center;">
<i class="fas fa-arrow-up clickable" title="Move up" onclick="moveItem({{ item.pk }}, -1)"></i>
<i class="fas fa-arrow-down clickable" title="Move down" onclick="moveItem({{ item.pk }}, 1)"></i>
<i class="fas fa-trash clickable" title="Remove" onClick="moveItem({{ item.pk }}, 0)"></i>
</td>
</tr>
{% empty %}
<tr><td colspan="2">No items</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
{% block scripts %}
<script type="text/javascript">
let workList = document.getElementById('work-list');
var dirty = false;
function moveItem(pk, dir) {
let items = Array.prototype.slice.call(workList.children, 0);
var i=0;
pk = "" + pk;
for(i=0; i<items.length; i++) {
if(items[i].dataset.pk === pk) break;
}
if(i >= items.length) return;
// check the direction is sensible
if (i + dir < 0 || i + dir >= items.length) return;
if (dir === 0) {
items[i].dataset.order = -1;
items[i].style = "display: none";
} else {
items[i].dataset.order = parseInt(items[i].dataset.order) + dir;
items[i+dir].dataset.order = parseInt(items[i+dir].dataset.order) - dir;
}
items.sort((a, b) => parseInt(a.dataset.order) - parseInt(b.dataset.order))
workList.innerHTML = ""
for(let j=0; j<items.length; j++) {
workList.appendChild(items[j]);
}
dirty = true;
}
function save() {
let items = Array.prototype.slice.call(workList.children, 0);
let data = {};
for(var i=0; i<items.length; i++) {
let item = items[i].dataset;
data[item.pk] = item.order;
}
console.log(data);
fetch("", {
method: "POST",
headers: {"Content-Type": "application/json", "X-CSRFToken": "{{ csrf_token }}"},
body: JSON.stringify(data)
}).then((response) => {
if (response.ok) {
dirty=false;
window.location = "{% url 'item_list' project=project.pk %}";
} else {
alert("Failed: " + response.statusText)
}
})
}
function checkSaved(e) {
if(dirty) {
e.preventDefault();
}
}
window.addEventListener('beforeunload', checkSaved);
</script>
{% endblock %}
@@ -0,0 +1,12 @@
{% with project.items.count as items %}
{% if items %}
<p>
There {{ items|pluralize:"is,are" }} currently {{ items}} item{{ items|pluralize }} for this project.<br/>
Click the '<a href="{% url 'item_list' project=project.pk %}">My Music</a>' button below to access your parts...
</p>
{% else %}
<p>
There are no items listed yet for this project - please check back later...
</p>
{% endif %}
{% endwith %}
@@ -0,0 +1,9 @@
<p class='menu-label'>Library</p>
<ul class="menu-list">
{% if project %}
<li><a href="{% url 'item_list' project=project.pk %}">My Music</a></li>
{% endif %}
{% if request.is_admin %}
<li><a href="{% url 'work_list' %}">Library</a></li>
{% endif %}
</ul>
@@ -0,0 +1,211 @@
{% extends 'interface/project_base.html' %}
{% load path_filters %}
{% block media %}
<script src="https://unpkg.com/dropzone@5/dist/min/dropzone.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/dropzone@5/dist/min/dropzone.min.css" type="text/css" />
{% endblock %}
{% block admin %}
<a href="{% url 'work_edit' work.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-edit"></i></span>
<span>Edit</span>
</a>
<a href="{% url 'work_add_to_project' work.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add to project</span>
</a>
{% endblock %}
{% block page %}
<h3 class="title">
{{ work.name }}
{% for tag in work.tags %}
<span class="tag is-success">{{ tag }}</span>
{% endfor %}
</h3>
<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">
Location: <a href="{% url 'collection_work_list' work.collection.pk %}">{{ work.collection }}</a> [{{ work.identifier }}]<br/>
Running time: {{ work.duration }}<br/>
Licence: {{ work.get_licence_display }}<br/>
{% for meta in work.meta %}
{{ meta.get_name_display }}: {{ meta.value }}<br/>
{% endfor %}
</p>
{% if work.parent %}
<p>From <a href="{% url 'work_detail' 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.pk %}">{{ related.name }} - {{ related.composer }}</a></li>
{% endfor %}
</ul>
{% endif %}
</section>
<section class="block">
<div class="columns">
<div class="column is-half">
<div class="box">
<h4 class="subtitle is-size-4">
<span class="icon"><i class="fas fa-book"></i></span>
Printed Parts
</h4>
<div class="tags">
{% for inst, c in work.physical_parts %}
<span class="tag is-warning">{{ inst }} ({{ c }})</span>
{% empty %}
<p class="is-italic">No printed parts listed</p>
{% endfor %}
</div>
</div>
</div>
<div class="column is-half">
<div class="box">
<h4 class="subtitle is-size-4">
<span class="icon"><i class="fas fa-print"></i></span>
Digital Parts
</h4>
<div class="tags">
{% if work.digital_parts %}
<a class="tag is-danger" href="{% url 'work_partset' pk=work.pk %}">Full Set</a>
{% endif %}
{% for part in work.digital_parts %}
<a class="tag is-info" href="{% url 'part_download' pk=part.pk filename=part.filename %}"
target="part_{{ part.pk }}" rel="">{{ part.instrument }}</a>
{% empty %}
<p class="is-italic">No digital parts available</p>
{% endfor %}
</div>
</div>
</div>
</div>
</section>
<section class="block">
<div class="box">
<div class="level">
<h4 class="subtitle is-size-4">
<span class="icon"><i class="fas fa-file"></i></span>
Files
</h4>
</div>
<div class="columns">
<div class="column">
<table class="table is-fullwidth">
<thead>
<tr>
<th>Filename</th>
<th>Sections</th>
<th/>
</tr>
</thead>
<tbody id="doc-list">
{% for doc in work.docs.all %}
<tr>
<td><a href="{% url 'document_download' pk=doc.pk %}" target="_blank">
{{ doc.upload.name|basename }}</a></td>
<td>
{% for part in doc.sections.all %}
<a class="tag is-info" href="{% url 'part_download' pk=part.pk filename=part.filename %}">{{ part.instrument }}</a>
{% endfor %}
</td>
<td class="has-text-right">
{% if request.is_admin %}
<a href="{% url 'document_annotate' pk=doc.pk %}"><i class="fas fa-tags"
title="Manage Tags"></i></a>
<a href=""><i class="fas fa-trash-alt" title="Delete Document"></i></a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if request.is_admin %}
<div class="column is-one-quarter">
<h4 class="is-size-5">Upload files</h4>
<form action="{% url 'document_add' object.pk %}" class="dropzone" id="doc-upload">
{% csrf_token %}
</form>
</div>
{% endif %}
</div>
</div>
</section>
{% if request.is_admin %}
<section class="block">
<div class="box">
<div class="level">
<h4 class="is-size-4">
<span class="icon"><i class="fas fa-book-reader"></i></span>
Loans
</h4>
<span class="level-right">
<a class="icon-text" href="{% url 'work_add_to_project' work.pk %}"><span class="icon"><i
class="fas fa-plus-circle"></i></span> Checkout</a>
</span>
</div>
<table class="table is-fullwidth">
<thead>
<tr>
<th>Ensemble</th>
<th>Project</th>
<th>Checked Out</th>
<th>Due Back</th>
</tr>
</thead>
<tbody>
{% for item in work.current_loans %}
<tr>
<td><a href="{% url 'ensemble_detail' item.project.ensemble_id %}">
{{ item.project.ensemble.name }}
</a></td>
<td><a href="{% url 'project_detail' item.project.pk %}">{{ item.project.name }}</a></td>
<td>{{ item.checkout.date|date:"d/m/Y" }}</td>
<td>{{ item.due.date|date:"d/m/Y" }}</td>
</tr>
{% empty %}
<tr>
<td>No current loans</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
{% block scripts %}
<script>
Dropzone.options.docUpload = { // camelized version of the `id`
paramName: "upload", // The name that will be used to transfer the file
maxFilesize: 12, // MB
createImageThumbnails: false,
thumbnailWidth: 60,
thumbnailHeight: 60,
init: function () {
this.on("complete", file => {
console.log(file);
let data = JSON.parse(file.xhr.response);
console.log(data);
let tbody = document.getElementById('doc-list');
let tr = document.createElement('tr');
tr.innerHTML = data.entry;
tbody.appendChild(tr);
this.removeFile(file);
});
}
};
</script>
{% endblock %}
{% endblock %}
@@ -0,0 +1,71 @@
{% extends "interface/project_base.html" %}
{% load url_tools %}
{% block admin %}
{% if collection_id %}
<a href="{% url 'work_add' pk=collection_id %}" 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 request.is_admin %}
<th class="is-hidden-touch">Collection</th>
<th class="is-hidden-mobile">Copies</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for work in object_list %}
<tr>
<td><a href="{% url 'work_detail' 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 request.is_admin %}
<td class="is-hidden-touch">{{ work.collection.name }}</td>
<td class="is-hidden-mobile {{ work.is_available|yesno:'has-text-success,has-text-danger' }}">{{ work.available }}</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 %}
@@ -0,0 +1,37 @@
{% extends "interface/project_base.html" %}
{% block page %}
<form action="" method="post" target="_blank">
{% csrf_token %}
<div class="admin-tools is-pulled-right">
<button type="submit" class="button is-link">
<span class="icon"><i class="fas fa-print"></i></span>
<span>Print Set</span>
</button>
<a class="button is-link is-light" href="{% url 'work_detail' pk=object.pk %}">
<span>Cancel</span>
</a>
</div>
<h3 class="subtitle"><a href="{% url 'work_detail' pk=work.pk %}">{{ work.name }}</a></h3>
<p class="block">
You can generate a custom partset for printing - select the number of copies of each you want...
</p>
<div class="columns is-multiline is-mobile">
{% for part in work.digital_parts %}
<div class="column is-3 has-text-right">
<span style="white-space: nowrap">{{ part.instrument }}</span>
<input name="parts" type="hidden" value="{{ part.tag }}">
<input name="copies" type="number" value="{% if part.tag == 'Score' %}0{% else %}1{% endif %}" size="1">
</div>
{% endfor %}
</div>
</form>
{% endblock %}
+50
View File
@@ -0,0 +1,50 @@
from django.test import TestCase
from django.contrib.auth.models import User
from interface.models import Ensemble, Project
from . import models
class IntegrationTestCase(TestCase):
def setUp(self):
self.homer = User.objects.create(username='homer')
self.ned = User.objects.create(username="ned")
self.lisa = User.objects.create(username="lisa")
self.dewey = User.objects.create(username="dewey")
self.be_sharps = self.homer.ensembles.create(name='Be Sharps', code="barbershop")
self.sesd = self.dewey.ensembles.create(name="Springfield Elementary School Band", code="sax")
self.sel = self.lisa.collections.create(name="Springfield Elementary Library")
self.flanders = self.ned.collections.create(name="Neds Shed")
def test_integration(self):
pass
def test_movement_from_large_work(self):
'''
Will be common to store a work which has several movements, but the project is only going to play one.
This also should give us the ability to store an anthology as one Work have Project reference 'no:23'
'''
work = self.sel.works.create(name="Some Quartet", composer="Beethoven")
for g in ('vl1', 'vl2', 'vla', 'vc'):
doc = work.docs.create(upload=f'sel/beethoven/some_quartet/some_quartet_{g}.pdf')
doc.sections.create(tag='mvmt:1', start=1, end=3)
doc.sections.create(tag='mvmt:2', start=4, end=8)
doc.sections.create(tag='mvmt:3', start=9, end=12)
doc.sections.create(tag=f'inst:{g}')
# no tags - get nothing (should it be everything?)
self.assertEqual(work.extract(), [])
# single tag - should get just that range
self.assertEqual(work.extract('inst:vl1'), [('sel/beethoven/some_quartet/some_quartet_vl1.pdf', None, None)])
# single tag - returns all documents with that range
result = work.extract('mvmt:2')
self.assertEqual(len(result), 4)
# multiple tags - returns the overlapping portion of all documents that have all tags
self.assertEqual(work.extract('inst:vl1', 'mvmt:2'), [('sel/beethoven/some_quartet/some_quartet_vl1.pdf', 4, 8)])
self.assertEqual(work.extract('inst:vl1', 'inst:vl2'), [])
+29
View File
@@ -0,0 +1,29 @@
from django.urls import path
from django.contrib.auth import views as auth_views
from . import views
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/collections', views.CollectionListView.as_view(), name="collection_list"),
path('library/collections/<int:pk>', views.CollectionWorkListView.as_view(), name="collection_work_list"),
path('library/collection/<int:pk>/create', views.WorkAddView.as_view(), name="work_add"),
path('library/works', views.WorkListView.as_view(), name="work_list"),
path('library/works/<int:pk>', views.WorkDetailView.as_view(), name="work_detail"),
path('library/works/<int:pk>/edit', views.WorkUpdateView.as_view(), name="work_edit"),
path('library/works/<int:pk>/partset', views.WorkPartSetView.as_view(), name="work_partset"),
path('library/works/<int:pk>/add_to_project', views.WorkAddToProject.as_view(), name="work_add_to_project"),
path('library/works/<int:pk>/upload', views.WorkAddDocumentView.as_view(), name="document_add"),
path('library/documents/<int:pk>/download', views.DocumentDownloadView.as_view(), name="document_download"),
path('library/documents/<int:pk>/annotate', views.DocumentAnnotateView.as_view(), name="document_annotate"),
path('library/parts/<int:pk>/<str:filename>', views.PartDownloadView.as_view(), name="part_download"),
]
from django.views.static import serve
urlpatterns.append(path('docs/<path:path>', serve, {'document_root': 'local_storage'}))
+361
View File
@@ -0,0 +1,361 @@
from django.shortcuts import render, 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
from django.http import FileResponse, HttpResponse, JsonResponse
from django.db import IntegrityError
from django.db.models import Q, Count, Sum
from django.utils.timezone import now
from django.urls import reverse
import json
import os.path
from interface.views import EnsembleMixin, ProjectMixin
from interface.models import Project
from .models import Collection, Work, Document, Section
from .imslp import INSTRUMENTS
from . import forms, models
from .pdf_utils import extract_pages, extract_and_concat
class ProjectItemListView(ProjectMixin, ListView):
template_name = "library/item_list.html"
model = models.ProjectItem
def post(self, request, **kwargs):
project = self.get_project()
project_works = 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'{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'] = INSTRUMENTS
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']
#if running_time:
# data['running_time'] = "{0:d}:{1:02d}".format(int(running_time / 60), running_time % 60)
#else:
# data['running_time'] = "-:--"
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()
class CollectionListView(EnsembleMixin, ListView):
def get_queryset(self):
return Collection.objects.filter(administrators=self.request.user)
class WorkListView(EnsembleMixin, ListView):
paginate_by = 20
def get_works(self):
return Work.objects.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id).order_by('name').distinct().select_related('collection')
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}'
return data
def get_queryset(self):
works = self.get_works().order_by('name')
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
class CollectionWorkListView(WorkListView):
def get_works(self):
works = Work.objects.filter(collection=self.kwargs['pk']).distinct()
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'] = Collection.objects.get(pk=self.kwargs['pk']).name
data['collection_id'] = self.kwargs['pk']
return data
class WorkAddView(EnsembleMixin, FormView):
template_name = "interface/default_form.html"
form_class = forms.WorkCreateForm
title = "Add a new work"
def form_valid(self, form):
work = form.save(commit=False)
work.ensemble_id = self.request.ensemble_id
work.collection_id = self.kwargs['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', pk=work.pk)
class WorkMixin(object):
def get_queryset(self):
if self.request.is_admin:
return Work.objects.all()
return Work.objects.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id)
class WorkDetailView(EnsembleMixin, WorkMixin, DetailView):
pass
class WorkUpdateView(EnsembleMixin, WorkMixin, UpdateView):
fields = ['name', 'composer', 'edition', 'code', 'licence', 'max_projects', 'running_time', 'notes']
template_name = 'interface/default_form.html'
def get_success_url(self):
return resolve_url('work_detail', self.kwargs['pk'])
class WorkAddToProject(EnsembleMixin, 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
work = self.get_object()
qs = qs.filter(ensemble_id=self.request.ensemble_id).exclude(pk__in=work.projects.all())
f.fields['project'].queryset = qs
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 WorkPartSetView(EnsembleMixin, 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):
part = work.digital_parts.select_related('doc').get(tag=tag)
sections.append((part.doc.upload.path, part.instrument, part.start, part.end, int(copies[i])))
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 WorkAddDocumentView(EnsembleMixin, 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):
doc = form.save(commit=False)
doc.work_id = self.kwargs['pk']
doc.save()
if self.request.headers['Accept'] == 'application/json':
filename = os.path.basename(doc.upload.name)
return JsonResponse({
"message": "created",
"id": doc.pk,
"entry": f"""
<td><a href="{reverse('document_download', args=[doc.pk])}">{filename}</a></td>
<td/>
<td class="has-text-right">
<a href="{reverse('document_annotate', args=[doc.pk])}"><i class="fas fa-tags"
title="Manage Tags"></i></a>
<a href=""><i class="fas fa-trash-alt" title="Delete Document"></i></a>
</td>
"""
}, status=201)
return redirect('document_annotate', doc.pk)
class DocumentMixin(object):
def get_queryset(self):
if self.request.is_admin:
return Document.objects.select_related('work')
return Document.objects.filter(work__ensemble=self.request.ensemble_id).select_related('work')
class DocumentDetailView(EnsembleMixin, DocumentMixin, DetailView):
pass
class DocumentDownloadView(EnsembleMixin, 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(EnsembleMixin, 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)
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(INSTRUMENTS)}
return data
class PartDownloadView(EnsembleMixin, SingleObjectMixin, View):
def get(self, request, **args):
self.request = request
self.args = args
self.object = self.get_object()
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="foo.pdf"'
return response
def get_queryset(self):
if self.request.is_admin:
parts = Section.objects.all()
else:
parts = Section.objects.filter(doc__work__collection__allowed_ensembles__ensemble=self.request.ensemble_id)
return parts.select_related('doc', 'doc__work')
Executable
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python3
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'polyphonic.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
View File
+16
View File
@@ -0,0 +1,16 @@
"""
ASGI config for polyphonic project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'polyphonic.settings')
application = get_asgi_application()
+137
View File
@@ -0,0 +1,137 @@
"""
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',
'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 = ''
+7
View File
@@ -0,0 +1,7 @@
try:
from .local_settings import *
except ImportError:
from .default_settings import *
INSTALLED_APPS += POLYPHONIC_MODULES
+30
View File
@@ -0,0 +1,30 @@
"""polyphonic URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/3.1/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, re_path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('interface.urls')),
#path('', include('submissions.urls')),
path('', include('library.urls')),
]
try:
import debug_toolbar
urlpatterns.append(path('__debug__', include(debug_toolbar.urls)))
except ImportError:
pass
+16
View File
@@ -0,0 +1,16 @@
"""
WSGI config for polyphonic project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'polyphonic.settings')
application = get_wsgi_application()
+14
View File
@@ -0,0 +1,14 @@
asgiref==3.4.1
boto3==1.18.34
botocore==1.21.34
crispy-bulma==0.8.0
Django==3.2.7
django-byostorage @ git+https://gitea.tfconsulting.com.au/tris/django-byostorage.git@4d87edd995a2b0c154298bd00b3fe0abb78ba72c
django-crispy-forms==1.14.0
django-markdown2==0.3.1
markdown2==2.4.1
python-dateutil==2.8.2
pytz==2021.1
six==1.16.0
sqlparse==0.4.1
urllib3==1.26.6