+
+ {% if project.enable_submissions %}
+ {% include 'submissions/project_detail.html' %}
{% endif %}
- {% endwith %}
+
+ {% if project.enable_library %}
+ {% include 'library/project_detail.html' %}
+ {% endif %}
+
- {% endblock %}
+{% endblock %}
diff --git a/interface/templatetags/path_filters.py b/interface/templatetags/path_filters.py
new file mode 100644
index 0000000..804ac1f
--- /dev/null
+++ b/interface/templatetags/path_filters.py
@@ -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)
\ No newline at end of file
diff --git a/interface/urls.py b/interface/urls.py
index 324572c..9254873 100644
--- a/interface/urls.py
+++ b/interface/urls.py
@@ -11,8 +11,8 @@ urlpatterns = [
path('manage', views.ManageView.as_view(), name="manage"),
path('', views.EnsembleDetailView.as_view(), name='ensemble_detail'),
- path('projects/', views.ProjectDetailView.as_view(), name="project_detail"),
- path('projects//submissions.mk', views.ProjectMakefileView.as_view(), name="project_makefile"),
+ path('projects/', views.ProjectDetailView.as_view(), name="project_detail"),
+ path('projects//submissions.mk', views.ProjectMakefileView.as_view(), name="project_makefile"),
path('projects//page/', views.WikiView.as_view(), name="wiki"),
path('projects//page//edit', views.WikiEditView.as_view(), name="wiki_edit"),
diff --git a/interface/views.py b/interface/views.py
index ea6bef5..af149d8 100644
--- a/interface/views.py
+++ b/interface/views.py
@@ -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'
diff --git a/library/admin.py b/library/admin.py
index c3317e1..01930ee 100644
--- a/library/admin.py
+++ b/library/admin.py
@@ -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)
\ No newline at end of file
+admin.site.register(models.Item, ItemAdmin)
\ No newline at end of file
diff --git a/library/migrations/0001_initial.py b/library/migrations/0001_initial.py
index 8c1d547..5747cd8 100644
--- a/library/migrations/0001_initial.py
+++ b/library/migrations/0001_initial.py
@@ -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',
diff --git a/library/migrations/0003_orchestrations.py b/library/migrations/0003_orchestrations.py
new file mode 100644
index 0000000..4f40154
--- /dev/null
+++ b/library/migrations/0003_orchestrations.py
@@ -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),
+ ]
diff --git a/library/models.py b/library/models.py
index 3a8ae42..4dd5f01 100644
--- a/library/models.py
+++ b/library/models.py
@@ -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
diff --git a/library/pdf_utils.py b/library/pdf_utils.py
index b7ceba4..c0e5b67 100644
--- a/library/pdf_utils.py
+++ b/library/pdf_utils.py
@@ -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)
diff --git a/library/storage.py b/library/storage.py
deleted file mode 100644
index 822e511..0000000
--- a/library/storage.py
+++ /dev/null
@@ -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
\ No newline at end of file
diff --git a/library/templates/library/playlist_view.html b/library/templates/library/item_list.html
similarity index 87%
rename from library/templates/library/playlist_view.html
rename to library/templates/library/item_list.html
index a2c782e..da6ea16 100644
--- a/library/templates/library/playlist_view.html
+++ b/library/templates/library/item_list.html
@@ -1,27 +1,37 @@
{% extends "interface/project_base.html" %}
{% block admin %}
-Change works
+Change items
{% endblock %}
{% block page %}
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).
- 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.