Library schema semi-fixed

This commit is contained in:
Tris Forster 2022-11-19 21:50:01 +11:00
parent 8f18b9ab9d
commit f7aaa98000
14 changed files with 369 additions and 162 deletions

2
.gitignore vendored
View File

@ -8,3 +8,5 @@ test.*
static
teststore
cache
local_storage
media

4
interface/fields.py Normal file
View File

@ -0,0 +1,4 @@
from crispy_forms.layout import Field
class BulmaFileUpload(Field):
template = 'bulma/file_upload.html'

View File

@ -1,4 +1,4 @@
# Generated by Django 3.2.7 on 2022-11-18 09:54
# Generated by Django 3.2.7 on 2022-11-19 10:25
import byostorage.user
from django.conf import settings
@ -9,11 +9,13 @@ import interface.models
class Migration(migrations.Migration):
replaces = [('interface', '0001_initial'), ('interface', '0002_alter_module_name'), ('interface', '0003_alter_ensemble_slug'), ('interface', '0004_alter_project_event_date')]
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('byostorage', '0004_alter_userstorage_storage'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
@ -22,7 +24,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Display name', max_length=100)),
('slug', models.SlugField(editable=False, help_text='Short name for the ensemble - used for folders', max_length=100)),
('slug', models.SlugField(editable=False, help_text='Short name for the ensemble - used for folders', max_length=100, unique=True)),
('code', models.CharField(default=interface.models.generate_code, help_text='Ensemble registration code', max_length=9)),
('passphrase', models.CharField(help_text='Used to register ensembles', max_length=100)),
('details', models.TextField(blank=True, help_text='Description of the ensemble (markdown)')),
@ -37,7 +39,7 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=100)),
('description', models.TextField(blank=True, help_text='Markdown format')),
('active', models.BooleanField(default=True)),
('event_date', models.DateField(blank=True, null=True)),
('event_date', models.DateTimeField(blank=True, null=True)),
('owner', models.CharField(blank=True, max_length=255)),
('ensemble', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='interface.ensemble')),
],
@ -73,7 +75,7 @@ class Migration(migrations.Migration):
name='Module',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.SlugField(max_length=20)),
('name', models.SlugField(choices=[('library', 'Library'), ('submissions', 'Submissions')], max_length=20)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modules', to='interface.project')),
],
),

View File

@ -154,53 +154,4 @@ class WikiPage(models.Model):
return resolve_url('wiki', project=self.project_id, pk=self.pk)
def __str__(self):
return self.title
"""
class Submission(models.Model):
project = models.ForeignKey(Project, related_name='old_submissions', on_delete=models.CASCADE)
date = models.DateTimeField(auto_now_add=True, )
name = models.CharField(max_length=255)
instrument = models.CharField(max_length=100, verbose_name="Instrument / Voice")
notes = models.TextField(blank=True)
complete = models.BooleanField(default=False)
url = models.CharField(max_length=512, blank=True)
private = models.BooleanField(default=False)
@property
def download_url(self):
if not self.complete:
raise RuntimeError("Submission not complete")
if self.private:
return self.url
params = {'Bucket': BUCKET, 'Key': self.url}
return s3client.generate_presigned_url('get_object', Params=params, ExpiresIn=3600)
@property
def download_name(self):
uri = urlparse(self.download_url)
_, name = os.path.split(uri.path)
return name or "<Unknown>"
def key_template(self):
return "submissions/{}_{}_{}_${{filename}}".format(
timezone.localtime(self.date).isoformat(timespec='seconds').replace(':', '')[:17],
slugify(self.name),
slugify(self.instrument)
)
@property
def short_name(self):
_, ext = os.path.splitext(self.download_name)
return "{}_{}_{}{}".format(
#timezone.localtime(self.date).strftime("%Y%m%d%H%M%S"),
slugify(self.name),
slugify(self.instrument),
self.pk,
ext
)
def __str__(self):
return f"{self.name}: {self.date}"
"""
return self.title

View File

@ -0,0 +1,28 @@
{% load crispy_forms_field %}
<div class="field">
<div id="div_id_{{ field.name }}" class="file has-name is-fullwidth">
<label class="file-label">
{% crispy_field field 'class' 'file-input'%}
<span class="file-cta">
<span class="file-icon">
<i class="fas fa-upload"></i>
</span>
<span class="file-label">
Choose a file…
</span>
</span>
<span class="file-name"></span>
</label>
</div>
</div>
<script>
const fileInput = document.querySelector('#div_id_{{ field.name }} input[type=file]');
fileInput.onchange = () => {
if (fileInput.files.length > 0) {
const fileName = document.querySelector('#div_id_{{ field.name }} .file-name');
fileName.textContent = fileInput.files[0].name;
}
}
</script>

View File

@ -0,0 +1,41 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% block page %}
<div class="admin-tools is-pulled-right">
<a class="button is-link" href="{% url 'register' %}">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Register another</span>
</a>
</div>
<h3 class="title">My Ensembles</h3>
<div class="columns is-multiline">
{% for ensemble in object_list %}
<div class="column is-half">
<div class="card">
<header class="card-header{% if ensemble.pk == ensemble_id %} has-background-link-light{% endif %}">
<a class="card-header-title" href="{% url 'register' %}?code={{ ensemble.code }}">
{{ ensemble.name }}
</a>
<a class="card-header-icon" href="{% url 'ensemble_forget' pk=ensemble.id %}">
<span class="delete"></span>
</a>
</header>
<div class="card-content">
{% if ensemble.details %}
<div class="content">
{{ ensemble.details | markdown }}
</div>
{% endif %}
<div>
{% with projects=ensemble.active_projects.count %}
<p>{{ projects }} active project{{ projects|pluralize }}</p>
{% endwith %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@ -0,0 +1,14 @@
from django import template
from django.utils import timesince
register = template.Library()
def roughtimesince(value):
return timesince.timesince(value, depth=1)
register.filter('roughtimesince', roughtimesince)
def roughtimeuntil(value):
return timesince.timeuntil(value, depth=1)
register.filter('roughtimeuntil', roughtimeuntil)

View File

@ -1,53 +0,0 @@
from django.test import TestCase, Client
from interface import models
class SubmissionTestCase(TestCase):
@staticmethod
def setUpTestData():
e1 = models.Ensemble.objects.create(name='The Be Sharps', code="1234", passphrase='Homer')
e1.projects.create(name='Baby on Board')
e2 = models.Ensemble.objects.create(name='Lisa and the Bleeding Gums', code="2345", passphrase="Maggie")
e2.projects.create(name='Baker St')
def setUp(self):
self.client = Client()
def test_submission_upload(self):
response = self.client.post('/register', {'code': '12-34', 'passphrase': 'Homer'})
self.assertRedirects(response, '/')
response = self.client.post(f"/projects/1/submission", {'name': 'Ned', 'instrument': 'Harp', 'method': 'upload'})
self.assertRedirects(response, '/projects/1/submission/1/upload')
response = self.client.get(response.url)
upload = response.context['upload']
self.assertEqual(upload['url'], f"http://localhost:9000/{models.BUCKET}")
self.assertRegex(upload['fields']['key'], r'^baby-on-board\/submissions\/[0-9T\-]+_ned_harp_\$\{filename\}$')
self.assertEqual(upload['fields']['success_action_redirect'], 'http://testserver/projects/1/submission/1/complete')
self.assertEqual(models.Submission.objects.count(), 1)
self.assertRedirects(self.client.get(f"/projects/1/submission/1/cancel"), '/projects/1')
self.assertEqual(models.Submission.objects.count(), 0)
def test_submission_link(self):
response = self.client.post('/register', {'code': '12-34', 'passphrase': 'Homer'})
self.assertRedirects(response, '/')
response = self.client.post(f"/projects/1/submission", {'name': 'Ned', 'instrument': 'Harp', 'method': 'link'})
self.assertRedirects(response, '/projects/1/submission/1/link')
url = 'https://drive.google.com/a/path/to/a/video.mp4#g6e6e4a23'
response = self.client.post(f"/projects/1/submission/1/link", {'url': url})
self.assertRedirects(response, '/projects/1/submission/1')
response = self.client.get('/projects/1/submission/1')
self.assertContains(response, "Thankyou for your submission")
response = self.client.get('/projects/1')
self.assertContains(response, 'Ned')
s = models.Submission.objects.get(pk=1)
self.assertEqual(s.download_url, url)

197
library/imslp.py Normal file
View File

@ -0,0 +1,197 @@
from collections import namedtuple
# taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_Instruments
ABBREVIATIONS = """
score Score
acc Accordion
afl Alto flute
alt Alto (voice) (contralto)
arp Arpeggione
bag Bagpipe
bar Baritone (voice)
bass Bass (voice)
bbar Bass baritone (voice)
bc Continuo (Basso continuo)
bcl Bass clarinet
bell Bell (Chimes)
bfl Bass flute
bgtr Bass guitar
bjo Banjo
bn Bassoon
bob Bass oboe (Baritone oboe)
br Brass instruments
bryt Baryton
bstcl Basset clarinet
bsthn Basset horn
bug Bugle
cbcl Contrabass clarinet
cbn Contrabassoon
cch Children's chorus
cel Celesta
ch Mixed chorus
cimb Cimbalom
cit Cittern
cl Clarinet
clvd Clavichord
cm Chalumeau
conc Concertina
crh Crumhorn
crt Cornet
crtt Cornett (Zink)
cv Child's voice
db Double Bass
dlcn Dulcian
dom Domra
dulc Dulcimer
egtr Electric guitar
eh English horn (Cor anglais)
elec Electronic Instruments
epf Electric piano
eq Equal voices
erhu Erhu
euph Euphonium
fch Female chorus
fda Flute d'amore (Tenor flute)
fgh Flugelhorn
fife Fife
fl Flute
flag Flageolet
ghca Glass harmonica (Bowl organ)
gl Glockenspiel
gtr Guitar
harm Harmonium
hca Harmonica (Mouth Organ)
heck Heckelphone
hn Horn
hp Harp
hpd Harpsichord
kbd Keyboard instrument
lute Lute
lyre Lyre
mand Mandolin
mar Marimba
mch Male chorus
mez Mezzo-soprano
mus Musette
nar Narrator (Reciter)
ob Oboe
oca Ocarina
oda Oboe d'amore
om Ondes Martenot
oph Ophicleide
orch Orchestra
org Organ
oud Oud
pan Pan flute (Pan-pipes)
perc Percussion
pf Piano
pf3h Piano 3 hands
pf4h Piano 4 hands
pf5h Piano 5 hands
pf6h Piano 6 hands
pflh Piano left hand
pfped Pedal piano
pfrh Piano right hand
picc Piccolo
pipa Pipa
pk Timpani
ptpt Piccolo trumpet
reb Rebec
rec Recorder
sar Sarrusophone
sax Saxophone
sheng Sheng
shw Shawm
sit Sitar
skbt Sackbut
sop Soprano (voice)
srp Serpent
stpt Slide trumpet
str String instruments
sxh Saxhorn
syn Synthesizer
tba Tuba
tbn Trombone
ten Tenor
thrm Theremin
timp Timpani
tpt Trumpet
uch Unison chorus
uke Ukelele (Ukulele)
v Voice (solo)
va Viola
vap Viola pomposa
vc Cello
vda Viola d'amore
vib Vibraphone
vie Vielle (Hurdy-Gurdy)
viol Viol (Viola da gamba)
vlne Violone
vn Violin
vuv Vuvuzela
vv Voices (multiple soloists)
wag Wagner tuba
ww Woodwind instruments
xiao Xiao
xyl Xylophone
zith Zither
"""
INSTRUMENTS = []
for line in ABBREVIATIONS.split('\n'):
parts = line.strip().split(maxsplit=1)
if len(parts) < 2: continue
name, _, _ = parts[1].partition('(')
INSTRUMENTS.append((parts[0], name))
INSTRUMENT_LOOKUP = dict(INSTRUMENTS)
TAG_LOOKUP = dict( ( (x[1].lower(), x[0]) for x in INSTRUMENTS ) )
class Instrument(namedtuple('Instrument', ('name', 'variant'), defaults=[None])):
@classmethod
def from_tag(cls, tag):
"""
>>> Instrument.from_tag('vn-1')
Instrument(name='Violin', variant='1')
>>> Instrument.from_tag('db')
Instrument(name='Double Bass', variant=None)
>>> Instrument.from_tag('Jaws Harp')
Instrument(name='Jaws Harp', variant=None)
"""
abbr, _, variant = tag.partition('-')
name = INSTRUMENT_LOOKUP.get(abbr.lower(), abbr)
if variant:
return cls(name, variant)
return cls(name, None)
def abbreviate(self):
"""
>>> Instrument('Violin', 1).abbreviate()
'vn-1'
>>> Instrument('Double Bass').abbreviate()
'db'
"""
tag = TAG_LOOKUP.get(self.name.lower())
if self.variant:
tag = f"{tag}-{self.variant}"
return tag
def __str__(self):
"""
>>> str(Instrument('Violin', 1))
'Violin 1'
>>> str(Instrument('Double Bass'))
'Double Bass'
"""
if self.variant:
return f"{self.name} {self.variant}"
return self.name
if __name__ == "__main__":
import doctest
print(doctest.testmod())

View File

@ -1,4 +1,4 @@
# Generated by Django 3.2.7 on 2022-11-18 09:54
# Generated by Django 3.2.7 on 2022-11-19 10:24
import byostorage.user
from django.conf import settings
@ -9,12 +9,14 @@ import library.models
class Migration(migrations.Migration):
replaces = [('library', '0001_initial'), ('library', '0002_auto_20221118_2208'), ('library', '0003_work_composer'), ('library', '0004_auto_20221118_2223'), ('library', '0005_auto_20221118_2253'), ('library', '0006_auto_20221119_2121')]
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('byostorage', '0004_alter_userstorage_storage'),
('interface', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
@ -23,21 +25,11 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Name of the collection', max_length=255)),
('prefix', models.SlugField(default='default', max_length=30)),
('location', models.CharField(help_text='Physical location (institution, town...)', max_length=100)),
('notes', models.TextField(blank=True, help_text='Publicly visible notes about collection and loans policy')),
('prefix', models.SlugField(default='default', help_text='Folder to store works in', max_length=30)),
('location', models.CharField(blank=True, help_text='Physical location (institution, town...)', max_length=100)),
('notes', models.TextField(blank=True, help_text='Publicly visible notes about collection and loans policy (markdown format)')),
('administrators', models.ManyToManyField(help_text='Administrators for this collection', related_name='collections', to=settings.AUTH_USER_MODEL)),
('storage', models.ForeignKey(blank=True, help_text='Storage for documents', null=True, on_delete=django.db.models.deletion.CASCADE, to='byostorage.userstorage')),
],
),
migrations.CreateModel(
name='Document',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('doctype', models.PositiveSmallIntegerField(choices=[(1, 'PDF'), (2, 'Audio'), (3, 'Video'), (4, 'Source')], default=1)),
('upload', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=library.models.doc_upload_filename)),
('created', models.DateTimeField(auto_now_add=True)),
('version', models.CharField(blank=True, max_length=30)),
('storage', models.ForeignKey(blank=True, help_text='User storage for documents', null=True, on_delete=django.db.models.deletion.CASCADE, to='byostorage.userstorage')),
],
),
migrations.CreateModel(
@ -59,45 +51,21 @@ class Migration(migrations.Migration):
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)),
('edition', models.CharField(blank=True, help_text='Edition details', max_length=255)),
('composer', models.CharField(blank=True, max_length=255)),
('orchestration', models.CharField(blank=True, help_text='IMDB format instrumentation', max_length=255)),
('original_parts', models.JSONField(blank=True, help_text='Original printed parts', null=True)),
('slug', models.SlugField(editable=False, help_text='Used as folder name', max_length=100)),
('name', models.CharField(help_text='Original name of the work', max_length=255)),
('original_parts', models.JSONField(blank=True, default=dict, help_text='Original printed parts (IMSLP format)')),
('code', models.CharField(blank=True, help_text='Collection specific code or number', max_length=100)),
('licence', models.PositiveSmallIntegerField(choices=[(2, 'Public Domain'), (4, 'Copyright Expired'), (6, 'Copyrighted'), (10, 'Internal use only')], default=6, help_text='Copyright status')),
('max_projects', models.IntegerField(default=1, help_text='How many projects can this work be attached to')),
('max_projects', models.IntegerField(default=1, help_text='How many active projects can this work be attached to')),
('running_time', models.DurationField(blank=True, help_text='Running time in seconds', null=True)),
('notes', models.TextField(blank=True)),
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='works', to='library.collection')),
('parent', models.ForeignKey(blank=True, help_text='Arrangement of another work or part of an anthology', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_works', to='library.work')),
('projects', models.ManyToManyField(help_text='Current usage', related_name='works', through='library.ProjectItem', to='interface.Project')),
('composer', models.CharField(blank=True, help_text='Surname, First Name/Initials', max_length=255)),
('edition', models.CharField(blank=True, help_text='Edition details to distinguish multiple versions', max_length=255)),
],
),
migrations.CreateModel(
name='WorkMeta',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.SlugField(choices=[('tag', 'Tag'), ('arr', 'Arranger'), ('lyrics', 'Lyracist'), ('genre', 'Genre'), ('style', 'Style')], max_length=20)),
('value', models.CharField(max_length=255)),
('work', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meta_info', to='library.work')),
],
),
migrations.CreateModel(
name='Section',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('tag', models.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='sections', to='library.document')),
],
options={
'ordering': ['doc', 'start', 'pk'],
},
),
migrations.AddField(
model_name='projectitem',
name='work',
@ -115,9 +83,37 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Ensemble access',
},
),
migrations.AddField(
model_name='document',
name='work',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='docs', to='library.work'),
migrations.CreateModel(
name='Document',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('doctype', models.PositiveSmallIntegerField(choices=[(1, 'PDF'), (2, 'Audio'), (3, 'Video'), (4, 'Source')], default=1)),
('upload', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=library.models.doc_upload_filename)),
('created', models.DateTimeField(auto_now_add=True)),
('version', models.CharField(blank=True, max_length=30)),
('work', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='docs', to='library.work')),
],
),
migrations.CreateModel(
name='WorkMeta',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.SlugField(choices=[('tag', 'Tag'), ('arr', 'Arranger'), ('lyrics', 'Lyracist'), ('genre', 'Genre'), ('style', 'Style'), ('orchestration', 'Orchestration')], max_length=20)),
('value', models.CharField(max_length=255)),
('work', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meta_info', to='library.work')),
],
),
migrations.CreateModel(
name='Section',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('tag', models.CharField(max_length=50)),
('start', models.SmallIntegerField(blank=True, null=True)),
('end', models.SmallIntegerField(blank=True, null=True)),
('doc', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='library.document')),
],
options={
'ordering': ['doc', 'start', 'pk'],
},
),
]

View File

@ -90,7 +90,7 @@ class ProjectItem(models.Model):
returned = models.DateTimeField(null=True, blank=True)
approved_by = models.ForeignKey('auth.User', on_delete=models.CASCADE)
order = models.SmallIntegerField(default=0)
section = models.SlugField
section = models.CharField(max_length=100, blank=True)
#version = models.CharField(max_length=30, blank=True, help_text="Limited to specific version tag")
class Meta:

View File

@ -0,0 +1,26 @@
{% extends "interface/project_base.html" %}
{% block page %}
<h3 class="title">Library collections for {{ request.user }}</h3>
<div class="columns is-multiline">
{% for collection in object_list %}
<div class="column is-half">
<div class="card">
<header class="card-header">
<a class="" href="{% url 'collection_work_list' pk=collection.id %}">
<p class="card-header-title">{{ collection.name }}</p>
</a>
</header>
<div class="card-content">
<p>{{ collection.location }}, {{ collection.works.count }} items.</p>
</div>
</div>
</div>
{% endfor %}
</div>
<div>
<small>{{ ensemble.ensemble_code }}</small>
</div>
{% endblock %}

View File

@ -31,8 +31,7 @@ ALLOWED_HOSTS = ['localhost']
# Application definition
POLYPHONIC_MODULES = [
'library',
'submissions'
'library'
]
INSTALLED_APPS = [

View File

@ -19,7 +19,7 @@ from django.urls import path, re_path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('interface.urls')),
path('', include('submissions.urls')),
#path('', include('submissions.urls')),
path('', include('library.urls')),
]