Got library sort of working

This commit is contained in:
Tris 2021-09-04 10:29:22 +10:00
parent 7aedc6bd07
commit 598ee5ad7e
37 changed files with 994 additions and 209 deletions

View File

@ -0,0 +1,18 @@
# Generated by Django 3.1.1 on 2021-05-05 05:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('interface', '0027_auto_20210322_1154'),
]
operations = [
migrations.AddField(
model_name='ensemble',
name='details',
field=models.TextField(blank=True),
),
]

View File

@ -44,6 +44,7 @@ class Ensemble(models.Model):
passphrase = models.CharField(max_length=100) passphrase = models.CharField(max_length=100)
admins = models.ManyToManyField('auth.User', related_name='ensembles') admins = models.ManyToManyField('auth.User', related_name='ensembles')
slug = models.SlugField(max_length=100, editable=False) slug = models.SlugField(max_length=100, editable=False)
details = models.TextField(blank=True)
storage = models.ForeignKey('byostorage.UserStorage', null=True, on_delete=models.SET_NULL) storage = models.ForeignKey('byostorage.UserStorage', null=True, on_delete=models.SET_NULL)
def active_projects(self): def active_projects(self):
@ -53,10 +54,10 @@ class Ensemble(models.Model):
code = str(self.code) code = str(self.code)
return "{}-{}-{}".format(code[:3], code[3:6], code[6:]) return "{}-{}-{}".format(code[:3], code[3:6], code[6:])
def save(self): def save(self, **kwargs):
if not self.slug: if not self.slug:
self.slug = slugify(self.name) self.slug = slugify(self.name)
super(Ensemble, self).save() super(Ensemble, self).save(**kwargs)
def __str__(self): def __str__(self):
return self.name return self.name
@ -86,7 +87,7 @@ class Project(models.Model):
@property @property
def has_happened(self): def has_happened(self):
return self.event_date < timezone.now() return self.event_date < timezone.now().date()
def save(self): def save(self):
if not self.slug: if not self.slug:

View File

@ -3,6 +3,7 @@
--border-color: #292929; --border-color: #292929;
--gray-blue: #667788; --gray-blue: #667788;
--light-blue: #c5eff7; --light-blue: #c5eff7;
--light-grey: #EEEEEE;
} }
@font-face { @font-face {
@ -36,7 +37,7 @@ BODY {
} }
.main { .main {
max-width: 1000px; max-width: 1280px;
margin: 10px auto; margin: 10px auto;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 5px; border-radius: 5px;
@ -55,16 +56,30 @@ BODY {
margin: 0px auto; margin: 0px auto;
} }
.wide {
width: 1200px;
}
.collapse { .collapse {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-around; justify-content: space-around;
} }
@media all and (max-width: 1200px) {
.wide {
width: 900px;
}
}
@media all and (max-width: 900px) { @media all and (max-width: 900px) {
.mdhide { .mdhide {
display: none; display: none;
} }
.wide {
width: auto;
}
} }
@media all and (max-width: 700px) { @media all and (max-width: 700px) {
@ -74,6 +89,9 @@ BODY {
.collapse { .collapse {
flex-direction: column; flex-direction: column;
} }
.wide {
width: auto;
}
} }
@ -138,6 +156,16 @@ INPUT[type=checkbox] {
margin-top: 20px; margin-top: 20px;
} }
.badge {
display: inline-block;
border: 1px solid var(--gray-blue);
font-weight: bold;
font-size: 0.9em;
border-radius: 10px;
padding: 4px 10px 2px;
margin: 2px;
}
.btn { .btn {
background-color: var(--gray-blue); background-color: var(--gray-blue);
display: inline-block; display: inline-block;
@ -232,10 +260,28 @@ H1 {
text-align: center; text-align: center;
} }
TD { TABLE {
padding: 5px; border-spacing: 0px;
} }
TD, TH {
padding: 3px 6px;
text-align: left;
}
TABLE THEAD TR {
background-color: var(--gray-blue);
color: var(--light-blue);
font-weight: bolder;
}
TABLE THEAD TH {
padding: 5px 6px;
}
TABLE.zebra TR:nth-child(even) {
background-color: var(--light-grey);
}
TABLE.horizontal TH { TABLE.horizontal TH {
text-align: right; text-align: right;
@ -298,12 +344,12 @@ TABLE SELECT {
border: 1px solid #999; border: 1px solid #999;
} }
TD.select-cell { TD.select-cell {
padding: 0; padding: 0px !important;
} }
.select-cell SELECT { .select-cell SELECT {
border: none; border: none;
background-color: white; background-color: transparent;
height: 30px; height: 30px;
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;

View File

@ -2,7 +2,7 @@
{% block page %} {% block page %}
<div> <div>
<h3>{{ title }}</h3> <h3>{% firstof title view.title %}</h3>
<form class="vertical" method="POST" enctype="multipart/form-data"> <form class="vertical" method="POST" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
{{ form }} {{ form }}

View File

@ -1,22 +1,12 @@
{% extends "interface/project_base.html" %} {% extends "base.html" %}
{% block page %} {% block content %}
<div style="flex-grow: 1"> <h2>{{ ensemble.name }}</h2>
<h1>Projects for {{ ensemble.name }}</h1> <p>{{ ensemble.details }}</p>
<div class="list-group narrow"> <h4>Administrators</h4>
{% for project in ensemble.active_projects %} <ul>
<a class="" href="{% url 'project_detail' project=project.id %}"> {% for admin in ensemble.admins.all %}
<h3>{{ project.name }}</h3> <li><a href="mailto:{{ admin.email }}">{% firstof admin.get_full_name admin.get_username %}</a></li>
<p><small>
{% if project.deadline %}In {{ project.deadline|timeuntil }}<br/>{% endif %}
{% if project.works.count %}{{ project.works.count }} works<br/>{% endif %}
{% if project.submissions.count %}{{ project.submissions.count }} submissions<br/>{% endif %}
</small></p>
</a>
{% endfor %} {% endfor %}
</div> </ul>
<div style="float: right; margin-top: 10px; color: #999;">
<small>{{ ensemble.ensemble_code }}</small>
</div>
</div>
{% endblock %} {% endblock %}

View File

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

View File

@ -7,9 +7,9 @@
{% if project.event_date %} {% if project.event_date %}
<h3 class="text-center"> <h3 class="text-center">
{% if project.has_happened %} {% if project.has_happened %}
{{ project.event_date|timesince }} {{ project.event_date|timesince }} ago.
{% else %} {% else %}
{{ project.event_date|}} In {{ project.event_date|timeuntil }}.
{% endif %} {% endif %}
</h3> </h3>
{% endif %} {% endif %}

View File

@ -0,0 +1,10 @@
from django import template
register = template.Library()
@register.simple_tag(takes_context=True)
def url_update(context, **kwargs):
params = context.request.GET.copy()
for k in kwargs:
params[k] = kwargs[k]
return "?" + params.urlencode()

View File

@ -0,0 +1,6 @@
from django.test import TestCase
class IntegrationTestCase(TestCase):
def test_runs(self):
self.assertTrue(True)

View File

@ -10,7 +10,9 @@ urlpatterns = [
path('register', views.register, name="register"), path('register', views.register, name="register"),
path('manage', views.ManageView.as_view(), name="manage"), path('manage', views.ManageView.as_view(), name="manage"),
path('', views.EnsembleDetailView.as_view(), name='ensemble_detail'), path('', views.EnsembleProjectListView.as_view(), name='ensemble_detail'),
path('ensembles/<int:pk>', views.EnsembleDetailView.as_view(), name='ensemble_detail'),
path('projects/<int:project>', views.ProjectDetailView.as_view(), name="project_detail"), path('projects/<int:project>', views.ProjectDetailView.as_view(), name="project_detail"),
path('projects/<int:project>/submissions.mk', views.ProjectMakefileView.as_view(), name="project_makefile"), path('projects/<int:project>/submissions.mk', views.ProjectMakefileView.as_view(), name="project_makefile"),

View File

@ -60,6 +60,10 @@ class EnsembleMixin(object):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@property
def ensemble(self):
return models.Ensemble.objects.get(pk=self.request.ensemble_id)
class ProjectMixin(EnsembleMixin): class ProjectMixin(EnsembleMixin):
def get_project(self): def get_project(self):
@ -195,7 +199,8 @@ def logout(request):
return redirect('/') return redirect('/')
class EnsembleDetailView(EnsembleMixin, DetailView): class EnsembleProjectListView(EnsembleMixin, DetailView):
template_name = 'interface/ensemble_project_list.html'
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
# capture provided urls # capture provided urls
@ -207,6 +212,9 @@ class EnsembleDetailView(EnsembleMixin, DetailView):
def get_object(self): def get_object(self):
return models.Ensemble.objects.get(pk=self.request.ensemble_id) return models.Ensemble.objects.get(pk=self.request.ensemble_id)
class EnsembleDetailView(DetailView):
model = models.Ensemble
class ProjectDetailView(ProjectMixin, DetailView): class ProjectDetailView(ProjectMixin, DetailView):
def get_object(self): def get_object(self):

13
library/README.md Normal file
View File

@ -0,0 +1,13 @@
# Model overview
Collections contain Works
Only the collection's administrators can add/remove/edit items
Collections can be enabled for Ensembles which makes everything availble in the Library
## How do loans work?
1. Project admins need to be able to search for available music - list with access type
2. Item can be attached to Project

View File

@ -2,17 +2,29 @@ from django.contrib import admin
from . import models from . import models
class OrchestrationAdmin(admin.ModelAdmin): #class OrchestrationAdmin(admin.ModelAdmin):
list_display = ['name', 'ensemble'] # list_display = ['name', 'ensemble']
list_filter = ['ensemble'] # list_filter = ['ensemble']
#admin.site.register(models.Orchestration, OrchestrationAdmin)
class CollectionAdmin(admin.ModelAdmin):
list_display = ['name', 'location']
admin.site.register(models.Collection, CollectionAdmin)
class ItemInline(admin.TabularInline): class ItemInline(admin.TabularInline):
model = models.Item model = models.Item
class DocInline(admin.TabularInline):
model = models.Document
class WorkAdmin(admin.ModelAdmin): class WorkAdmin(admin.ModelAdmin):
list_display = ['name', 'composer', 'orchestration'] list_display = ['name', 'edition', 'composer', 'running_time']
list_filter = ['ensemble'] list_filter = ['collection']
inlines = [ItemInline] inlines = [DocInline, ItemInline]
admin.site.register(models.Work, WorkAdmin)
class PartInline(admin.TabularInline): class PartInline(admin.TabularInline):
model = models.Part model = models.Part
@ -20,14 +32,19 @@ class PartInline(admin.TabularInline):
class DocumentAdmin(admin.ModelAdmin): class DocumentAdmin(admin.ModelAdmin):
list_display = ['work', '__str__'] list_display = ['work', '__str__']
list_filter = ['work__ensemble'] list_filter = ['work__collection']
inlines = [PartInline] inlines = [PartInline]
admin.site.register(models.Document, DocumentAdmin)
class ItemAdmin(admin.ModelAdmin): class ItemAdmin(admin.ModelAdmin):
list_display = ['project', 'work', 'order'] list_display = ['project', 'work', 'order']
list_filter = ['project'] list_filter = ['project']
admin.site.register(models.Orchestration, OrchestrationAdmin)
admin.site.register(models.Work, WorkAdmin)
admin.site.register(models.Document, DocumentAdmin)
admin.site.register(models.Item, ItemAdmin) admin.site.register(models.Item, ItemAdmin)
class EnsembleAccessAdmin(admin.ModelAdmin):
list_display = ['ensemble', 'collection', 'access_type']
list_filter = ['ensemble']
admin.site.register(models.EnsembleAccess, EnsembleAccessAdmin)

View File

@ -1,12 +1,15 @@
from django import forms from django import forms
from .models import Work from .models import Work
from interface.models import Project
from django.db.models import Q
class WorkCreateForm(forms.ModelForm): class WorkCreateForm(forms.ModelForm):
uploads = forms.FileField(label="PDFs to upload", widget=forms.ClearableFileInput(attrs={'multiple': True})) uploads = forms.FileField(label="PDFs to upload", widget=forms.ClearableFileInput(attrs={'multiple': True}), required=False)
class Meta: class Meta:
model = Work model = Work
fields = ['uploads', 'name', 'orchestration', 'running_time', 'notes'] fields = ['uploads', 'name', 'composer', 'edition', 'collection', 'code', 'running_time', 'notes']
class PlaylistAddForm(forms.Form): class PlaylistAddForm(forms.Form):
work = forms.ModelChoiceField(queryset=Work.objects.all()) work = forms.ModelChoiceField(queryset=Work.objects.all())
@ -22,3 +25,6 @@ class PlaylistAddForm(forms.Form):
def save(self): def save(self):
self.instance.works.add(self.cleaned_data['work']) self.instance.works.add(self.cleaned_data['work'])
class ProjectSelectForm(forms.Form):
project = forms.ModelChoiceField(queryset=Project.objects.all())

View File

View File

View File

@ -0,0 +1,20 @@
from django.core.management.base import BaseCommand, CommandError
import argparse
import csv
from library import models
class Command(BaseCommand):
help = 'Imports works from a csv file'
def add_arguments(self, parser):
parser.add_argument('collection', type=int, help="Collection ID")
parser.add_argument('source', type=argparse.FileType('r'), help="Source CSV")
def handle(self, *args, **options):
collection = models.Collection.objects.get(pk=options['collection'])
reader = csv.DictReader(options['source'])
for row in reader:
collection.works.create(name=row['Piece'], composer=row['Composer'], notes=row['Notes'])

View File

@ -1,9 +1,10 @@
# Generated by Django 3.1.1 on 2021-03-11 07:07 # Generated by Django 3.1.1 on 2021-04-28 03:53
import byostorage.cached
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import library.models import library.models
import byostorage.cached
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -11,14 +12,29 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('interface', '0022_auto_20210303_2043'), ('interface', '0027_auto_20210322_1154'),
('byostorage', '0003_auto_20210323_1047'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel(
name='Collection',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('location', models.CharField(help_text='Physical location', max_length=100)),
('notes', models.TextField(blank=True)),
('administrators', models.ManyToManyField(help_text='Administrators for this collection', related_name='collections', to=settings.AUTH_USER_MODEL)),
('ensembles', models.ManyToManyField(related_name='collections', to='interface.Ensemble')),
('storage', models.ForeignKey(blank=True, help_text='Storage for documents', null=True, on_delete=django.db.models.deletion.CASCADE, to='byostorage.userstorage')),
],
),
migrations.CreateModel( migrations.CreateModel(
name='Document', name='Document',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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.cached.CachedStorage(), upload_to=library.models.doc_upload_filename)), ('upload', models.FileField(storage=byostorage.cached.CachedStorage(), upload_to=library.models.doc_upload_filename)),
], ],
), ),
@ -48,15 +64,19 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(editable=False, max_length=100)), ('slug', models.SlugField(editable=False, max_length=100)),
('name', models.CharField(max_length=255)), ('name', models.CharField(max_length=255)),
('running_time', models.IntegerField(blank=True, null=True)), ('version', models.CharField(blank=True, help_text='Version or edition details', max_length=100)),
('composer', models.CharField(blank=True, help_text='Use Composer / Arranger format', max_length=255)),
('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_loans', models.BooleanField(default=1, help_text='How many projects can this work be attached to')),
('running_time', models.IntegerField(blank=True, help_text='Running time in seconds', null=True)),
('notes', models.TextField(blank=True)), ('notes', models.TextField(blank=True)),
('ensemble', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='works', to='interface.ensemble')), ('tag_list', models.CharField(blank=True, help_text='Multiple tags for the work', max_length=255)),
('collection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='works', to='library.collection')),
('orchestration', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='works', to='library.orchestration')), ('orchestration', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='works', to='library.orchestration')),
('projects', models.ManyToManyField(related_name='works', through='library.Item', to='interface.Project')), ('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.Item', to='interface.Project')),
], ],
options={
'unique_together': {('ensemble', 'slug')},
},
), ),
migrations.CreateModel( migrations.CreateModel(
name='Part', name='Part',
@ -75,7 +95,16 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='item', model_name='item',
name='work', name='work',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='library.work'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_items', to='library.work'),
),
migrations.CreateModel(
name='EnsembleAccess',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('access_type', models.PositiveSmallIntegerField(choices=[(1, 'Unlimited'), (2, 'Approval required')], default=2)),
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='library.collection')),
('ensemble', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='interface.ensemble')),
],
), ),
migrations.AddField( migrations.AddField(
model_name='document', model_name='document',

View File

@ -0,0 +1,34 @@
# Generated by Django 3.1.1 on 2021-05-04 08:30
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('library', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='item',
name='approved_by',
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='auth.user'),
preserve_default=False,
),
migrations.AddField(
model_name='item',
name='checkin',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='item',
name='checkout',
field=models.DateTimeField(default=django.utils.timezone.now),
preserve_default=False,
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 3.1.1 on 2021-05-04 08:45
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('library', '0002_auto_20210504_1830'),
]
operations = [
migrations.AlterField(
model_name='work',
name='max_loans',
field=models.IntegerField(default=1, help_text='How many projects can this work be attached to'),
),
migrations.AlterField(
model_name='work',
name='orchestration',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='works', to='library.orchestration'),
),
]

View File

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

View File

@ -0,0 +1,44 @@
# Generated by Django 3.1.1 on 2021-05-04 23:27
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('library', '0003_auto_20210504_1845'),
]
operations = [
migrations.RenameField(
model_name='item',
old_name='checkin',
new_name='due',
),
migrations.AddField(
model_name='item',
name='returned',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='collection',
name='location',
field=models.CharField(help_text='Physical location (institution, town...)', max_length=100),
),
migrations.AlterField(
model_name='collection',
name='name',
field=models.CharField(help_text='Often just the name of the owning ensemble', max_length=255),
),
migrations.AlterField(
model_name='collection',
name='notes',
field=models.TextField(blank=True, help_text='Publicly visible notes about collection and loans policy'),
),
migrations.AlterField(
model_name='work',
name='collection',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='works', to='library.collection'),
),
]

View File

@ -1,4 +1,4 @@
# Generated by Django 3.1.1 on 2021-03-22 23:47 # Generated by Django 3.1.1 on 2021-05-06 02:41
from django.db import migrations, models from django.db import migrations, models
@ -6,13 +6,13 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('library', '0003_orchestrations'), ('library', '0004_auto_20210505_0927'),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='work', model_name='work',
name='composer', name='parts',
field=models.CharField(blank=True, max_length=255), field=models.JSONField(blank=True, null=True),
), ),
] ]

View File

@ -0,0 +1,48 @@
# Generated by Django 3.2.7 on 2021-09-02 03:55
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('interface', '0028_ensemble_details'),
('library', '0005_work_parts'),
]
operations = [
migrations.AlterModelOptions(
name='ensembleaccess',
options={'verbose_name_plural': 'Ensemble access'},
),
migrations.RemoveField(
model_name='collection',
name='ensembles',
),
migrations.AddField(
model_name='document',
name='version',
field=models.CharField(blank=True, max_length=30),
),
migrations.AddField(
model_name='item',
name='version',
field=models.CharField(blank=True, help_text='Limited to specific version tag', max_length=30),
),
migrations.AlterField(
model_name='collection',
name='name',
field=models.CharField(help_text='Name of the collection', max_length=255),
),
migrations.AlterField(
model_name='ensembleaccess',
name='collection',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_ensembles', to='library.collection'),
),
migrations.AlterField(
model_name='ensembleaccess',
name='ensemble',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_collections', to='interface.ensemble'),
),
]

View File

@ -0,0 +1,33 @@
# Generated by Django 3.2.7 on 2021-09-02 04:07
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('library', '0006_auto_20210902_1355'),
]
operations = [
migrations.RemoveField(
model_name='work',
name='orchestration',
),
migrations.RemoveField(
model_name='work',
name='version',
),
migrations.AddField(
model_name='document',
name='created',
field=models.DateTimeField(auto_created=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='work',
name='edition',
field=models.CharField(blank=True, help_text='Edition details', max_length=100),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.7 on 2021-09-02 04:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0007_auto_20210902_1407'),
]
operations = [
migrations.AddField(
model_name='work',
name='orchestration',
field=models.CharField(blank=True, help_text='IMDB format instrumentation', max_length=255),
),
]

View File

@ -1,7 +1,11 @@
from os import SCHED_OTHER
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.timezone import now
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
import re import re
@ -14,42 +18,181 @@ except (ImportError, AttributeError):
library_storage = get_storage_class()() library_storage = get_storage_class()()
logger.info("Library storage: %s", library_storage.__class__.__name__) logger.info("Library storage: %s", library_storage.__class__.__name__)
INSTRUMENTS = [ # taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_Instruments
('Score', 'Score'), ABBREVIATIONS = """
('S', 'Soprano'), score Score
('A', 'Alto'), acc Accordion
('T', 'Tenor'), afl Alto flute
('B', 'Bass'), alt Alto (voice) (contralto)
('V', 'Vocals'), arp Arpeggione
('Vln', 'Violin'), bag Bagpipe
('Vla', 'Viola'), bar Baritone (voice)
('Vc', 'Violoncello'), bass Bass (voice)
('Cb', 'Contrabass'), bbar Bass baritone (voice)
('Fl', 'Flute'), bc Continuo (Basso continuo)
('Picc', 'Piccolo'), bcl Bass clarinet
('Cl', 'Clarinet'), bell Bell (Chimes)
('Ob', 'Oboe'), bfl Bass flute
('Hn', 'Horn'), bgtr Bass guitar
('Tpt', 'Trumpet'), bjo Banjo
('Tbn', 'Trombone'), bn Bassoon
('Tuba', 'Tuba'), bob Bass oboe (Baritone oboe)
('Timp', 'Timpani'), br Brass instruments
('Drum', 'Drumset'), bryt Baryton
('Perc', 'Percussion'), bstcl Basset clarinet
('Pno', 'Piano'), bsthn Basset horn
('Hp', 'Harp'), 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))
'''
ORCHESTRATIONS = { ORCHESTRATIONS = {
'SATB': ('S', 'A', 'T', 'B'), 'SATB': ('S', 'A', 'T', 'B'),
'String Quartet': ('Vln1', 'Vln2', 'Vla', 'Vc'), 'String Quartet': ('Vln1', 'Vln2', 'Vla', 'Vc'),
'String Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb'),
'Chamber Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb', 'Chamber Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb',
'Fl1', 'Fl2', 'Cl1', 'Cl2', 'Hn1', 'Hn2', 'Fl1', 'Fl2', 'Cl1', 'Cl2', 'Hn1', 'Hn2',
'Tpt1', 'Tpt2', 'Tbn1', 'Tbn2', 'Tuba', 'Tpt1', 'Tpt2', 'Tbn1', 'Tbn2', 'Tuba',
'Timp', 'Drum', 'Perc'), 'Timp', 'Drum', 'Perc'),
'RWE': ('Fl1', 'Fl2', 'Cl', 'Tbn', 'Vln1', 'Vln2', 'Vla', 'Vc'), 'Custom': (),
'Custom': ()
} }
'''
DOCTYPES = [
(1, 'PDF'),
(2, 'Audio'),
(3, 'Video'),
(4, 'Source'),
]
LICENCE_TYPES = [
(2, 'Public Domain'),
(4, 'Copyright Expired'),
(6, 'Copyrighted'),
(10, 'Internal use only'),
]
ACCESS_TYPES = [
(1, 'Unlimited'),
(2, 'Approval required'),
]
def tag_to_instrument(tag): def tag_to_instrument(tag):
m = re.match(r'([A-Za-z]+)(\d*)', tag) m = re.match(r'([A-Za-z]+)(\d*)', tag)
@ -58,13 +201,18 @@ def tag_to_instrument(tag):
l = m.groups() l = m.groups()
return "{0} {1}".format(dict(INSTRUMENTS).get(l[0],l[0]), l[1]).strip() return "{0} {1}".format(dict(INSTRUMENTS).get(l[0],l[0]), l[1]).strip()
'''
class Orchestration(models.Model): class Orchestration(models.Model):
"""
Stores a list of instrument codes as a single entry (space delimited).
Can be global or ensemble specific
"""
ensemble = models.ForeignKey('interface.Ensemble', on_delete=models.CASCADE, related_name="orchestrations", null=True, blank=True) ensemble = models.ForeignKey('interface.Ensemble', on_delete=models.CASCADE, related_name="orchestrations", null=True, blank=True)
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
instruments = models.TextField() instruments = models.TextField()
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, tag_to_instrument(t)) for t in tags if t ]
def save(self): def save(self):
@ -73,44 +221,21 @@ class Orchestration(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
'''
class Work(models.Model):
ensemble = models.ForeignKey('interface.Ensemble', on_delete=models.CASCADE, related_name="works")
slug = models.SlugField(max_length=100, editable=False)
name = models.CharField(max_length=255)
composer = models.CharField(max_length=255, blank=True)
orchestration = models.ForeignKey(Orchestration, null=True, on_delete=models.SET_NULL, related_name='works')
running_time = models.IntegerField(null=True, blank=True)
notes = models.TextField(blank=True)
projects = models.ManyToManyField('interface.Project', through='Item', 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):
return self.orchestration.as_list()
def save(self):
if not self.slug:
self.slug = slugify(self.name)
super(Work, self).save()
def __str__(self):
return self.name
class Item(models.Model): class Item(models.Model):
""" """
Item represents a Work in a Project e.g. item in set list or programme Item represents a specic version of a Work in a Project e.g. item in set list or programme
It also allows works to be shared from one ensemble to another on a per-project basis. It also allows works to be shared from one ensemble to another on a per-project basis.
""" """
project = models.ForeignKey('interface.Project', on_delete=models.CASCADE) project = models.ForeignKey('interface.Project', on_delete=models.CASCADE)
work = models.ForeignKey('Work', on_delete=models.CASCADE) work = models.ForeignKey('Work', on_delete=models.CASCADE, related_name='project_items')
checkout = models.DateTimeField()
due = models.DateTimeField(null=True, blank=True)
returned = models.DateTimeField(null=True, blank=True)
approved_by = models.ForeignKey('auth.User', on_delete=models.CASCADE)
order = models.SmallIntegerField(default=0) order = models.SmallIntegerField(default=0)
version = models.CharField(max_length=30, blank=True, help_text="Limited to specific version tag")
class Meta: class Meta:
ordering = ['order', 'work'] ordering = ['order', 'work']
@ -118,20 +243,151 @@ class Item(models.Model):
def __str__(self): def __str__(self):
return f"<{self.project.slug}:{self.work.slug}>" return f"<{self.project.slug}:{self.work.slug}>"
class Collection(models.Model):
"""
Storage location for works (physical or virtual)
"""
name = models.CharField(max_length=255, help_text="Name of the collection")
administrators = models.ManyToManyField('auth.User', related_name="collections", help_text="Administrators for this collection")
location = models.CharField(max_length=100, help_text="Physical location (institution, town...)")
storage = models.ForeignKey('byostorage.UserStorage', on_delete=models.CASCADE, null=True, blank=True, help_text="Storage for documents")
notes = models.TextField(blank=True, help_text="Publicly visible notes about collection and loans policy")
#ensembles = models.ManyToManyField('interface.Ensemble', related_name="collections", through='EnsembleAccess')
def __str__(self):
return self.name
class EnsembleAccess(models.Model):
"""
Can have different access levels to a collection
"""
ensemble = models.ForeignKey('interface.Ensemble', on_delete=models.CASCADE, related_name="allowed_collections")
collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name="allowed_ensembles")
access_type = models.PositiveSmallIntegerField(choices=ACCESS_TYPES, default=2)
class Meta:
verbose_name_plural = "Ensemble access"
class Work(models.Model):
"""
A musical work 'owned' by a collection from a licencing perspective.
"""
collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name="works", help_text="Owner")
slug = models.SlugField(max_length=100, editable=False)
name = models.CharField(max_length=255)
edition = models.CharField(max_length=100, blank=True, help_text="Edition details")
parent = models.ForeignKey('Work', null=True, blank=True, on_delete=models.SET_NULL, related_name="related_works",
help_text="Arrangement of another work or part of an anthology")
composer = models.CharField(max_length=255, blank=True, help_text="Use Composer / Arranger format")
#orchestration = models.ForeignKey(Orchestration, null=True, on_delete=models.SET_NULL, related_name='works', blank=True)
orchestration = models.CharField(max_length=255, blank=True, help_text="IMDB format instrumentation")
parts = models.JSONField(null=True, blank=True)
# Collection details
collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name="works")
code = models.CharField(max_length=100, blank=True, help_text="Collection specific code or number")
licence = models.PositiveSmallIntegerField(choices=LICENCE_TYPES, default=6, help_text="Copyright status")
max_loans = models.IntegerField(default=1, help_text="How many projects can this work be attached to")
# Extra info
running_time = models.IntegerField(null=True, blank=True, help_text="Running time in seconds")
notes = models.TextField(blank=True)
tag_list = models.CharField(max_length=255, blank=True, help_text="Multiple tags for the work")
projects = models.ManyToManyField('interface.Project', through='Item', related_name="works", help_text="Current usage")
@property
def duration(self):
if self.running_time is None:
return "-:--"
return "{0:d}:{1:02d}".format(int(self.running_time / 60), self.running_time % 60)
@property
def tags(self):
return self.tag_list.split(';') if self.tag_list else []
@tags.setter
def set_tags(self, tags):
self.tag_list = ";".join(tags)
@property
def digital_parts(self):
return Part.objects.filter(doc__work=self.pk)
@property
def physical_parts(self):
if not self.parts:
return []
return [ (tag_to_instrument(k), v) for (k, v) in self.parts.items() ]
#@property
#def instruments(self):
# return self.orchestration.as_list()
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super(Work, self).save(*args, **kwargs)
@property
def active_projects(self):
return self.projects.filter(active=True)
@property
def current_loans(self):
return self.project_items.filter(checkout__lte=now(), returned=None).select_related('project')
@cached_property
def loans(self):
try:
return self.loan_count
except AttributeError:
return self.project_items.filter(checkout__lte=now(), returned=None).count()
@property
def is_available(self):
if self.max_loans < 0:
return True
return self.max_loans > self.loans
@property
def available(self):
if self.max_loans < 0:
return 'Unlimited'
a = self.max_loans - self.loans
return '{0} of {1}'.format(max(a, 0), self.max_loans)
@property
def identifier(self):
return f"{self.collection.pk:03d}-{self.pk:03d}"
def __str__(self):
return f"{self.name} ({self.composer})"
def doc_upload_filename(doc, filename): def doc_upload_filename(doc, filename):
storage = doc.work.ensemble.storage storage = doc.work.collection.storage
if not storage: if not storage:
raise RuntimeError("Storage not set") raise RuntimeError("Collection has no storage attached")
return f'{storage}:{doc.work.ensemble.slug}/works/{doc.work.slug}/{filename}' return f'{storage}:works/{doc.work.slug}-{doc.work.pk}/{filename}'
class Document(models.Model): class Document(models.Model):
"""
Document represents a single file stored in the storage backend.
"""
work = models.ForeignKey('Work', on_delete=models.CASCADE, related_name="docs") work = models.ForeignKey('Work', on_delete=models.CASCADE, related_name="docs")
doctype = models.PositiveSmallIntegerField(choices=DOCTYPES, default=1)
upload = models.FileField(upload_to=doc_upload_filename, storage=library_storage) upload = models.FileField(upload_to=doc_upload_filename, storage=library_storage)
created = models.DateTimeField(auto_now_add=True)
version = models.CharField(max_length=30, blank=True)
def __str__(self): def __str__(self):
return self.upload.name return self.upload.name
class Part(models.Model): class Part(models.Model):
"""
Part is a tagged portion of a Document
"""
doc = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="parts") doc = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="parts")
tag = models.SlugField(max_length=20) tag = models.SlugField(max_length=20)
start = models.SmallIntegerField(null=True, blank=True) start = models.SmallIntegerField(null=True, blank=True)

View File

@ -1,7 +1,7 @@
{% extends "interface/project_base.html" %} {% extends "interface/project_base.html" %}
{% block admin %} {% block admin %}
<a href="{% url 'item_list_manage' project.pk %}"><i class="fas fa-list"></i><span class="smhide admin">Change items</span></a> <a href="{% url 'item_list_manage' project.pk %}"><i class="fas fa-list"></i><span class="smhide admin"> Change items</span></a>
{% endblock %} {% endblock %}
{% block page %} {% block page %}
@ -23,15 +23,22 @@
<input id="part-preference" name="part" type="number" value="0" min="0" max="4" onchange="updateParts()" size="1"/><br/><br/> <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> <button type="submit" style="height: 32px;"><i class="fas fa-copy"></i> Get My Parts!</button>
</div> </div>
<table style="max-width: 600px; margin: 10pt auto;" class=""> <table style="max-width: 600px; margin: 10pt auto;" class="zebra">
<thead>
<tr>
<th/>
<th>Piece</th>
<th>Part</th>
</tr>
</thead>
<tbody> <tbody>
{% for item in object_list %} {% for item in object_list %}
<tr> <tr>
<td>{{ forloop.counter }})</td> <td>{{ forloop.counter }}.</td>
<td> <td>
<a href="{% url 'work_detail' item.work.pk %}">{{ item.work.name }}</a> <a href="{% url 'work_detail' item.work.pk %}">{{ item.work.name }}</a>
</td> </td>
<td class=""> <td class="select-cell">
<input type="hidden" name="works" value="{{ item.work.pk }}"/> <input type="hidden" name="works" value="{{ item.work.pk }}"/>
<select name="instruments"> <select name="instruments">
<option value='-'>None</option> <option value='-'>None</option>

View File

@ -6,18 +6,19 @@
{% endblock %} {% endblock %}
{% block page %} {% block page %}
<table style="max-width: 600px; margin: 10pt auto;" class="item-table"> <table style="max-width: 600px; margin: 10pt auto;" class="zebra">
<thead> <thead>
<tr> <tr>
<th>Item</th> <th>Item</th>
<th>Time</th> <th>Time</th>
<th/>
</tr> </tr>
</thead> </thead>
<tbody id="work-list"> <tbody id="work-list">
{% for item in object_list %} {% for item in object_list %}
<tr data-pk="{{ item.pk }}" data-order="{{ forloop.counter }}"> <tr data-pk="{{ item.pk }}" data-order="{{ forloop.counter }}">
<td>{{ item.work.name }}</td> <td>{{ item.work.name }}</td>
<td>{% firstof item.work.running_time '?' %}</td> <td>{{ item.work.duration }}</td>
<td style="text-align: center;"> <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-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-arrow-down clickable" title="Move down" onclick="moveItem({{ item.pk }}, 1)"></i>

View File

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

View File

@ -6,23 +6,84 @@
{% endblock %} {% endblock %}
{% block page %} {% block page %}
<h2><a href="{% url 'work_list' %}">Works</a> / {{ work.name }}</h2> <h2>{{ work.name }} {% if work.running_time %}({{ work.duration }}){% endif %} <small>[{{ work.identifier }}]</small></h2>
<h4>{{ work.composer }}{% if work.version %} - {{ work.version }}{% endif %}</h4>
<p>{{ work.notes }}</p> <p>{{ work.notes }}</p>
<h3>Parts</h3> {% if work.collection %}
<ul> <p>Location: {{ work.collection }} [{{ work.collection_index }}]</p>
{% for part in work.parts %} {% endif %}
<li><a href="{% url 'part_download' pk=part.pk filename=part.filename %}" target="part_{{ part.pk }}" rel="">{{ part.instrument }}</a></li>
{% endfor %}
</ul>
<a href="{% url 'work_partset' pk=work.pk %}"><i class="fas fa-print"></i> Print part set</a>
<h3>Original Documents</h3> {% if work.parent %}
<p>From <a href="{% url 'work_detail' work.parent.pk %}">{{ work.parent.name }} - {{ work.parent.composer }}</a></p>
{% endif %}
{% if work.related_works.count %}
<h3>Related</h3>
<ul>
{% for related in work.related_works.all %}
<li><a href="{% url 'work_detail' related.pk %}">{{ related.name }} - {{ related.composer }}</a></li>
{% endfor %}
</ul>
{% endif %}
<h4>Loans
{% if request.is_admin %}
<a href="{% url 'work_add_to_project' work.pk %}"><i class="fas fa-plus-circle"></i></a>
{% endif %}
</h4>
<table style="margin: 10px auto">
<thead>
<tr>
<th>Ensemble</th>
<th>Project</th>
<th>Checked Out</th>
<th>Due Back</th>
</tr>
</thead>
<tbody>
{% for item in work.current_loans %}
<tr>
<td><a href="{% url 'ensemble_detail' item.project.ensemble_id %}">{{ item.project.ensemble.name }}</a></td>
<td><a href="{% url 'project_detail' item.project.pk %}">{{ item.project.name }}</a></td>
<td>{{ item.checkout.date|date:"d/m/Y" }}</td>
<td>{{ item.due.date|date:"d/m/Y" }}</td>
</tr>
{% empty %}
<tr><td>No current loans</td></tr>
{% endfor %}
</tbody>
</table>
<h4>Printed Parts</h4>
<p>
{% for inst, c in work.physical_parts %}
<span class="badge">{{ inst }} ({{ c }})</span>
{% empty %}
No physical parts available
{% endfor %}
</p>
<h4>Digital Parts
<a href="{% url 'work_partset' pk=work.pk %}" title="Print part set"><i class="fas fa-print"></i></a>
</h4>
<p>
{% for part in work.digital_parts %}
<a class="badge" href="{% url 'part_download' pk=part.pk filename=part.filename %}"
target="part_{{ part.pk }}" rel="">{{ part.instrument }}</a>
{% empty %}
No digital parts available
{% endfor %}
</p>
<h4>Documents</h4>
<ul> <ul>
{% for doc in work.docs.all %} {% for doc in work.docs.all %}
<li> <li>
<a href="{% url 'document_download' pk=doc.pk %}" target="_blank">{{ doc.upload.name|basename }}</a> <a href="{% url 'document_download' pk=doc.pk %}" target="_blank">{{ doc.upload.name|basename }}</a>
&nbsp; &nbsp;
[{{ doc.parts.count }} parts] {% with parts=doc.parts.count %}
{% if parts %}[{{ parts }} parts]{% endif %}
{% endwith %}
{% if request.is_admin %} {% if request.is_admin %}
<a href="{% url 'document_annotate' pk=doc.pk %}"><i class="fas fa-tags"></i></a> <a href="{% url 'document_annotate' pk=doc.pk %}"><i class="fas fa-tags"></i></a>
{% endif %} {% endif %}

View File

@ -1,25 +1,61 @@
{% extends "interface/project_base.html" %} {% extends "interface/project_base.html" %}
{% load url_tools %}
{% block admin %} {% block admin %}
<a href="{% url 'work_add' %}"><i class="fas fa-plus-circle"></i> Add new</a> <a href="{% url 'work_add' %}"><i class="fas fa-plus-circle"></i> Add new</a>
{% endblock %} {% endblock %}
{% block page %} {% block page %}
<h2>Works</h2> <h2>Library for {{ view.ensemble }}</h2>
<table style="max-width: 800px; margin: 10pt auto;"> <div style="margin-bottom: 1em;">
<form method="GET">
<input name="filter" type="text" placeholder="Filter" value="{{ request.GET.filter }}"/>
<a href="?">Clear</a>
</form>
</div>
<table class="zebra wide">
<thead> <thead>
<tr>
<th>Work</th>
<th>Composer</th>
<th class="smhide">Edition</th>
<th class="smhide">Orchestration</th>
<th class="smhide">Collection</th>
<th>Copies</th>
</tr>
</thead> </thead>
<tbody> <tbody>
{% for work in object_list %} {% for work in object_list %}
{% with work.docs.count as doc_count %}
{% with work.parts.count as part_count %}
<tr> <tr>
<td><a href="{% url 'work_detail' pk=work.pk %}">{{ work.name }}</a></td> <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> <td title="{{ work.composer }}">{{ work.composer|truncatewords:3 }}</td>
<td class="smhide" title="{{ work.edition }}">{{ work.edition|truncatewords:2 }}</td>
<td class="smhid" title="{{ work.orchestration }}">{{ work.orchestration|truncatewords:2}}</td>
<td class="smhide">{{ work.collection.name }}</td>
<td style="color: {{ work.is_available|yesno:'green,red' }};">{{ work.available }}</td>
</tr> </tr>
{% endwith %} {% empty %}
{% endwith %} <tr><td colspan="4">No works found</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<div class="pagination" style="text-align: right;">
<span class="step-links">
{% if page_obj.has_previous %}
<a href="{% url_update page=1 %}" title="First">&laquo;</a>
<a href="{% url_update page=page_obj.previous_page_number %}" title="Previous">&lsaquo;</a>
{% endif %}
<span class="current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}.
</span>
{% if page_obj.has_next %}
<a href="{% url_update page=page_obj.next_page_number %}" title="Next">&rsaquo;</a>
<a href="{% url_update page=page_obj.paginator.num_pages %}" title="Last">&raquo;</a>
{% endif %}
</span>
</div>
{% endblock %} {% endblock %}

View File

@ -1,3 +1,22 @@
from django.test import TestCase from django.test import TestCase
# Create your tests here. from django.contrib.auth.models import User
from interface.models import Ensemble, Project
from . import models
class IntegrationTestCase(TestCase):
def setUp(self):
self.homer = User.objects.create(username='homer')
self.ned = User.objects.create(username="ned")
self.lisa = User.objects.create(username="lisa")
self.dewey = User.objects.create(username="dewey")
self.be_sharps = self.homer.ensembles.create(name='Be Sharps', code="barbershop")
self.sesd = self.dewey.ensembles.create(name="Springfield Elementary School Band", code="sax")
self.sel = self.lisa.collections.create(name="Springfield Elementary Library")
self.flanders = self.ned.collections.create(name="Neds Shed")
def test_integration(self):
pass

View File

@ -13,8 +13,12 @@ urlpatterns = [
path('library/works/create', views.WorkAddView.as_view(), name="work_add"), path('library/works/create', views.WorkAddView.as_view(), name="work_add"),
path('library/works/<int:pk>', views.WorkDetailView.as_view(), name="work_detail"), path('library/works/<int:pk>', views.WorkDetailView.as_view(), name="work_detail"),
path('library/works/<int:pk>/partset', views.WorkPartSetView.as_view(), name="work_partset"), path('library/works/<int:pk>/partset', views.WorkPartSetView.as_view(), name="work_partset"),
path('library/works/<int:pk>/upload', views.DocumentAddView.as_view(), name="document_add"), path('library/works/<int:pk>/add_to_project', views.WorkAddToProject.as_view(), name="work_add_to_project"),
path('library/documents/<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>/download', views.DocumentDownloadView.as_view(), name="document_download"),
path('library/documents/<int:pk>/annotate', views.DocumentAnnotateView.as_view(), name="document_annotate"), 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"), path('library/parts/<int:pk>/<str:filename>', views.PartDownloadView.as_view(), name="part_download"),
] ]
from django.views.static import serve
urlpatterns.append(path('localstorage/<path:path>', serve, {'document_root': 'local_storage'}))

View File

@ -4,6 +4,8 @@ from django.views.generic.list import ListView, MultipleObjectMixin
from django.views.generic.edit import CreateView, FormView, UpdateView from django.views.generic.edit import CreateView, FormView, UpdateView
from django.http import FileResponse, HttpResponse from django.http import FileResponse, HttpResponse
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models import Q, Count
from django.utils.timezone import now
import json import json
@ -98,9 +100,20 @@ class ProjectItemAddView(ProjectMixin, UpdateView):
return self.get_project() return self.get_project()
class WorkListView(EnsembleMixin, ListView): class WorkListView(EnsembleMixin, ListView):
paginate_by = 20
def get_queryset(self): def get_queryset(self):
return Work.objects.filter(ensemble=self.request.ensemble_id).order_by('name') #works = Work.objects.filter(collection__ensembles=self.request.ensemble_id).order_by('name').select_related('collection')
works = Work.objects.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id).order_by('name').select_related('collection')
loan_count = Count('project_items', Q(project_items__checkout__lte=now(), project_items__returned=None))
works = works.annotate(loan_count=loan_count)
q = self.request.GET.get('filter')
if q:
works = works.filter(Q(name__contains=q) | Q(composer__contains=q) | Q(tag_list__contains=q))
return works
class WorkAddView(EnsembleMixin, FormView): class WorkAddView(EnsembleMixin, FormView):
template_name = "interface/default_form.html" template_name = "interface/default_form.html"
@ -108,34 +121,59 @@ class WorkAddView(EnsembleMixin, FormView):
def get_form(self): def get_form(self):
f = super(WorkAddView, self).get_form() f = super(WorkAddView, self).get_form()
qs = f.fields['orchestration'].queryset #qs = f.fields['orchestration'].queryset
f.fields['orchestration'].queryset = qs.filter(ensemble_id__isnull=True) | qs.filter(ensemble_id=self.request.ensemble_id) #f.fields['orchestration'].queryset = qs.filter(ensemble_id__isnull=True) | qs.filter(ensemble_id=self.request.ensemble_id)
qs = f.fields['collection'].queryset
qs = qs.filter(administrators=self.request.user)
f.fields['collection'].queryset = qs
return f return f
def form_valid(self, form): def form_valid(self, form):
obj = form.save(commit=False) work = form.save(commit=False)
obj.ensemble_id = self.request.ensemble_id work.ensemble_id = self.request.ensemble_id
try: work.save()
obj.save()
except IntegrityError:
form.add_error('name', 'Name must be unique')
return self.form_invalid(form)
# handle the files # handle the files
uploads = self.request.FILES.getlist('uploads') uploads = self.request.FILES.getlist('uploads')
docs = [] docs = []
for f in uploads: for f in uploads:
docs.append(obj.docs.create(upload=f).pk) docs.append(work.docs.create(upload=f).pk)
if len(docs) == 1: if len(docs) == 1:
return redirect('document_annotate', docs[0]) return redirect('document_annotate', docs[0])
else: else:
return redirect('work_detail', pk=obj.pk) return redirect('work_detail', pk=work.pk)
class WorkDetailView(EnsembleMixin, DetailView): class WorkDetailView(EnsembleMixin, DetailView):
def get_queryset(self): def get_queryset(self):
return Work.objects.filter(ensemble=self.request.ensemble_id) return Work.objects.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id)
class WorkAddToProject(EnsembleMixin, FormView):
form_class = forms.ProjectSelectForm
template_name = "interface/default_form.html"
title = "Select project to add work to"
def get_object(self):
return Work.objects.get(pk=self.kwargs['pk'])
def get_form(self):
f = super(WorkAddToProject, self).get_form()
qs = f.fields['project'].queryset
work = self.get_object()
qs = qs.filter(ensemble_id=self.request.ensemble_id).exclude(pk__in=work.projects.all())
f.fields['project'].queryset = qs
return f
def form_valid(self, form):
work = self.get_object()
project = form.cleaned_data['project']
work.project_items.create(project=project, approved_by=self.request.user, checkout=now())
return redirect('item_list', project=project.pk)
class WorkPartSetView(EnsembleMixin, DetailView): class WorkPartSetView(EnsembleMixin, DetailView):
template_name = "library/work_partset.html" template_name = "library/work_partset.html"
@ -162,7 +200,7 @@ class WorkPartSetView(EnsembleMixin, DetailView):
return response return response
def get_queryset(self): def get_queryset(self):
return Work.objects.filter(ensemble_id=self.request.ensemble_id) return Version.objects.filter(work__ensemble_id=self.request.ensemble_id)
class DocumentDetailView(EnsembleMixin, DetailView): class DocumentDetailView(EnsembleMixin, DetailView):
@ -192,7 +230,7 @@ class DocumentDownloadView(EnsembleMixin, SingleObjectMixin, View):
return redirect(self.object.upload.url) return redirect(self.object.upload.url)
def get_queryset(self): def get_queryset(self):
return Document.objects.filter(work__ensemble=self.request.ensemble_id) return Document.objects.filter(work__collection__allowed_ensembles__ensemble=self.request.ensemble_id)
class DocumentAnnotateView(EnsembleMixin, DetailView): class DocumentAnnotateView(EnsembleMixin, DetailView):
template_name = 'library/document_annotate.html' template_name = 'library/document_annotate.html'
@ -228,11 +266,11 @@ class DocumentAnnotateView(EnsembleMixin, DetailView):
for i in range(part.start, (part.end or part.start)+1): for i in range(part.start, (part.end or part.start)+1):
pages[i] = part.tag pages[i] = part.tag
data['json_data'] = {'pageTags': pages, 'instruments': data['document'].work.instruments} data['json_data'] = {'pageTags': pages, 'instruments': data['document'].work.orchestration}
return data return data
def get_queryset(self): def get_queryset(self):
return Document.objects.filter(work__ensemble=self.request.ensemble_id).select_related('work') return Document.objects.filter(work__collection__allowed_ensembles__ensemble=self.request.ensemble_id).select_related('work')
class PartDownloadView(EnsembleMixin, SingleObjectMixin, View): class PartDownloadView(EnsembleMixin, SingleObjectMixin, View):
@ -251,4 +289,4 @@ class PartDownloadView(EnsembleMixin, SingleObjectMixin, View):
return response return response
def get_queryset(self): def get_queryset(self):
return Part.objects.filter(doc__work__ensemble=self.request.ensemble_id).select_related('doc', 'doc__work') return Part.objects.filter(doc__work__collection__allowed_ensembles__ensemble=self.request.ensemble_id).select_related('doc', 'doc__work')

View File

@ -84,6 +84,7 @@ DATABASES = {
} }
} }
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
# Password validation # Password validation
# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators

View File

@ -1,14 +1,14 @@
asgiref==3.2.10 asgiref==3.4.1
boto3==1.15.11 boto3==1.18.34
botocore==1.18.11 botocore==1.21.34
Django==3.1.1 Django==3.2.7
django-byostorage @ git+https://gitea.tfconsulting.com.au/tris/django-byostorage.git@c67d636d2457faa57644cd812ca1b5a916e23766
django-markdown2==0.3.1 django-markdown2==0.3.1
jmespath==0.10.0 jmespath==0.10.0
markdown2==2.3.9 markdown2==2.4.1
python-dateutil==2.8.1 python-dateutil==2.8.2
pytz==2020.1 pytz==2021.1
s3transfer==0.3.3 s3transfer==0.5.0
six==1.15.0 six==1.16.0
sqlparse==0.3.1 sqlparse==0.4.1
urllib3==1.25.10 urllib3==1.26.6
git+https://gitea.tfconsulting.com.au/tris/django-byostorage.git@master#egg=django_byostorage