Library working:

This commit is contained in:
Tris 2021-03-22 10:30:13 +11:00
parent 35555c3321
commit 1581f56b74
32 changed files with 511 additions and 211 deletions

View File

@ -1,6 +1,18 @@
## Polyphonic
A simple web app for managing video uploads to an S3 bucket for virtual ensembles.
A simple web app managing scores, large files and submissions in a manor tailored to musical ensembles.
No registration required for ensemble participants - just a one time code and passphrase.
### Library App
Store all your scores on your own cloud account (Amazon S3, Google Files etc). Tag up the scores so you can generate
custom part sets and assign them to projects so people can easily print just their parts.
### Submissions App
Accept video/audio submissions direct to your cloud storage. Was developed and used during 2020 lockdown period for
virtual choirs/orchestras but could have more uses.
### S3 Setup

View File

@ -7,7 +7,7 @@ class EnsembleAdmin(admin.ModelAdmin):
class ProjectAdmin(admin.ModelAdmin):
list_display = ['name', 'ensemble', 'deadline', 'active', 'slug']
list_display = ['name', 'ensemble', 'event_date', 'active', 'slug']
list_filter = ['ensemble', 'active']
class SubmissionAdmin(admin.ModelAdmin):

View File

@ -0,0 +1,93 @@
# Generated by Django 3.1.1 on 2021-03-21 23:10
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import interface.models
class Migration(migrations.Migration):
replaces = [('interface', '0001_initial'), ('interface', '0002_auto_20200904_1004'), ('interface', '0003_auto_20200905_0118'), ('interface', '0004_auto_20200905_0127'), ('interface', '0005_auto_20200905_0638'), ('interface', '0006_submission_key'), ('interface', '0007_auto_20200906_1009'), ('interface', '0008_auto_20200906_1122'), ('interface', '0009_auto_20200907_0103'), ('interface', '0010_auto_20200907_0148'), ('interface', '0011_auto_20200907_0234'), ('interface', '0012_remove_ensemble_bucket'), ('interface', '0013_auto_20200907_1455'), ('interface', '0014_auto_20200909_1016'), ('interface', '0015_resource_media_type'), ('interface', '0016_auto_20200910_2025'), ('interface', '0017_auto_20200914_0943'), ('interface', '0018_auto_20200914_1009'), ('interface', '0019_project_owner'), ('interface', '0020_auto_20201003_2103'), ('interface', '0021_project_description')]
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
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)),
('active', models.BooleanField(default=True)),
],
),
migrations.CreateModel(
name='WikiPage',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('markdown', models.TextField()),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='wiki_pages', to='interface.project')),
('title', models.CharField(default='', max_length=255)),
],
),
migrations.CreateModel(
name='Ensemble',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('code', models.CharField(default=interface.models.generate_code, max_length=9)),
('passphrase', models.CharField(max_length=100)),
('admins', models.ManyToManyField(related_name='ensembles', to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='project',
name='ensemble',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='interface.ensemble'),
),
migrations.AddField(
model_name='project',
name='deadline',
field=models.DateField(blank=True, null=True),
),
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)),
('key', models.CharField(blank=True, max_length=255)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='resources', to='interface.project')),
('description', models.TextField(blank=True)),
('media_type', models.CharField(choices=[('audio', 'Audio'), ('video', 'Video'), ('general', 'General')], default='*', max_length=10)),
('visible', models.BooleanField(default=True)),
],
),
migrations.AddField(
model_name='project',
name='owner',
field=models.CharField(blank=True, max_length=255),
),
migrations.CreateModel(
name='Submission',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('instrument', models.CharField(max_length=100, verbose_name='Instrument / Voice')),
('notes', models.TextField(blank=True)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='all_submissions', to='interface.project')),
('date', models.DateTimeField(auto_now_add=True)),
('complete', models.BooleanField(default=False)),
('url', models.CharField(blank=True, max_length=512)),
('private', models.BooleanField(default=False)),
],
),
migrations.AddField(
model_name='project',
name='description',
field=models.TextField(blank=True),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.1.1 on 2021-03-12 02:56
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('interface', '0022_auto_20210303_2043'),
]
operations = [
migrations.AddField(
model_name='ensemble',
name='storage',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='byostorage.userstorage'),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.1.1 on 2021-03-12 06:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('interface', '0023_ensemble_storage'),
]
operations = [
migrations.AddField(
model_name='project',
name='accept_submissions',
field=models.BooleanField(default=False, help_text='Allow media submissions from participants'),
),
migrations.AddField(
model_name='project',
name='has_items',
field=models.BooleanField(default=True, help_text='Enable items to be added from the library'),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.1.1 on 2021-03-12 06:13
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('interface', '0024_auto_20210312_1712'),
]
operations = [
migrations.RenameField(
model_name='project',
old_name='has_items',
new_name='enable_library',
),
migrations.RenameField(
model_name='project',
old_name='accept_submissions',
new_name='enable_submissions',
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.1.1 on 2021-03-12 22:26
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('interface', '0025_auto_20210312_1713'),
]
operations = [
migrations.RenameField(
model_name='project',
old_name='deadline',
new_name='event_date',
),
]

View File

@ -23,6 +23,18 @@ MEDIA_TYPES = [
('general', "General"),
]
def rough_date(d):
days = (self.event_date - timezone.now().date()).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(days / 7, days % 7)
return in_past, f"{days} days"
def generate_code(length=9):
return "".join([ random.choice('0123456789') for _ in range(length) ])
@ -32,6 +44,7 @@ class Ensemble(models.Model):
passphrase = models.CharField(max_length=100)
admins = models.ManyToManyField('auth.User', related_name='ensembles')
slug = models.SlugField(max_length=100, editable=False)
storage = models.ForeignKey('byostorage.UserStorage', null=True, on_delete=models.SET_NULL)
def active_projects(self):
return self.projects.filter(active=True)
@ -53,9 +66,11 @@ class Project(models.Model):
ensemble = models.ForeignKey(Ensemble, related_name='projects', on_delete=models.CASCADE, null=True)
description = models.TextField(blank=True)
active = models.BooleanField(default=True)
deadline =models.DateField(null=True, blank=True)
event_date =models.DateField(null=True, blank=True)
owner = models.CharField(max_length=255, blank=True)
slug = models.SlugField(max_length=100, editable=False)
enable_library = models.BooleanField(default=True, help_text="Enable items to be added from the library")
enable_submissions = models.BooleanField(default=False, help_text="Allow media submissions from participants")
@property
def submissions(self):
@ -65,6 +80,14 @@ class Project(models.Model):
key = os.path.join(self.slug, object_name)
return s3client.generate_presigned_post(BUCKET, key, Fields=fields or {}, Conditions=conditions or [], ExpiresIn=expires)
@property
def days(self):
return (self.event_date - timezone.now().date()).days
@property
def has_happened(self):
return self.event_date < timezone.now()
def save(self):
if not self.slug:
self.slug = slugify(self.name)

View File

@ -313,7 +313,8 @@ TD.select-cell {
}
DIV.selected {
background-color: #EEE;
background-color: var(--gray-blue);
color: white;
border: 1px solid #999;
border-radius: 5px;
}

View File

@ -5,6 +5,7 @@
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="icon" type="image/png" href="{% static 'interface/icon.png' %}" />
<link rel="stylesheet" href="{% static 'interface/css/polyphonic.css' %}"></link>
<title>{% block title %}Polyphonic{% endblock %}</title>

View File

@ -1,11 +1,11 @@
{% extends "base.html" %}
{% extends "interface/project_base.html" %}
{% block content %}
{% block page %}
<div style="flex-grow: 1">
<h1>Projects for {{ ensemble.name }}</h1>
<div class="list-group narrow">
{% for project in ensemble.active_projects %}
<a class="" href="{% url 'project_detail' pk=project.id %}">
<a class="" href="{% url 'project_detail' project=project.id %}">
<h3>{{ project.name }}</h3>
<p><small>
{% if project.deadline %}In {{ project.deadline|timeuntil }}<br/>{% endif %}
@ -15,7 +15,7 @@
</a>
{% endfor %}
</div>
<div style="text-align: right; margin-top: 10px; color: #999;">
<div style="float: right; margin-top: 10px; color: #999;">
<small>{{ ensemble.ensemble_code }}</small>
</div>
</div>

View File

@ -10,25 +10,32 @@
{% endif %}
<h1>{{ project.name }}</h1>
{% block page %}
No content
{% endblock %}
{% if project %}
<div class="project-links">
<div class="pills" role="tablist">
<a role="tab" href="{% url 'project_detail' pk=project.id %}">Project info</a>
{% if project %}
<a role="tab" href="{% url 'project_detail' project=project.id %}">Project info</a>
{% for page in project.wiki_pages.all %}
<a class="nav-link {% if page.id == wiki_id %}active{% endif %}"
href="{% url 'wiki' project=project.id pk=page.id %}">{{ page.title }}</a>
{% endfor %}
{% endif %}
{% if project and project.enable_submissions%}
<a role="tab" href="{% url 'resource_list' project=project.pk %}">Resources</a>
<!--a role="tab" href="">Record a submission</a-->
{% if request.is_admin %}
<a role="tab" href="{% url 'submission_list' project=project.id %}">Submissions</a>
{% endif %}
<a role="tab" href="{% url 'submission_create' project=project.id %}">Send a file</a>
{% endif %}
{% include "library/project_menu.html" %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -2,30 +2,25 @@
{% load md2 %}
{% block page %}
{% block page %}
<div class="narrow">
{% if project.deadline %}
<h3 class="text-center">In {{ project.deadline|timeuntil }}</h3>
{% if project.event_date %}
<h3 class="text-center">{{ project.when }}</h3>
{% endif %}
<p>{{ project.description|markdown }}</p>
<p>{{ project.description|markdown }}</p>
{% if project.owner %}
<p>Project email: <a href="mailto:{{ project.owner }}">{{ project.owner }}</a></p>
<p>Project email: <a href="mailto:{{ project.owner }}">{{ project.owner }}</a></p>
{% endif %}
{% with sub_count=project.submissions.count %}
<p>There have been {{ sub_count }} submission{{ sub_count|pluralize }} so far...</p>
{% if sub_count %}
<h4>Recent submissions</h4>
<table style="width: 100%">
<tbody">
{% for submission in project.submissions|slice:":5" %}
<tr>
<td>{{ submission.date|timesince }} ago</td>
<td>{{ submission.name }} ({{ submission.instrument }})</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if project.enable_submissions %}
{% include 'submissions/project_detail.html' %}
{% endif %}
{% endwith %}
{% if project.enable_library %}
{% include 'library/project_detail.html' %}
{% endif %}
</div>
{% endblock %}
{% endblock %}

View File

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

View File

@ -11,8 +11,8 @@ urlpatterns = [
path('manage', views.ManageView.as_view(), name="manage"),
path('', views.EnsembleDetailView.as_view(), name='ensemble_detail'),
path('projects/<int:pk>', views.ProjectDetailView.as_view(), name="project_detail"),
path('projects/<int:pk>/submissions.mk', views.ProjectMakefileView.as_view(), name="project_makefile"),
path('projects/<int:project>', views.ProjectDetailView.as_view(), name="project_detail"),
path('projects/<int:project>/submissions.mk', views.ProjectMakefileView.as_view(), name="project_makefile"),
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"),

View File

@ -207,10 +207,10 @@ class EnsembleDetailView(EnsembleMixin, DetailView):
def get_object(self):
return models.Ensemble.objects.get(pk=self.request.ensemble_id)
class ProjectDetailView(EnsembleMixin, DetailView):
class ProjectDetailView(ProjectMixin, DetailView):
def get_queryset(self):
return models.Project.objects.filter(ensemble=self.request.ensemble_id)
def get_object(self):
return self.get_project()
class ProjectMakefileView(EnsembleMixin, DetailView):
template_name = 'interface/project_submissions.mk'

View File

@ -2,12 +2,17 @@ from django.contrib import admin
from . import models
class PlaylistInline(admin.TabularInline):
model = models.Playlist
class OrchestrationAdmin(admin.ModelAdmin):
list_display = ['name', 'ensemble']
list_filter = ['ensemble']
class ItemInline(admin.TabularInline):
model = models.Item
class WorkAdmin(admin.ModelAdmin):
list_display = ['name', 'orchestration']
inlines = [PlaylistInline]
list_filter = ['ensemble']
inlines = [ItemInline]
class PartInline(admin.TabularInline):
model = models.Part
@ -15,12 +20,14 @@ class PartInline(admin.TabularInline):
class DocumentAdmin(admin.ModelAdmin):
list_display = ['work', '__str__']
list_filter = ['work__ensemble']
inlines = [PartInline]
class PlaylistAdmin(admin.ModelAdmin):
class ItemAdmin(admin.ModelAdmin):
list_display = ['project', 'work', 'order']
list_filter = ['project']
admin.site.register(models.Orchestration, OrchestrationAdmin)
admin.site.register(models.Work, WorkAdmin)
admin.site.register(models.Document, DocumentAdmin)
admin.site.register(models.Playlist, PlaylistAdmin)
admin.site.register(models.Item, ItemAdmin)

View File

@ -1,9 +1,9 @@
# Generated by Django 3.1.1 on 2021-03-10 22:20
# Generated by Django 3.1.1 on 2021-03-11 07:07
from django.db import migrations, models
import django.db.models.deletion
import library.models
import library.storage
import byostorage.cached
class Migration(migrations.Migration):
@ -19,11 +19,11 @@ class Migration(migrations.Migration):
name='Document',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('upload', models.FileField(storage=library.storage.RemoteCachedStorage(), upload_to=library.models.upload_filename)),
('upload', models.FileField(storage=byostorage.cached.CachedStorage(), upload_to=library.models.doc_upload_filename)),
],
),
migrations.CreateModel(
name='Playlist',
name='Item',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('order', models.SmallIntegerField(default=0)),
@ -33,27 +33,31 @@ class Migration(migrations.Migration):
'ordering': ['order', 'work'],
},
),
migrations.CreateModel(
name='Orchestration',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('instruments', models.TextField()),
('ensemble', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='orchestrations', to='interface.ensemble')),
],
),
migrations.CreateModel(
name='Work',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(editable=False, max_length=100)),
('name', models.CharField(max_length=255)),
('orchestration', models.CharField(choices=[('SATB', 'SATB'), ('String Quartet', 'String Quartet'), ('Chamber Orchestra', 'Chamber Orchestra'), ('RWE', 'RWE'), ('Custom', 'Custom')], max_length=100)),
('running_time', models.IntegerField(blank=True, null=True)),
('notes', models.TextField(blank=True)),
('ensemble', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='works', to='interface.ensemble')),
('projects', models.ManyToManyField(related_name='works', through='library.Playlist', to='interface.Project')),
('orchestration', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='works', to='library.orchestration')),
('projects', models.ManyToManyField(related_name='works', through='library.Item', to='interface.Project')),
],
options={
'unique_together': {('ensemble', 'slug')},
},
),
migrations.AddField(
model_name='playlist',
name='work',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='library.work'),
),
migrations.CreateModel(
name='Part',
fields=[
@ -68,6 +72,11 @@ class Migration(migrations.Migration):
'ordering': ['doc', 'start', 'pk'],
},
),
migrations.AddField(
model_name='item',
name='work',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='library.work'),
),
migrations.AddField(
model_name='document',
name='work',

View File

@ -0,0 +1,27 @@
# Generated by Django 3.1.1 on 2021-03-11 06:26
from django.db import migrations
ORCHESTRATIONS = {
'SATB': ('S', 'A', 'T', 'B'),
'String Quartet': ('Vln1', 'Vln2', 'Vla', 'Vc'),
'Chamber Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb',
'Fl1', 'Fl2', 'Cl1', 'Cl2', 'Ob1', 'Ob2', 'Hn1', 'Hn2',
'Tpt1', 'Tpt2', 'Tbn1', 'Tbn2', 'Tuba',
'Timp', 'Drum', 'Perc'),
}
def add_orchestrations(apps, schema_editor):
q = apps.get_model('library', 'Orchestration').objects
for name, instruments in ORCHESTRATIONS.items():
q.create(name=name, instruments=", ".join(instruments))
class Migration(migrations.Migration):
dependencies = [
('library', '0001_initial'),
]
operations = [
migrations.RunPython(add_orchestrations, lambda apps, schema_editor: None),
]

View File

@ -34,7 +34,7 @@ INSTRUMENTS = [
('Tbn', 'Trombone'),
('Tuba', 'Tuba'),
('Timp', 'Timpani'),
('Drum', 'Drupset'),
('Drum', 'Drumset'),
('Perc', 'Percussion'),
('Pno', 'Piano'),
('Hp', 'Harp'),
@ -58,14 +58,30 @@ def tag_to_instrument(tag):
l = m.groups()
return "{0} {1}".format(dict(INSTRUMENTS).get(l[0],l[0]), l[1]).strip()
class Orchestration(models.Model):
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 Work(models.Model):
ensemble = models.ForeignKey('interface.Ensemble', on_delete=models.CASCADE, related_name="works")
slug = models.SlugField(max_length=100, editable=False)
name = models.CharField(max_length=255)
orchestration = models.CharField(max_length=100, choices=[ (k, k) for k, v in ORCHESTRATIONS.items() ])
orchestration = models.ForeignKey(Orchestration, null=True, on_delete=models.SET_NULL, related_name='works')
running_time = models.IntegerField(null=True, blank=True)
notes = models.TextField(blank=True)
projects = models.ManyToManyField('interface.Project', through='Playlist', related_name="works")
projects = models.ManyToManyField('interface.Project', through='Item', related_name="works")
class Meta:
unique_together = ['ensemble', 'slug']
@ -76,8 +92,7 @@ class Work(models.Model):
@property
def instruments(self):
tags = ORCHESTRATIONS.get(self.orchestration, 'Custom')
return [ (tag, tag_to_instrument(tag)) for tag in tags ]
return self.orchestration.as_list()
def save(self):
if not self.slug:
@ -87,7 +102,11 @@ class Work(models.Model):
def __str__(self):
return self.name
class Playlist(models.Model):
class Item(models.Model):
"""
Item represents a Work in 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)
work = models.ForeignKey('Work', on_delete=models.CASCADE)
order = models.SmallIntegerField(default=0)
@ -98,12 +117,15 @@ class Playlist(models.Model):
def __str__(self):
return f"<{self.project.slug}:{self.work.slug}>"
def upload_filename(instance, filename):
return f'{instance.work.ensemble.slug}/works/{instance.work.slug}/{filename}'
def doc_upload_filename(doc, filename):
storage = doc.work.ensemble.storage
if not storage:
raise RuntimeError("Storage not set")
return f'{storage}:{doc.work.ensemble.slug}/works/{doc.work.slug}/{filename}'
class Document(models.Model):
work = models.ForeignKey('Work', on_delete=models.CASCADE, related_name="docs")
upload = models.FileField(upload_to=upload_filename, storage=library_storage)
upload = models.FileField(upload_to=doc_upload_filename, storage=library_storage)
def __str__(self):
return self.upload.name

View File

@ -1,19 +1,28 @@
import tempfile
import subprocess
import os.path
import string
def extract_pages(source, bookmark, start=None, end=None):
SAFECHARS = string.ascii_letters + string.digits + " _-"
return extract_and_concat([(source, bookmark, start, end)])
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) in enumerate(items):
for i, (source, bookmark, start, end, count) in enumerate(items):
if count == 0:
continue
if start is None:
sections.append(source)
@ -25,13 +34,22 @@ def extract_and_concat(items):
dest = os.path.join(d.name, f'section_{i}.pdf')
cmd = ['gs', '-sDEVICE=pdfwrite', '-q', '-dBATCH', '-dNOPAUSE',
cmd = ['gs', '-sDEVICE=pdfwrite', '-dBATCH', '-dNOPAUSE',
f'-dFirstPage={start}', f'-dLastPage={end}',
f'-sOutputFile={dest}',
source]
subprocess.run(cmd, check=True)
sections.append(dest)
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')
@ -39,6 +57,7 @@ def extract_and_concat(items):
cmd = ['gs', '-sDEVICE=pdfwrite', '-q', '-dBATCH', '-dNOPAUSE',
'-sOutputFile=-']
cmd.extend(sections)
cmd.append(pdfmarks)
subprocess.run(cmd, stdout=output)

View File

@ -1,96 +0,0 @@
from django.core.files.storage import Storage, get_storage_class
from django.utils.deconstruct import deconstructible
from django.conf import settings
from hashlib import sha1
import os.path
import shutil
import tempfile
import time
import logging
logger = logging.getLogger(__name__)
@deconstructible
class RemoteCachedStorage(Storage):
CACHE_EXPIRES = 30
def __init__(self, remote=None, cachedir=None):
if not remote:
remote = settings.CACHED_STORAGE_REMOTE
if not cachedir:
cachedir = settings.CACHED_STORAGE_DIR
self.remote = get_storage_class(remote)()
self.cachedir = cachedir
os.makedirs(self.cachedir, exist_ok=True)
self.clean()
def _filepath(self, name):
base, ext = os.path.splitext(name)
filename = sha1(base.encode('utf8')).hexdigest() + ext
return os.path.join(self.cachedir, filename)
def _cached(self, name):
p = self._filepath(name)
if not os.path.exists(p):
logger.debug("Caching %s to %s", name, p)
source = self.remote._open(name, 'rb')
dest = tempfile.NamedTemporaryFile(dir=self.cachedir, delete=False, prefix="_")
shutil.copyfileobj(source, dest)
source.close()
dest.close()
os.rename(dest.name, p)
now = time.time()
os.utime(p, (now, now))
if now > self.next_check:
self.clean() # wont get this file as we just touched it
return p
def _open(self, name, mode='rb'):
p = self._cached(name)
return open(p, mode)
def path(self, name):
return self._cached(name)
def _save(self, name, content):
return self.remote._save(name, content)
def delete(self, name):
return self.remote.delete(name)
def exists(self, name):
return self.remote.exists(name)
def listdir(self, name):
return self.remote.listdir(name)
def size(self, name):
return self.remote.size(name)
def url(self, name):
return self.remote.url(name)
def get_valid_name(self, name):
return self.remote.get_valid_name(name)
def get_available_name(self, name, max_length=None):
return self.remote.get_available_name(name, max_length)
def get_alternative_name(self, file_root, file_ext):
return self.remote.get_alternative_name(file_root, file_ext)
def clean(self):
now = time.time()
threshold = now - self.CACHE_EXPIRES
logger.info("Removing cached files older than %d seconds", self.CACHE_EXPIRES)
for f in os.listdir(self.cachedir):
f = os.path.join(self.cachedir, f)
s = os.stat(f)
if s.st_atime < threshold:
logger.debug("Removing %s", f)
os.unlink(f)
self.next_check = now + 300

View File

@ -1,27 +1,37 @@
{% extends "interface/project_base.html" %}
{% block admin %}
<a href="{% url 'playlist_manage' project.pk %}"><i class="fas fa-list"></i><span class="smhide admin">Change works</span></a>
<a href="{% url 'item_list_manage' project.pk %}"><i class="fas fa-list"></i><span class="smhide admin">Change items</span></a>
{% endblock %}
{% block page %}
<p>
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 in the list or click on a piece for more download options.
You can also tweak which parts you get for each piece, or click on a piece for more download options.
</p>
<form action="" method="post" target="_blank" style="display: flex;">
{% csrf_token %}
<table style="max-width: 600px; margin: 10pt auto;" class="item-table">
<thead>
</thead>
<div style="margin: 0px 20px; text-align: right;">
<label for="instrument-select">Pick your instrument</label>
<select id="instrument-select" name="instrument" onchange="updateParts()">
{% for tag, instrument in instruments %}
<option value="{{ tag }}">{{ instrument }}</option>
{% endfor %}
</select><br/><br/>
<label for="part-preference">Part preference</label>
<input id="part-preference" name="part" type="number" value="0" min="0" max="4" onchange="updateParts()" size="1"/><br/><br/>
<button type="submit" style="height: 32px;"><i class="fas fa-copy"></i> Get My Parts!</button>
</div>
<table style="max-width: 600px; margin: 10pt auto;" class="">
<tbody>
{% for item in object_list %}
<tr>
<td>{{ forloop.counter }})</td>
<td>
<a href="{% url 'work_detail' item.work.pk %}">{{ item.work.name }}</a>
</td>
<td class="select-cell">
<td class="">
<input type="hidden" name="works" value="{{ item.work.pk }}"/>
<select name="instruments">
<option value='-'>None</option>
@ -34,17 +44,6 @@
{% endfor %}
</tbody>
</table>
<div style="margin: 0px 20px; text-align: right;">
<label for="instrument-select">Pick your instrument</label>
<select id="instrument-select" name="instrument" onchange="updateParts()">
{% for tag, instrument in instruments %}
<option value="{{ tag }}">{{ instrument }}</option>
{% endfor %}
</select><br/><br/>
<label for="part-preference">Part preference</label>
<input id="part-preference" name="part" type="number" value="0" min="0" max="4" onchange="updateParts()" size="1"/><br/><br/>
<button type="submit" style="height: 32px;"><i class="fas fa-copy"></i> Get My Parts!</button>
</div>
</form>
{% endblock %}

View File

@ -2,28 +2,31 @@
{% block admin %}
<a href="#" onclick="save()"><i class="fas fa-save"></i><span class="smhide action">Save</span></a>
<a href="{% url 'playlist_append' project.pk %}"><i class="fas fa-plus-circle"></i><span class="smhide action">Add</a></a>
<a href="{% url 'item_list_append' project.pk %}"><i class="fas fa-plus-circle"></i><span class="smhide action">Add</a></a>
{% endblock %}
{% block page %}
<table style="max-width: 600px; margin: 10pt auto;" class="item-table">
<thead>
<tr>
<th>Piece</th>
<th>Part</th>
<th>Item</th>
<th>Time</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>{% firstof item.work.running_time '?' %}</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"></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>
@ -47,8 +50,13 @@ function moveItem(pk, dir) {
// check the direction is sensible
if (i + dir < 0 || i + dir >= items.length) return;
items[i].dataset.order = parseInt(items[i].dataset.order) + dir;
items[i+dir].dataset.order = parseInt(items[i+dir].dataset.order) - dir;
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))
@ -76,7 +84,7 @@ function save() {
}).then((response) => {
if (response.ok) {
dirty=false;
window.location = "{% url 'project_playlist' project=project.pk %}";
window.location = "{% url 'item_list' project=project.pk %}";
} else {
alert("Failed: " + response.statusText)
}

View File

@ -0,0 +1 @@
<p>Click the '<a href="{% url 'item_list' project=project.pk %}">My Music</a>' button below to access your parts...</p>

View File

@ -1 +1,6 @@
<a href="{% url 'project_playlist' project=project.pk %}">My Music</a>
{% if project %}
<a href="{% url 'item_list' project=project.pk %}">My Music</a>
{% endif %}
{% if request.is_admin %}
<a href="{% url 'work_list' %}">Works</a>
{% endif %}

View File

@ -1,4 +1,5 @@
{% extends 'interface/project_base.html' %}
{% load path_filters %}
{% block admin %}
<a href="{% url 'document_add' work.pk %}"><i class="fas fa-plus-circle"></i> Upload file</a>
@ -13,14 +14,17 @@
<li><a href="{% url 'part_download' pk=part.pk filename=part.filename %}" target="part_{{ part.pk }}" rel="">{{ part.instrument }}</a></li>
{% endfor %}
</ul>
<a href="{% url 'work_partset' pk=work.pk %}"><i class="fas fa-print"></i> Print part set</a>
<h3>Original Documents</h3>
<ul>
{% for doc in work.docs.all %}
<li>
<a href="{% url 'document_download' pk=doc.pk %}" target="_blank">{{ doc.upload.name }}</a> [{{ doc.parts.count }} parts]
<a href="{% url 'document_download' pk=doc.pk %}" target="_blank">{{ doc.upload.name|basename }}</a>
&nbsp;
[{{ doc.parts.count }} parts]
{% if request.is_admin %}
<a href="{% url 'document_annotate' pk=doc.pk %}"><i class="fas fa-edit"></i></a>
<a href="{% url 'document_annotate' pk=doc.pk %}"><i class="fas fa-tags"></i></a>
{% endif %}
</li>
{% endfor %}

View File

@ -0,0 +1,31 @@
{% extends "interface/project_base.html" %}
{% block page %}
<h2><a href="{% url 'work_list' %}">Works</a> / <a href="{% url 'work_detail' pk=work.pk %}">{{ work.name }}</a></h2>
<form action="" method="post" target="_blank">
{% csrf_token %}
<table style="margin: 0px auto">
<thead>
<tr>
<th>Part</th>
<th>Copies</th>
</tr>
</thead>
<tbody>
{% for part in work.parts %}
<tr>
<td>{{ part.instrument }}</td>
<td>
<input name="parts" type="hidden" value="{{ part.tag }}">
<input name="copies" type="number" value="{% if part.tag == 'Score' %}0{% else %}1{% endif %}" size="1">
</td>
</tr>
{% endfor %}
<tr>
<td colspan="2" style="text-align: center;"><button type="submit"><i class="fas fa-print"></i> Print</button></td>
</tr>
</tbody>
</table>
</form>
{% endblock %}

View File

@ -5,13 +5,14 @@ from . import views
urlpatterns = [
path('projects/<int:project>/works', views.PlaylistView.as_view(), name="project_playlist"),
path('projects/<int:project>/works/manage', views.PlaylistManageView.as_view(), name="playlist_manage"),
path('projects/<int:project>/works/append', views.PlaylistAddView.as_view(), name="playlist_append"),
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/works', views.WorkListView.as_view(), name="work_list"),
path('library/works/add', views.WorkAddView.as_view(), name="work_add"),
path('library/works/create', views.WorkAddView.as_view(), name="work_add"),
path('library/works/<int:pk>', views.WorkDetailView.as_view(), name="work_detail"),
path('library/works/<int:pk>/partset', views.WorkPartSetView.as_view(), name="work_partset"),
path('library/works/<int:pk>/upload', views.DocumentAddView.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"),

View File

@ -1,6 +1,6 @@
from django.shortcuts import render, redirect, resolve_url
from django.views.generic.detail import DetailView, SingleObjectMixin, View
from django.views.generic.list import ListView
from django.views.generic.list import ListView, MultipleObjectMixin
from django.views.generic.edit import CreateView, FormView, UpdateView
from django.http import FileResponse, HttpResponse
from django.db import IntegrityError
@ -9,13 +9,13 @@ import json
from interface.views import EnsembleMixin, ProjectMixin
from interface.models import Project
from .models import Work, Document, Part, Playlist, INSTRUMENTS
from . import forms
from .models import Work, Document, Part, INSTRUMENTS
from . import forms, models
from .pdf_utils import extract_pages, extract_and_concat
class PlaylistView(ProjectMixin, ListView):
template_name = "library/playlist_view.html"
model = Playlist
class ProjectItemListView(ProjectMixin, ListView):
template_name = "library/item_list.html"
model = models.Item
def post(self, request, **kwargs):
@ -43,7 +43,7 @@ class PlaylistView(ProjectMixin, ListView):
continue
part = Part.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))
sections.append((part.doc.upload.path, part.doc.work.name, part.start, part.end, 1))
result = extract_and_concat(sections)
@ -55,18 +55,18 @@ class PlaylistView(ProjectMixin, ListView):
def get_queryset(self):
return super(PlaylistView, self).get_queryset().select_related('project', 'work')
return super(ProjectItemListView, self).get_queryset().select_related('project', 'work')
def get_context_data(self, **kwargs):
data = super(PlaylistView, self).get_context_data(**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')
return data
class PlaylistManageView(ProjectMixin, ListView):
template_name = "library/playlist_manage.html"
model = Playlist
class ProjectItemManageView(ProjectMixin, ListView):
template_name = "library/item_list_manage.html"
model = models.Item
def post(self, request, **kwargs):
self.request = request
@ -76,19 +76,23 @@ class PlaylistManageView(ProjectMixin, ListView):
q = self.get_queryset()
for pk, order in data.items():
i = q.filter(pk=pk).update(order=order)
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(PlaylistManageView, self).get_queryset().select_related('project', 'work')
return super(ProjectItemManageView, self).get_queryset().select_related('project', 'work')
class PlaylistAddView(ProjectMixin, UpdateView):
class ProjectItemAddView(ProjectMixin, UpdateView):
form_class = forms.PlaylistAddForm
template_name = "interface/default_form.html"
def get_success_url(self):
return resolve_url('playlist_manage', project=self.kwargs['project'])
return resolve_url('item_list_manage', project=self.kwargs['project'])
def get_object(self):
return self.get_project()
@ -102,6 +106,12 @@ class WorkAddView(EnsembleMixin, FormView):
template_name = "interface/default_form.html"
form_class = forms.WorkCreateForm
def get_form(self):
f = super(WorkAddView, self).get_form()
qs = f.fields['orchestration'].queryset
f.fields['orchestration'].queryset = qs.filter(ensemble_id__isnull=True) | qs.filter(ensemble_id=self.request.ensemble_id)
return f
def form_valid(self, form):
obj = form.save(commit=False)
obj.ensemble_id = self.request.ensemble_id
@ -127,6 +137,32 @@ class WorkDetailView(EnsembleMixin, DetailView):
def get_queryset(self):
return Work.objects.filter(ensemble=self.request.ensemble_id)
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.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):
return Work.objects.filter(ensemble_id=self.request.ensemble_id)
class DocumentDetailView(EnsembleMixin, DetailView):
@ -198,6 +234,7 @@ class DocumentAnnotateView(EnsembleMixin, DetailView):
def get_queryset(self):
return Document.objects.filter(work__ensemble=self.request.ensemble_id).select_related('work')
class PartDownloadView(EnsembleMixin, SingleObjectMixin, View):
def get(self, request, **args):

View File

@ -38,6 +38,7 @@ INSTALLED_APPS = [
'django.contrib.messages',
'django.contrib.staticfiles',
'django_markdown2',
'byostorage',
'interface',
'library',
]

View File

@ -11,3 +11,4 @@ s3transfer==0.3.3
six==1.15.0
sqlparse==0.3.1
urllib3==1.25.10
git+https://gitea.tfconsulting.com.au/tris/django-byostorage.git@165cca64c19176ca282147cdacc1f92c3a6ceb2f#egg=django_byostorage