Refactored into /app
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
pylint==2.6.0
|
||||
django-debug-toolbar
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class InterfaceConfig(AppConfig):
|
||||
name = 'interface'
|
||||
@@ -0,0 +1,4 @@
|
||||
from crispy_forms.layout import Field
|
||||
|
||||
class BulmaFileUpload(Field):
|
||||
template = 'bulma/file_upload.html'
|
||||
@@ -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()
|
||||
@@ -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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
<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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.test import TestCase
|
||||
|
||||
class IntegrationTestCase(TestCase):
|
||||
|
||||
def test_runs(self):
|
||||
self.assertTrue(True)
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LibraryConfig(AppConfig):
|
||||
name = 'library'
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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'])
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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}]'
|
||||
@@ -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 %}
|
||||
@@ -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'), [])
|
||||
@@ -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'}))
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
try:
|
||||
from .local_settings import *
|
||||
except ImportError:
|
||||
from .default_settings import *
|
||||
|
||||
|
||||
INSTALLED_APPS += POLYPHONIC_MODULES
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user