Got library working again

This commit is contained in:
Tris Forster 2023-02-16 17:22:18 +11:00
parent b85440d25c
commit 13f466228b
17 changed files with 427 additions and 140 deletions

View File

@ -6,27 +6,19 @@
<div class="card"> <div class="card">
<a class="" href="{% url 'project_detail' project=project.id %}"> <a class="" href="{% url 'project_detail' project=project.id %}">
<header class="card-header{% if not project.active %} has-background-light{% endif %}"> <header class="card-header{% if not project.active %} has-background-light{% endif %}">
<p class="card-header-title">{% if not ensemble %}{{ project.ensemble }}{% endif%} {{ project.name }}</p> <p class="card-header-title">
{{ project.name }}
</p>
<p class="card-header-icon" style="color: black;">{{ project.rough_date }}</p> <p class="card-header-icon" style="color: black;">{{ project.rough_date }}</p>
</header> </header>
</a> </a>
<div class="card-content"> <div class="card-content">
<div class="content"> <div class="content" style="height: 100px; overflow: hidden">
{{ project.description | markdown }} {{ project.description | markdown }}
{% if not ensemble %}
<div class="has-text-centered"><i><small>With {{ project.ensemble }}</small></i></div>
{% endif %}
</div> </div>
<p><small>
{% if project.deadline %}In {{ project.deadline|timeuntil }}<br/>{% endif %}
{% if project.submissions.count %}{{ project.submissions.count }} submissions<br/>{% endif %}
</small></p>
</div>
<div class="card-footer">
{% if 'library' in project.active_modules %}
{% with project.works.count as c %}
<a class="card-footer-item" href="{% url 'item_list' project=project.pk %}">
{{ c }} work{{ c | pluralize }}
</a>
{% endwith %}
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -5,9 +5,9 @@
from django.shortcuts import get_object_or_404, redirect, resolve_url from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.views.generic import RedirectView from django.views.generic import RedirectView
from django.views.generic.detail import DetailView from django.views.generic.detail import DetailView, SingleObjectMixin
from django.views.generic.list import ListView from django.views.generic.list import ListView
from django.views.generic.edit import CreateView, UpdateView from django.views.generic.edit import CreateView, UpdateView, FormMixin
from django.core.exceptions import SuspiciousOperation from django.core.exceptions import SuspiciousOperation
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
from django.db.models import Q from django.db.models import Q
@ -99,6 +99,8 @@ class AuthorizedResourceMixin(object):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
# TODO: RevokeResourceView - increment nonce
class ForgetResourceView(AuthorizedResourceMixin, RedirectView): class ForgetResourceView(AuthorizedResourceMixin, RedirectView):
def is_authorized(self): def is_authorized(self):
@ -239,6 +241,11 @@ class EnsembleDetailView(EnsembleMixin, DetailView):
data['ensemble_link'] = self.request.path + "?auth=" + self.ensemble.auth() data['ensemble_link'] = self.request.path + "?auth=" + self.ensemble.auth()
return data return data
class EnsembleRevokeView(SingleObjectMixin, RedirectView):
def get_redirect_url(self):
return
""" PROJECT VIEWS """ """ PROJECT VIEWS """
class ProjectListView(ProjectMixin, ListView): class ProjectListView(ProjectMixin, ListView):

View File

@ -27,13 +27,14 @@ class MetaInline(admin.TabularInline):
class WorkAdmin(admin.ModelAdmin): class WorkAdmin(admin.ModelAdmin):
list_display = ['name', 'composer', 'edition', 'identifier', 'running_time'] list_display = ['name', 'composer', 'edition', 'identifier', 'running_time']
list_filter = ['collection'] list_filter = ['collection']
search_fields = ['name', 'composer']
inlines = [MetaInline, DocInline, ItemInline] inlines = [MetaInline, DocInline, ItemInline]
admin.site.register(models.Work, WorkAdmin) admin.site.register(models.Work, WorkAdmin)
class SectionInline(admin.TabularInline): class SectionInline(admin.TabularInline):
model = models.Section model = models.Section
fields = ['type', 'tag', 'ordinal', 'start', 'end'] fields = ['type', 'tag', 'ordinal', 'start', 'end', 'page']
class DocumentAdmin(admin.ModelAdmin): class DocumentAdmin(admin.ModelAdmin):
list_display = ['work', '__str__'] list_display = ['work', '__str__']
@ -53,3 +54,8 @@ class EnsembleAccessAdmin(admin.ModelAdmin):
list_filter = ['ensemble'] list_filter = ['ensemble']
admin.site.register(models.EnsembleAccess, EnsembleAccessAdmin) admin.site.register(models.EnsembleAccess, EnsembleAccessAdmin)
class OrchestrationAdmin(admin.ModelAdmin):
list_display = ['name', 'instruments']
admin.site.register(models.Orchestration, OrchestrationAdmin)

View File

@ -0,0 +1,38 @@
[
{
"model": "library.orchestration",
"pk": 2,
"fields": {
"ensemble": null,
"name": "SATB",
"instruments": "score sop alt ten bass"
}
},
{
"model": "library.orchestration",
"pk": 3,
"fields": {
"ensemble": null,
"name": "String Quartet",
"instruments": "score vn-1 vn-2 va vc"
}
},
{
"model": "library.orchestration",
"pk": 4,
"fields": {
"ensemble": null,
"name": "Chamber Orchestra",
"instruments": "score fl cl ob bn vn-1 vn-2 va vc db"
}
},
{
"model": "library.orchestration",
"pk": 5,
"fields": {
"ensemble": null,
"name": "Full Orchestra",
"instruments": "score picc fl-1 fl-2 ob cl-1 cl-2 bcl bn tpt-1 tpt-2 tpt-3 hn-1 hn-2 hn-3 hn-4 tbn-1 tbn-2 tbn-3 tba timp perc-1 perc-2 mall pf vn-1 vn-2 va vc db"
}
}
]

View File

@ -7,6 +7,7 @@ from collections import namedtuple
ABBREVIATIONS = """ ABBREVIATIONS = """
score Score score Score
cb Double bass cb Double bass
mall Mallet percussion
acc Accordion acc Accordion
afl Alto flute afl Alto flute
@ -184,6 +185,10 @@ class Instrument(namedtuple('Instrument', ('name', 'variant'), defaults=[None]))
return cls(name, variant) return cls(name, variant)
return cls(name, None) return cls(name, None)
@property
def tag(self):
l = self.name.lower()
return INSTRUMENT_TAGS.get(l, l)
def abbreviate(self): def abbreviate(self):
""" """

View File

@ -0,0 +1,28 @@
# Generated by Django 3.2.7 on 2023-02-01 21:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0005_auto_20230101_1547'),
]
operations = [
migrations.AlterField(
model_name='section',
name='tag',
field=models.CharField(blank=True, max_length=50),
),
migrations.AlterField(
model_name='section',
name='type',
field=models.SmallIntegerField(choices=[(1, 'Instrument'), (2, 'Movement'), (3, 'Excerpt')]),
),
migrations.AlterField(
model_name='work',
name='licence',
field=models.PositiveSmallIntegerField(choices=[(2, 'Public Domain'), (4, 'Copyright Expired'), (5, 'Recording Licence'), (6, 'Performance Licence'), (8, 'Perusal Licence'), (10, 'Internal use only')], default=6, help_text='Copyright status'),
),
]

View File

@ -0,0 +1,33 @@
# Generated by Django 3.2.7 on 2023-02-14 04:54
from django.db import migrations, models
import django.db.models.deletion
def add_default_orchestration(apps, schema_editor):
m = apps.get_model('library', 'Orchestration').objects
m.create(pk=1, name="Custom", instruments="")
def remove_default_orchestration(apps, schema_editor):
m = apps.get_model('library', 'Orchestration').objects
m.filter(pk=1).delete()
class Migration(migrations.Migration):
dependencies = [
('interface', '0004_auto_20230210_0938'),
('library', '0006_auto_20230202_0804'),
]
operations = [
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.RunPython(add_default_orchestration, remove_default_orchestration),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.2.7 on 2023-02-14 05:05
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('library', '0007_orchestration'),
]
operations = [
migrations.AddField(
model_name='work',
name='orchestration',
field=models.ForeignKey(default=1, help_text='Orchestration for the work', on_delete=django.db.models.deletion.SET_DEFAULT, to='library.orchestration'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.7 on 2023-02-15 03:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0010_work_orchestration'),
]
operations = [
migrations.AddField(
model_name='section',
name='page',
field=models.SmallIntegerField(choices=[(0, 'auto'), (1, 'left'), (2, 'right')], default=0),
),
]

View File

@ -7,6 +7,8 @@ from django.utils.functional import cached_property
from django.core.files.storage import get_storage_class from django.core.files.storage import get_storage_class
from django.db.models import Q, Count, Min, Max from django.db.models import Q, Count, Min, Max
import re
from byostorage.user import BYOStorage from byostorage.user import BYOStorage
from byostorage.cached import CachedStorage from byostorage.cached import CachedStorage
from .imslp import Instrument from .imslp import Instrument
@ -26,10 +28,6 @@ logger = logging.getLogger(__name__)
# FIXME: move back to settings # FIXME: move back to settings
library_storage = CachedStorage(BYOStorage()) library_storage = CachedStorage(BYOStorage())
'''
class Orchestration(models.Model): class Orchestration(models.Model):
""" """
Stores a list of instrument codes as a single entry (space delimited). Stores a list of instrument codes as a single entry (space delimited).
@ -41,7 +39,21 @@ class Orchestration(models.Model):
def as_list(self): def as_list(self):
tags = [ t.strip() for t in self.instruments.split(' ') ] tags = [ t.strip() for t in self.instruments.split(' ') ]
return [ (t, tag_to_instrument(t)) for t in tags if t ] return [ (t, Instrument.from_tag(t)) for t in tags if t ]
def tag_order(self):
tags = [ t.strip() for t in self.instruments.split(' ') if t ]
order = {'score': 0}
for i, t in enumerate(tags):
order.setdefault(t.strip('-0123456789'), i*2+1)
return order
def sorter(self):
tag_order = self.tag_order()
def f(x):
return (tag_order.get(x[0].strip('-0123456789'), 1000), x[0])
return f
def save(self): def save(self):
self.as_list() self.as_list()
@ -49,7 +61,6 @@ class Orchestration(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
'''
class ProjectItem(models.Model): class ProjectItem(models.Model):
""" """
@ -160,7 +171,8 @@ class Work(models.Model):
composer = models.CharField(max_length=255, default='Anon', composer = models.CharField(max_length=255, default='Anon',
help_text="Surname, Initials") help_text="Surname, Initials")
original_parts = models.JSONField(default=dict, help_text="Original printed parts (IMSLP format)") orchestration = models.ForeignKey(Orchestration, on_delete=models.SET_DEFAULT, default=1, help_text="Orchestration for the work")
original_parts = models.JSONField(default=dict, blank=True, help_text="Original printed parts (IMSLP format)")
# Collection details # Collection details
collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name="works") collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name="works")
@ -189,13 +201,17 @@ class Work(models.Model):
@property @property
def digital_parts(self): def digital_parts(self):
return Section.objects.filter(doc__work=self.pk) sections = [ (s.tag, s) for s in Section.objects.filter(doc__work=self.pk) ]
sections.sort(key=self.orchestration.sorter())
return [ s[1] for s in sections ]
@property @property
def physical_parts(self): def physical_parts(self):
if not self.original_parts: if not self.original_parts:
return [] return []
return [ (Instrument.from_tag(k), v) for (k, v) in self.original_parts.items() ] parts = list(self.original_parts.items())
parts.sort(key=self.orchestration.sorter())
return [ (Instrument.from_tag(x[0]), x[1]) for x in parts ]
@property @property
def tags(self): def tags(self):
@ -240,15 +256,18 @@ class Work(models.Model):
return self.code; return self.code;
composer = self.composer or "Anon" composer = self.composer or "Anon"
composer = re.sub('[^\w]', '', composer)
words = self.name.split() words = self.name.split()
#if len(words) > 2:
# work = ''.join([ x[0] for x in self.name.split() ])
#else:
# work = words[0][:3]
work = words[0][:3] work = words[0][:3]
return f"{composer[:4]}-{work}-{self.pk:03d}".upper() return f"{composer[:4]}-{work}-{self.pk:05d}".upper()
def assigned_instruments(self):
return Section.objects.filter(doc__work_id=self.pk, type=Section.TYPE_INSTRUMENT).values_list('tag', flat=True)
def unassigned_instruments(self):
assigned = set(self.assigned_instruments())
return [ x for x in self.orchestration.as_list() if not x[0] in assigned ]
def __str__(self): def __str__(self):
return f"{self.name} ({self.composer})" return f"{self.name} ({self.composer})"
@ -326,6 +345,16 @@ class Section(models.Model):
TYPE_EXCERPT: 'warning', TYPE_EXCERPT: 'warning',
} }
PAGE_AUTO = 0
PAGE_LEFT = 1
PAGE_RIGHT = 2
PAGE_PREFERENCE = (
(PAGE_AUTO, 'auto'),
(PAGE_LEFT, 'left'),
(PAGE_RIGHT, 'right'),
)
type = models.SmallIntegerField(choices=SECTION_TYPES) type = models.SmallIntegerField(choices=SECTION_TYPES)
doc = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="sections") doc = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="sections")
@ -333,6 +362,7 @@ class Section(models.Model):
ordinal = models.IntegerField(default=0) ordinal = models.IntegerField(default=0)
start = models.SmallIntegerField(null=True, blank=True) start = models.SmallIntegerField(null=True, blank=True)
end = models.SmallIntegerField(null=True, blank=True) end = models.SmallIntegerField(null=True, blank=True)
page = models.SmallIntegerField(default=PAGE_AUTO, choices=PAGE_PREFERENCE) # NOT CURRENTLY USED
class Meta: class Meta:
ordering = ['type', 'ordinal', 'doc', 'start', 'pk'] ordering = ['type', 'ordinal', 'doc', 'start', 'pk']
@ -346,10 +376,6 @@ class Section(models.Model):
return str(instr) return str(instr)
return f"{self.ordinal} - {self.tag}" return f"{self.ordinal} - {self.tag}"
#@property
#def instrument(self):
# return Instrument.from_tag(self.tag)
@property @property
def bulma_class(self): def bulma_class(self):
return self.SECTION_CLASSES[self.type] return self.SECTION_CLASSES[self.type]

View File

@ -5,7 +5,7 @@
<span class="icon"><i class="fas fa-save"></i></span> <span class="icon"><i class="fas fa-save"></i></span>
<span>Save</span> <span>Save</span>
</a> </a>
<a href="{% url 'work_detail' pk=object.work.pk %}" class="button is-link is-light"> <a href="{% url 'work_detail' collection=collection.pk pk=object.work_id %}" class="button is-link is-light">
<span>Cancel</span> <span>Cancel</span>
</a> </a>
{% endblock %} {% endblock %}
@ -21,7 +21,7 @@
} }
.grid-page { .grid-page {
height: 25px; height: 25px;
margin-bottom: 10px; margin-bottom: 5px;
border: 1px solid #999; border: 1px solid #999;
border-radius: 5px; border-radius: 5px;
text-align: center; text-align: center;
@ -44,46 +44,45 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: start;
} }
#tag-area { #tag-area {
min-width: 220px; min-width: 220px;
} }
.instrument {
display: list-item !important;
}
.instrument:hover {
background-color: var(--primary);
color: white;
}
</style> </style>
{% endblock %} {% endblock %}
{% block page %} {% block page %}
<h3 class="subtitle"><a href="{% url 'work_detail' document.work.pk %}">{{ document.work.name }}</a></h3> <h3 class="subtitle"><a href="{% url 'work_detail' collection.pk document.work_id %}">{{ document.work.name }}</a></h3>
<div id="annotation-area" class="columns is-centered"> <div id="annotation-area" class="columns is-centered">
<div class="column is-narrow">
<div class="columns">
<div class="column is-narrow">
<button class="button is-small is-primary" id="prev">&lt;</button>
</div>
<div class="column">
<span id="page-num">-</span> / <span id="page-count">-</span>
</div>
<div class="column is-narrow">
<button class="button is-small is-primary" id="next">&gt;</button>
</div>
</div>
<ul id="unassigned-area">
{% for tag, inst in document.work.unassigned_instruments %}
<li class="is-clickable" onclick="assignInstrument('{{tag}}', this)")>{{ inst }}</li>
{% endfor %}
<li><a onclick="document.getElementById('add-modal').classList.add('is-active')">Add instrument</a></li>
</ul>
</div>
<div class="column is-narrow"> <div class="column is-narrow">
<div class="has-text-centered"> <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;"> <div class="box" style="display: inline-block;">
<canvas id="inline-viewer" style="width: 500px;"></canvas> <canvas id="inline-viewer" style="width: 500px;"></canvas>
</div> </div>
@ -97,9 +96,39 @@
<div class="grid-column" id="tag-area"> <div class="grid-column" id="tag-area">
</div> </div>
</div> </div>
<button class="button is-primary" style="width: 100%" onclick="expandEntries()">Expand</button>
</div> </div>
</div> </div>
<div class="modal" id="add-modal">
<div class="modal-background" onclick="closeAddModal()"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Add Instrument</p>
<button class="delete" aria-label="close" onclick="closeAddModal()"></button>
</header>
<section class="modal-card-body">
<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>
</section>
</div>
</div>
<p>{{ document.upload.name }}</p> <p>{{ document.upload.name }}</p>
{% endblock %} {% endblock %}
@ -194,10 +223,9 @@
if (pageNum <= 1) { if (pageNum <= 1) {
return; return;
} }
pageNum--; queueRenderPage(pageNum-1);
queueRenderPage(pageNum);
} }
//document.getElementById('prev').addEventListener('click', onPrevPage); document.getElementById('prev').addEventListener('click', onPrevPage);
/** /**
* Displays next page. * Displays next page.
@ -206,10 +234,9 @@
if (pageNum >= pdfDoc.numPages) { if (pageNum >= pdfDoc.numPages) {
return; return;
} }
pageNum++; queueRenderPage(pageNum+1);
queueRenderPage(pageNum);
} }
//document.getElementById('next').addEventListener('click', onNextPage); document.getElementById('next').addEventListener('click', onNextPage);
/** /**
* Asynchronously downloads PDF. * Asynchronously downloads PDF.
@ -243,6 +270,12 @@
dirty = false; dirty = false;
}); });
function closeAddModal() {
document.getElementById('add-modal').classList.remove('is-active');
document.getElementById('add-instrument-name').value = "";
document.getElementById('add-instrument-variant').value = "";
}
function addInstrument() { function addInstrument() {
let name = document.getElementById('add-instrument-name'); let name = document.getElementById('add-instrument-name');
let variant = document.getElementById('add-instrument-variant'); let variant = document.getElementById('add-instrument-variant');
@ -271,6 +304,11 @@
addTag(tag, pageNum, pageNum); addTag(tag, pageNum, pageNum);
} }
function assignInstrument(tag, el) {
addTag(tag, pageNum, pageNum);
el.remove();
}
function addTag(tag, start, end) { function addTag(tag, start, end) {
console.log("addTag", tag, start, end); console.log("addTag", tag, start, end);
const el = document.createElement('div'); const el = document.createElement('div');
@ -295,7 +333,15 @@
let del = document.createElement('span'); let del = document.createElement('span');
del.className = "icon is-action"; del.className = "icon is-action";
del.innerHTML = '<i class="fas fa-trash-alt" title="Remove this tag"></i>'; del.innerHTML = '<i class="fas fa-trash-alt" title="Remove this tag"></i>';
del.addEventListener('click', () => {el.remove(), dirty=true}); del.addEventListener('click', () => {
let li = document.createElement('li');
li.classList.add("is-clickable");
li.addEventListener('click', () => assignInstrument(tag, li));
li.innerHTML = get_instrument(el.dataset.tag);
document.getElementById('unassigned-area').appendChild(li);
el.remove();
dirty=true;
});
label.appendChild(del) label.appendChild(del)
el.appendChild(label); el.appendChild(label);
@ -315,8 +361,8 @@
let start = tag.dataset.start; let start = tag.dataset.start;
let end = tag.dataset.end; let end = tag.dataset.end;
let span = end-start+1; let span = end-start+1;
let height = span * 25 + (span-1) * 10; let height = span * 25 + (span-1) * 5;
let top = (start-1) * 35; let top = (start-1) * 30;
tag.style.height = height + 'px'; tag.style.height = height + 'px';
tag.style.marginTop = top + 'px'; tag.style.marginTop = top + 'px';
@ -341,6 +387,19 @@
updateTag(el); updateTag(el);
} }
function expandEntries() {
const entries = Array.from(tagArea.children);
entries.sort((a, b) => a.dataset.start-b.dataset.start);
const c = entries.length;
for (let i=0; i<c-1; i++) {
entries[i].dataset.end = entries[i+1].dataset.start - 1;
updateTag(entries[i]);
}
entries[c-1].dataset.end = pdfDoc.numPages;
updateTag(entries[c-1]);
}
function saveTags() { function saveTags() {
const pageTags = []; const pageTags = [];
for (let pageTag of tagArea.children ) { for (let pageTag of tagArea.children ) {
@ -361,7 +420,7 @@
} }
).then((response) => { ).then((response) => {
if (response.ok) { if (response.ok) {
window.location = "{% url 'work_detail' document.work.pk %}" window.location = "{% url 'work_detail' collection.pk document.work_id %}"
} else { } else {
alert("Failed: " + response.statusText) alert("Failed: " + response.statusText)
} }

View File

@ -13,7 +13,7 @@
<button class="button is-link">Yes</button> <button class="button is-link">Yes</button>
</div> </div>
<div class="control"> <div class="control">
<a class="button is-link is-light" href="{% url 'work_detail' object.work.pk %}">No</a> <a class="button is-link is-light" href="{% url 'work_detail' collection.pk object.work.pk %}">No</a>
</div> </div>
</div> </div>
</form> </form>

View File

@ -4,14 +4,14 @@
{{ doc.upload.name|basename }}</a></td> {{ doc.upload.name|basename }}</a></td>
<td> <td>
{% for section in doc.sections.all %} {% for section in doc.sections.all %}
<a class="tag is-{{ section.bulma_class }}" target="_blank" href="{% url 'part_download' pk=section.pk filename=section.filename %}">{{ section.name }}</a> <a class="tag is-{{ section.bulma_class }}" target="_blank" href="{% url 'part_download' collection.pk section.pk section.filename %}">{{ section.name }}</a>
{% endfor %} {% endfor %}
</td> </td>
<td class="has-text-right" style="white-space: nowrap;"> <td class="has-text-right" style="white-space: nowrap;">
{% if request.is_admin %} {% if request.is_admin %}
<a href="{% url 'document_annotate' pk=doc.pk %}"><i class="fas fa-tags" <a href="{% url 'document_annotate' collection.pk doc.pk %}"><i class="fas fa-tags"
title="Manage Tags"></i></a> title="Manage Tags"></i></a>
<a href="{% url 'document_delete' pk=doc.pk %}"><i class="fas fa-trash-alt" title="Delete Document"></i></a> <a href="{% url 'document_delete' collection.pk doc.pk %}"><i class="fas fa-trash-alt" title="Delete Document"></i></a>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -7,11 +7,11 @@
{% endblock %} {% endblock %}
{% block admin %} {% block admin %}
<a href="{% url 'work_edit' work.pk %}" class="button is-link"> <a href="{% url 'work_edit' collection.pk work.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-edit"></i></span> <span class="icon"><i class="fas fa-edit"></i></span>
<span>Edit</span> <span>Edit</span>
</a> </a>
<a href="{% url 'work_add_to_project' work.pk %}" class="button is-link"> <a href="{% url 'work_add_to_project' collection.pk work.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span> <span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add to project</span> <span>Add to project</span>
</a> </a>
@ -29,16 +29,27 @@
<p class="block">{{ work.notes }}</p> <p class="block">{{ work.notes }}</p>
<p class="block"> <p class="block">
Location: <a href="{% url 'collection_work_list' work.collection.pk %}">{{ work.collection }}</a> [{{ work.identifier }}]<br/> <table class="table">
Running time: {% firstof work.duration 'Unknown' %}<br/> <tr>
Licence: {{ work.get_licence_display }}<br/> <th>Location:</th><td><a href="{% url 'collection_work_list' work.collection.pk %}">{{ work.collection }}</a> [{{ work.identifier }}]</td>
<th>Orchestration:</th><td>{{ work.orchestration }}</td>
</tr><tr>
<th>Running time:</th><td>{% firstof work.duration 'Unknown' %}</td>
<th>Licence:</th><td>{{ work.get_licence_display }}</td>
</tr><tr>
<td colspan="4">
{% for meta in work.meta %} {% for meta in work.meta %}
{{ meta.get_name_display }}: <a href="{% url 'collection_work_list' work.collection.pk %}?filter={{ meta.name}}:{{ meta.value }}">{{ meta.value }}</a><br/> <a href="{% url 'collection_work_list' work.collection.pk %}?filter={{ meta.name}}:{{ meta.value }}" class="tag" >
{{ meta.get_name_display }}:
{{ meta.value }}
</a>
{% endfor %} {% endfor %}
</p> </td>
</tr>
</table>
{% if work.parent %} {% if work.parent %}
<p>From <a href="{% url 'work_detail' work.parent.pk %}">{{ work.parent.name }} - {{ work.parent.composer }}</a> <p>From <a href="{% url 'work_detail' collection.pk work.parent.pk %}">{{ work.parent.name }} - {{ work.parent.composer }}</a>
</p> </p>
{% endif %} {% endif %}
@ -62,7 +73,7 @@
</h4> </h4>
<div class="tags"> <div class="tags">
{% for inst, c in work.physical_parts %} {% for inst, c in work.physical_parts %}
<span class="tag is-warning">{{ inst }} ({{ c }})</span> <span class="tag is-warning">{{ inst }}{% if c > 1 %} [<strong>{{ c }}</strong>]{% endif %}</span>
{% empty %} {% empty %}
<p class="is-italic">No printed parts listed</p> <p class="is-italic">No printed parts listed</p>
{% endfor %} {% endfor %}
@ -77,10 +88,10 @@
</h4> </h4>
<div class="tags"> <div class="tags">
{% if work.digital_parts %} {% if work.digital_parts %}
<a class="tag is-danger" href="{% url 'work_partset' pk=work.pk %}">Full Set</a> <a class="tag is-danger" href="{% url 'work_partset' collection.pk work.pk %}">Full Set</a>
{% endif %} {% endif %}
{% for section in work.digital_parts %} {% for section in work.digital_parts %}
<a class="tag is-info" href="{% url 'part_download' pk=section.pk filename=section.filename %}" <a class="tag is-info" href="{% url 'part_download' collection.pk section.pk section.filename %}"
target="section_{{ section.pk }}" rel="">{{ section.name }}</a> target="section_{{ section.pk }}" rel="">{{ section.name }}</a>
{% empty %} {% empty %}
<p class="is-italic">No digital parts available</p> <p class="is-italic">No digital parts available</p>
@ -118,7 +129,7 @@
{% if request.is_admin %} {% if request.is_admin %}
<div class="column is-one-quarter"> <div class="column is-one-quarter">
<h4 class="is-size-5">Upload files</h4> <h4 class="is-size-5">Upload files</h4>
<form action="{% url 'document_add' object.pk %}" class="dropzone" id="doc-upload"> <form action="{% url 'document_add' collection.pk object.pk %}" class="dropzone" id="doc-upload">
{% csrf_token %} {% csrf_token %}
</form> </form>
</div> </div>
@ -135,7 +146,7 @@
Loans Loans
</h4> </h4>
<span class="level-right"> <span class="level-right">
<a class="icon-text" href="{% url 'work_add_to_project' work.pk %}"><span class="icon"><i <a class="icon-text" href="{% url 'work_add_to_project' collection.pk work.pk %}"><span class="icon"><i
class="fas fa-plus-circle"></i></span> Checkout</a> class="fas fa-plus-circle"></i></span> Checkout</a>
</span> </span>
</div> </div>

View File

@ -31,10 +31,12 @@ urlpatterns = [
path('collections/<int:collection>/docs/<int:pk>/delete', views.DocumentDeleteView.as_view(), name="document_delete"), path('collections/<int:collection>/docs/<int:pk>/delete', views.DocumentDeleteView.as_view(), name="document_delete"),
path('collections/<int:collection>/docs/<int:pk>/download', views.DocumentDownloadView.as_view(), name="document_download"), path('collections/<int:collection>/docs/<int:pk>/download', views.DocumentDownloadView.as_view(), name="document_download"),
path('collections/<int:collection>/docs/<int:pk>/annotate', views.DocumentAnnotateView.as_view(), name="document_annotate"), path('collections/<int:collection>/docs/<int:pk>/annotate', views.DocumentAnnotateView.as_view(), name="document_annotate"),
path('collections/<int:collection>/docs/<int:pk>/<str:filename>', views.PartDownloadView.as_view(), name="part_download"),
path('collections/<int:collection>/download/<int:section>/<str:filename>', views.PartDownloadView.as_view(), name="part_download"),
#path('api/', include(router.urls)) #path('api/', include(router.urls))
path('api/library/collections/<int:pk>/export', api.CollectionExportView.as_view(), name="collection_export"), path('api/library/collections/<int:pk>/export', api.CollectionExportView.as_view(), name="collection_export"),
path('api/library/works/<int:pk>/export', api.WorkExportView.as_view(), name="work_export"), path('api/library/works/<int:pk>/export', api.WorkExportView.as_view(), name="work_export"),
path('api/library/collections/<int:pk>/import', api.WorkImportView.as_view(), name="work_import"), path('api/library/collections/<int:pk>/import', api.WorkImportView.as_view(), name="work_import"),
path('api/library/collections/<int:pk>/bulk_import', api.CollectionImportView.as_view(), name="collection_import"),
] ]

View File

@ -1,10 +1,11 @@
from django.shortcuts import render, redirect, resolve_url from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.views.generic.detail import DetailView, SingleObjectMixin, View from django.views.generic.detail import DetailView, SingleObjectMixin, View
from django.views.generic.list import ListView, MultipleObjectMixin from django.views.generic.list import ListView, MultipleObjectMixin
from django.views.generic.edit import CreateView, FormView, UpdateView, DeleteView from django.views.generic.edit import CreateView, FormView, UpdateView, DeleteView
from django.http import FileResponse, HttpResponse, JsonResponse from django.http import FileResponse, HttpResponse, JsonResponse
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models import Q, Count, Sum from django.db.models import Q, Count, Sum
from django.db import transaction
from django.utils.timezone import now from django.utils.timezone import now
from django.urls import reverse from django.urls import reverse
from django.template.loader import render_to_string from django.template.loader import render_to_string
@ -119,41 +120,42 @@ class ProjectItemAddView(ProjectMixin, UpdateView):
class CollectionMixin(AuthorizedResourceMixin): class CollectionMixin(AuthorizedResourceMixin):
def is_authorized(self): def is_authorized(self):
super().is_authorized() collection_id = self.kwargs['collection']
try: self.collection = get_object_or_404(models.Collection, pk=collection_id)
self.collection = self.get_collection()
if super().is_authorized():
return True return True
except (models.Collection.DoesNotExist, KeyError):
return False if self.collection.has_admininistrator(self.request.user):
self.request.is_admin = True
return True
return False
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
if self.collection:
data['collection'] = self.collection
return data
def get_queryset(self): def get_queryset(self):
return super().get_queryset().filter(collection=self.collection) return super().get_queryset().filter(collection=self.collection)
def get_collection(self):
collection = self.get_collection_queryset().get(pk=self.kwargs['collection'])
self.request.is_admin = collection.has_administrator(self.request.user)
return collection
def get_collection_queryset(self): class CollectionListView(ListView):
paginate_by = 20
def get_queryset(self):
collections = models.Collection.objects.all() collections = models.Collection.objects.all()
if self.request.user.is_anonymous: if self.request.user.is_anonymous:
return models.Collection.objects.none() return models.Collection.objects.none()
if self.request.is_admin: if self.request.user.is_staff:
return collections return collections
return collections.filter(Q(administrators=self.request.user) | Q(allowed_ensembles__ensemble__admins=self.request.user)) return collections.filter(Q(administrators=self.request.user) | Q(allowed_ensembles__ensemble__admins=self.request.user))
class CollectionListView(CollectionMixin, ListView):
paginate_by = 20
def is_authorized(self):
return True
def get_queryset(self):
return self.get_collection_queryset()
class WorkListView(CollectionMixin, ListView): class WorkListView(CollectionMixin, ListView):
paginate_by = 20 paginate_by = 20
@ -178,7 +180,7 @@ class WorkListView(CollectionMixin, ListView):
else: else:
works = works.filter(Q(name__contains=q) | Q(composer__contains=q) | Q(meta_info__value__contains=q)) works = works.filter(Q(name__contains=q) | Q(composer__contains=q) | Q(meta_info__value__contains=q))
return works return works.order_by('name', 'pk')
class CollectionWorkListView(WorkListView): class CollectionWorkListView(WorkListView):
@ -237,7 +239,7 @@ class WorkUpdateView(CollectionMixin, WorkMixin, UpdateView):
class WorkAddToProject(EnsembleMixin, FormView): class WorkAddToProject(ProjectMixin, FormView):
admin_required = True admin_required = True
form_class = forms.ProjectSelectForm form_class = forms.ProjectSelectForm
template_name = "interface/default_form.html" template_name = "interface/default_form.html"
@ -295,7 +297,7 @@ class WorkPartSetView(EnsembleMixin, DetailView):
works = works.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id) works = works.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id)
return works return works
class WorkAddDocumentView(EnsembleMixin, CreateView): class WorkAddDocumentView(CollectionMixin, CreateView):
template_name = "interface/default_form.html" template_name = "interface/default_form.html"
model = Document model = Document
fields = ['upload'] fields = ['upload']
@ -333,22 +335,28 @@ class WorkAddDocumentView(EnsembleMixin, CreateView):
return JsonResponse({ return JsonResponse({
"message": "created", "message": "created",
"id": doc.pk, "id": doc.pk,
"entry": render_to_string('library/document_entry.html', {'doc': doc, 'request': self.request}) "entry": render_to_string('library/document_entry.html',
{'collection': self.collection, 'doc': doc, 'request': self.request}
)
}, status=201) }, status=201)
return redirect('document_annotate', doc.pk) return redirect('document_annotate', self.collection.pk, doc.pk)
class DocumentMixin(object): class DocumentMixin(CollectionMixin):
model = models.Document
def get_queryset(self): def get_queryset(self):
if self.request.is_admin: return models.Document.objects.filter(work__collection=self.collection)
return Document.objects.select_related('work')
return Document.objects.filter(work__ensemble=self.request.ensemble_id).select_related('work')
class DocumentDetailView(EnsembleMixin, DocumentMixin, DetailView): # 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(DocumentMixin, DetailView):
pass pass
class DocumentDownloadView(EnsembleMixin, DocumentMixin, SingleObjectMixin, View): class DocumentDownloadView(DocumentMixin, SingleObjectMixin, View):
def get(self, request, **args): def get(self, request, **args):
self.request = request self.request = request
@ -359,7 +367,7 @@ class DocumentDownloadView(EnsembleMixin, DocumentMixin, SingleObjectMixin, View
#return response #return response
return redirect(self.object.upload.url) return redirect(self.object.upload.url)
class DocumentAnnotateView(EnsembleMixin, DocumentMixin, DetailView): class DocumentAnnotateView(DocumentMixin, DetailView):
template_name = 'library/document_annotate.html' template_name = 'library/document_annotate.html'
def post(self, request, **args): def post(self, request, **args):
@ -369,11 +377,12 @@ class DocumentAnnotateView(EnsembleMixin, DocumentMixin, DetailView):
data = json.loads(request.body) data = json.loads(request.body)
self.object.sections.all().delete() with transaction.atomic():
for tag, start, end in data: self.object.sections.all().delete()
#pages.sort() for tag, start, end in data:
#end = pages[-1] if len(pages) > 1 else None #pages.sort()
o = self.object.sections.create(tag=tag, start=start, end=end) #end = pages[-1] if len(pages) > 1 else None
o = self.object.sections.create(tag=tag, type=models.Section.TYPE_INSTRUMENT, start=start, end=end)
return HttpResponse(status=204) return HttpResponse(status=204)
@ -387,15 +396,17 @@ class DocumentAnnotateView(EnsembleMixin, DocumentMixin, DetailView):
data['json_data'] = {'pageTags': pages, 'instruments': dict(INSTRUMENTS)} data['json_data'] = {'pageTags': pages, 'instruments': dict(INSTRUMENTS)}
return data return data
class DocumentDeleteView(EnsembleMixin, DocumentMixin, DeleteView): class DocumentDeleteView(DocumentMixin, DeleteView):
#def get_template_names(self): #def get_template_names(self):
# return ["interface/default_form.html"] # return ["interface/default_form.html"]
def get_success_url(self): def get_success_url(self):
return resolve_url('work_detail', self.object.work.pk) return resolve_url('work_detail', self.collection.pk, self.object.work_id)
class PartDownloadView(EnsembleMixin, SingleObjectMixin, View): class PartDownloadView(CollectionMixin, SingleObjectMixin, View):
pk_url_kwarg = 'section'
def get(self, request, **args): def get(self, request, **args):
self.request = request self.request = request

View File

@ -37,7 +37,7 @@ from interface.views import EnsembleMixin
from rest_framework import routers, serializers, viewsets from rest_framework import routers, serializers, viewsets
from library.models import Collection, Work, Document, Section from library.models import Collection, Work, Document, Section, WorkMeta
import requests import requests
@ -48,6 +48,17 @@ import shutil
from django.db import transaction from django.db import transaction
from django.core.files.uploadedfile import TemporaryUploadedFile from django.core.files.uploadedfile import TemporaryUploadedFile
class WorkMetaSerializer(serializers.ModelSerializer):
class Meta:
model = WorkMeta
exclude = ['id', 'work']
def to_representation(self, instance):
return f"{instance.name}:{instance.value}"
def to_internal_value(self, data):
name, _, value = data.partition(':')
return super().to_internal_value({'name': name, 'value': value})
class SectionSerializer(serializers.ModelSerializer): class SectionSerializer(serializers.ModelSerializer):
class Meta: class Meta:
@ -111,6 +122,7 @@ class DocumentSerializer(serializers.ModelSerializer):
class WorkSerializer(serializers.ModelSerializer): class WorkSerializer(serializers.ModelSerializer):
docs = DocumentSerializer(many=True) docs = DocumentSerializer(many=True)
meta_info = WorkMetaSerializer(many=True)
class Meta: class Meta:
model = Work model = Work
@ -119,6 +131,7 @@ class WorkSerializer(serializers.ModelSerializer):
def create(self, validated): def create(self, validated):
with transaction.atomic(): with transaction.atomic():
docs = validated.pop('docs', []) docs = validated.pop('docs', [])
meta = validated.pop('meta_info', [])
work = Work.objects.create(**validated) work = Work.objects.create(**validated)
for d in docs: for d in docs:
@ -134,11 +147,25 @@ class WorkSerializer(serializers.ModelSerializer):
for s in sections: for s in sections:
Section.objects.create(doc_id=doc.pk, **s) Section.objects.create(doc_id=doc.pk, **s)
for m in meta:
WorkMeta.objects.create(work_id=work.pk, **m)
return work return work
class CollectionSerializer(serializers.Serializer): class CollectionSerializer(serializers.Serializer):
works = WorkSerializer(many=True) works = WorkSerializer(many=True)
def create(self, validated):
s = WorkSerializer()
print(validated)
collection = validated['collection_id']
with transaction.atomic():
for work in validated['works']:
work['collection_id'] = collection
s.create(work)
return Collection.objects.get(pk=collection)
from rest_framework import generics from rest_framework import generics
class CollectionExportView(generics.RetrieveAPIView): class CollectionExportView(generics.RetrieveAPIView):
@ -159,3 +186,8 @@ class WorkImportView(generics.CreateAPIView):
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(collection_id=self.kwargs['pk']) serializer.save(collection_id=self.kwargs['pk'])
class CollectionImportView(generics.CreateAPIView):
serializer_class = CollectionSerializer
def perform_create(self, serializer):
serializer.save(collection_id=self.kwargs['pk'])