Created library app

This commit is contained in:
Tris 2021-03-11 09:32:44 +11:00
parent 98fe7b77ce
commit 526ae4f59b
19 changed files with 1086 additions and 0 deletions

0
library/__init__.py Normal file
View File

26
library/admin.py Normal file
View File

@ -0,0 +1,26 @@
from django.contrib import admin
from . import models
class PlaylistInline(admin.TabularInline):
model = models.Playlist
class WorkAdmin(admin.ModelAdmin):
list_display = ['name', 'orchestration']
inlines = [PlaylistInline]
class PartInline(admin.TabularInline):
model = models.Part
fields = ['tag', 'start', 'end']
class DocumentAdmin(admin.ModelAdmin):
list_display = ['work', '__str__']
inlines = [PartInline]
class PlaylistAdmin(admin.ModelAdmin):
list_display = ['project', 'work', 'order']
list_filter = ['project']
admin.site.register(models.Work, WorkAdmin)
admin.site.register(models.Document, DocumentAdmin)
admin.site.register(models.Playlist, PlaylistAdmin)

5
library/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class LibraryConfig(AppConfig):
name = 'library'

24
library/forms.py Normal file
View File

@ -0,0 +1,24 @@
from django import forms
from .models import Work
class WorkCreateForm(forms.ModelForm):
uploads = forms.FileField(label="PDFs to upload", widget=forms.ClearableFileInput(attrs={'multiple': True}))
class Meta:
model = Work
fields = ['uploads', 'name', 'orchestration', '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'])

View File

@ -0,0 +1,76 @@
# Generated by Django 3.1.1 on 2021-03-10 22:20
from django.db import migrations, models
import django.db.models.deletion
import library.models
import library.storage
class Migration(migrations.Migration):
initial = True
dependencies = [
('interface', '0022_auto_20210303_2043'),
]
operations = [
migrations.CreateModel(
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)),
],
),
migrations.CreateModel(
name='Playlist',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('order', models.SmallIntegerField(default=0)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, 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, 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')),
],
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=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('tag', models.SlugField(max_length=20)),
('start', models.SmallIntegerField(blank=True, null=True)),
('end', models.SmallIntegerField(blank=True, null=True)),
('notes', models.TextField(blank=True)),
('doc', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parts', to='library.document')),
],
options={
'ordering': ['doc', 'start', 'pk'],
},
),
migrations.AddField(
model_name='document',
name='work',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='docs', to='library.work'),
),
]

View File

138
library/models.py Normal file
View File

@ -0,0 +1,138 @@
from django.conf import settings
from django.db import models
from django.utils.text import slugify
from django.core.files.storage import get_storage_class
import re
import logging
logger = logging.getLogger(__name__)
try:
library_storage = get_storage_class(settings.LIBRARY_STORAGE)()
except (ImportError, AttributeError):
library_storage = get_storage_class()()
logger.info("Library storage: %s", library_storage.__class__.__name__)
INSTRUMENTS = [
('Score', 'Score'),
('S', 'Soprano'),
('A', 'Alto'),
('T', 'Tenor'),
('B', 'Bass'),
('V', 'Vocals'),
('Vln', 'Violin'),
('Vla', 'Viola'),
('Vc', 'Violoncello'),
('Cb', 'Contrabass'),
('Fl', 'Flute'),
('Picc', 'Piccolo'),
('Cl', 'Clarinet'),
('Ob', 'Oboe'),
('Hn', 'Horn'),
('Tpt', 'Trumpet'),
('Tbn', 'Trombone'),
('Tuba', 'Tuba'),
('Timp', 'Timpani'),
('Drum', 'Drupset'),
('Perc', 'Percussion'),
('Pno', 'Piano'),
('Hp', 'Harp'),
]
ORCHESTRATIONS = {
'SATB': ('S', 'A', 'T', 'B'),
'String Quartet': ('Vln1', 'Vln2', 'Vla', 'Vc'),
'Chamber Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb',
'Fl1', 'Fl2', 'Cl1', 'Cl2', 'Hn1', 'Hn2',
'Tpt1', 'Tpt2', 'Tbn1', 'Tbn2', 'Tuba',
'Timp', 'Drum', 'Perc'),
'RWE': ('Fl1', 'Fl2', 'Cl', 'Tbn', 'Vln1', 'Vln2', 'Vla', 'Vc'),
'Custom': ()
}
def tag_to_instrument(tag):
m = re.match(r'([A-Za-z]+)(\d*)', tag)
if not m:
return tag
l = m.groups()
return "{0} {1}".format(dict(INSTRUMENTS).get(l[0],l[0]), l[1]).strip()
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() ])
running_time = models.IntegerField(null=True, blank=True)
notes = models.TextField(blank=True)
projects = models.ManyToManyField('interface.Project', through='Playlist', related_name="works")
class Meta:
unique_together = ['ensemble', 'slug']
@property
def parts(self):
return Part.objects.filter(doc__work=self.pk)
@property
def instruments(self):
tags = ORCHESTRATIONS.get(self.orchestration, 'Custom')
return [ (tag, tag_to_instrument(tag)) for tag in tags ]
def save(self):
if not self.slug:
self.slug = slugify(self.name)
super(Work, self).save()
def __str__(self):
return self.name
class Playlist(models.Model):
project = models.ForeignKey('interface.Project', on_delete=models.CASCADE)
work = models.ForeignKey('Work', on_delete=models.CASCADE)
order = models.SmallIntegerField(default=0)
class Meta:
ordering = ['order', 'work']
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}'
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)
def __str__(self):
return self.upload.name
class Part(models.Model):
doc = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="parts")
tag = models.SlugField(max_length=20)
start = models.SmallIntegerField(null=True, blank=True)
end = models.SmallIntegerField(null=True, blank=True)
notes = models.TextField(blank=True)
class Meta:
ordering = ['doc', 'start', 'pk']
@property
def instrument(self):
return tag_to_instrument(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}]'

46
library/pdf_utils.py Normal file
View File

@ -0,0 +1,46 @@
import tempfile
import subprocess
import os.path
def extract_pages(source, bookmark, start=None, end=None):
return extract_and_concat([(source, bookmark, start, end)])
def extract_and_concat(items):
# create a temporary directory for our sections
d = tempfile.TemporaryDirectory(prefix="polyphonic_")
sections = []
for i, (source, bookmark, start, end) in enumerate(items):
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', '-q', '-dBATCH', '-dNOPAUSE',
f'-dFirstPage={start}', f'-dLastPage={end}',
f'-sOutputFile={dest}',
source]
subprocess.run(cmd, check=True)
sections.append(dest)
# concat the items
output = tempfile.NamedTemporaryFile(prefix="polyphonic_", suffix='.pdf')
cmd = ['gs', '-sDEVICE=pdfwrite', '-q', '-dBATCH', '-dNOPAUSE',
'-sOutputFile=-']
cmd.extend(sections)
subprocess.run(cmd, stdout=output)
output.seek(0)
return output

96
library/storage.py Normal file
View File

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

@ -0,0 +1,193 @@
{% extends "interface/project_base.html" %}
{% block admin %}
<a href="#" onclick="saveTags()"><i class="fas fa-save"></i> Save</a>&nbsp;
{% endblock %}
{% block page %}
<h2><a href="{% url 'work_list' %}">Works</a> / <a href="{% url 'work_detail' document.work.pk %}">{{ document.work.name }}</a></h2>
<div id="annotation-area">
<div style="display: flex">
<canvas id="inline-viewer" style="width: 600px;"></canvas>
<div style="margin: 0px 20px">
<div>
<div style="text-align: center;">
<p>Page: <span id="page_num"></span> / <span id="page_count"></span></p>
</div>
<button id="prev"><i class="fas fa-backward"></i></button>
<button id="next"><i class="fas fa-forward"></i></button>
</div>
<div id="tag-list" style="cursor: pointer; margin-top: 20px;">
<div id="tag-None" data-tag="None">No tag</div>
<div id="tag-Score" data-tag="Score">Score</div>
{% for instrument in document.work.instruments %}
<div id="tag-{{ instrument.0 }}" data-tag="{{ instrument.0 }}">{{ instrument.1 }}</div>
{% endfor %}
</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 currentTags = document.getElementById('current-tags');
var selectedTag = document.getElementById('tag-None');
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);
// 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;
// get the page tags
let tag = data.pageTags[num];
if(tag) {
selectTag(tag);
} else {
if (selectedTag) {
setTag(selectedTag.dataset.tag);
}
}
}
/**
* 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_;
document.getElementById('page_count').textContent = pdfDoc.numPages;
// Initial/first page rendering
renderPage(pageNum);
});
function setTag(tag) {
data.pageTags[pageNum] = tag;
dirty = true;
selectTag(tag);
}
function selectTag(tag) {
if( selectedTag ) {
selectedTag.classList.remove('selected');
}
selectedTag = document.getElementById('tag-' + tag);
if( selectedTag) {
selectedTag.classList.add('selected');
}
}
function saveTags() {
console.log(data.pageTags);
fetch("", {
method: "POST",
headers: {"Content-Type": "application/json", "X-CSRFToken": "{{ csrf_token }}"},
body: JSON.stringify(data.pageTags)}
).then((response) => {
if(response.ok) {
window.location = "{% url 'work_detail' document.work.pk %}"
} else {
alert("Failed: " + response.statusText)
}
});
dirty = false;
}
function checkSaved(e) {
if (dirty) {
e.preventDefault();
}
}
window.addEventListener('beforeunload', checkSaved);
</script>
{% endblock %}

View File

@ -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 %}

View File

@ -0,0 +1,94 @@
{% extends "interface/project_base.html" %}
{% 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>
{% endblock %}
{% block page %}
<table style="max-width: 600px; margin: 10pt auto;" class="item-table">
<thead>
<tr>
<th>Piece</th>
<th>Part</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 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>
</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;
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 'project_playlist' project=project.pk %}";
} else {
alert("Failed: " + response.statusText)
}
})
}
function checkSaved(e) {
if(dirty) {
e.preventDefault();
}
}
window.addEventListener('beforeunload', checkSaved);
</script>
{% endblock %}

View File

@ -0,0 +1,84 @@
{% 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>
{% 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.
</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>
<tbody>
{% for item in object_list %}
<tr>
<td>
<a href="{% url 'work_detail' item.work.pk %}">{{ item.work.name }}</a>
</td>
<td class="select-cell">
<input type="hidden" name="works" value="{{ item.work.pk }}"/>
<select name="instruments">
<option value='-'>None</option>
{% for part in item.work.parts %}
<option value='{{ part.tag }}'>{{ part.instrument }}</option>
{% endfor %}
</select>
</td>
</tr>
{% 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 %}
{% block scripts %}
<script type="text/javascript">
function updateParts() {
let inst = document.getElementById("instrument-select").value;
let part = document.getElementById("part-preference").value;
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-select").value="{{instrument}}";
document.getElementById("part-preference").value="{{part}}";
updateParts();
</script>
{% endblock %}

View File

@ -0,0 +1 @@
<a href="{% url 'project_playlist' project=project.pk %}">My Music</a>

View File

@ -0,0 +1,28 @@
{% extends 'interface/project_base.html' %}
{% block admin %}
<a href="{% url 'document_add' work.pk %}"><i class="fas fa-plus-circle"></i> Upload file</a>
{% endblock %}
{% block page %}
<h2><a href="{% url 'work_list' %}">Works</a> / {{ work.name }}</h2>
<p>{{ work.notes }}</p>
<h3>Parts</h3>
<ul>
{% for part in work.parts %}
<li><a href="{% url 'part_download' pk=part.pk filename=part.filename %}" target="part_{{ part.pk }}" rel="">{{ part.instrument }}</a></li>
{% endfor %}
</ul>
<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]
{% if request.is_admin %}
<a href="{% url 'document_annotate' pk=doc.pk %}"><i class="fas fa-edit"></i></a>
{% endif %}
</li>
{% endfor %}
</ul>
{% endblock %}

View File

@ -0,0 +1,25 @@
{% extends "interface/project_base.html" %}
{% block admin %}
<a href="{% url 'work_add' %}"><i class="fas fa-plus-circle"></i> Add new</a>
{% endblock %}
{% block page %}
<h2>Works</h2>
<table style="max-width: 800px; margin: 10pt auto;">
<thead>
</thead>
<tbody>
{% for work in object_list %}
{% with work.docs.count as doc_count %}
{% with work.parts.count as part_count %}
<tr>
<td><a href="{% url 'work_detail' pk=work.pk %}">{{ work.name }}</a></td>
<td>{{ doc_count }} file{{ doc_count|pluralize }} with {{ part_count }} part{{ part_count|pluralize }}</td>
</tr>
{% endwith %}
{% endwith %}
{% endfor %}
</tbody>
</table>
{% endblock %}

3
library/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

19
library/urls.py Normal file
View File

@ -0,0 +1,19 @@
from django.urls import path
from django.contrib.auth import views as auth_views
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('library/works', views.WorkListView.as_view(), name="work_list"),
path('library/works/add', views.WorkAddView.as_view(), name="work_add"),
path('library/works/<int:pk>', views.WorkDetailView.as_view(), name="work_detail"),
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"),
path('library/parts/<int:pk>/<str:filename>', views.PartDownloadView.as_view(), name="part_download"),
]

217
library/views.py Normal file
View File

@ -0,0 +1,217 @@
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.edit import CreateView, FormView, UpdateView
from django.http import FileResponse, HttpResponse
from django.db import IntegrityError
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 .pdf_utils import extract_pages, extract_and_concat
class PlaylistView(ProjectMixin, ListView):
template_name = "library/playlist_view.html"
model = Playlist
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 = 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))
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(PlaylistView, self).get_queryset().select_related('project', 'work')
def get_context_data(self, **kwargs):
data = super(PlaylistView, 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
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():
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')
class PlaylistAddView(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'])
def get_object(self):
return self.get_project()
class WorkListView(EnsembleMixin, ListView):
def get_queryset(self):
return Work.objects.filter(ensemble=self.request.ensemble_id).order_by('name')
class WorkAddView(EnsembleMixin, FormView):
template_name = "interface/default_form.html"
form_class = forms.WorkCreateForm
def form_valid(self, form):
obj = form.save(commit=False)
obj.ensemble_id = self.request.ensemble_id
try:
obj.save()
except IntegrityError:
form.add_error('name', 'Name must be unique')
return self.form_invalid(form)
# handle the files
uploads = self.request.FILES.getlist('uploads')
docs = []
for f in uploads:
docs.append(obj.docs.create(upload=f).pk)
if len(docs) == 1:
return redirect('document_annotate', docs[0])
else:
return redirect('work_detail', pk=obj.pk)
class WorkDetailView(EnsembleMixin, DetailView):
def get_queryset(self):
return Work.objects.filter(ensemble=self.request.ensemble_id)
class DocumentDetailView(EnsembleMixin, DetailView):
def get_queryset(self):
return Document.objects.filter(work__ensemble=self.request.ensemble_id).select_related('work')
class DocumentAddView(EnsembleMixin, CreateView):
template_name = "interface/default_form.html"
model = Document
fields = ['upload']
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.work_id = self.kwargs['pk']
self.object.save()
return redirect('document_annotate', self.object.pk)
class DocumentDownloadView(EnsembleMixin, 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)
def get_queryset(self):
return Document.objects.filter(work__ensemble=self.request.ensemble_id)
class DocumentAnnotateView(EnsembleMixin, 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)
tags = {}
for page, tag in data.items():
tags.setdefault(tag, []).append(int(page))
try:
del(tags['None'])
except KeyError:
pass
self.object.parts.all().delete()
for tag, pages in tags.items():
pages.sort()
end = pages[-1] if len(pages) > 1 else None
o = self.object.parts.create(tag=tag, start=pages[0], 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'].parts.all():
for i in range(part.start, (part.end or part.start)+1):
pages[i] = part.tag
data['json_data'] = {'pageTags': pages, 'instruments': data['document'].work.instruments}
return data
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):
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):
return Part.objects.filter(doc__work__ensemble=self.request.ensemble_id).select_related('doc', 'doc__work')