Author SHA1 Message Date
Tris Forster f7aaa98000 Library schema semi-fixed 2022-11-19 21:50:01 +11:00
Tris Forster 8f18b9ab9d Removing old files 2022-11-19 21:30:59 +11:00
Tris 598ee5ad7e Got library sort of working 2021-09-04 10:29:22 +10:00
Tris 7aedc6bd07 Added composer field 2021-03-26 09:27:38 +11:00
Tris f50fe11ced Migration 2021-03-22 11:55:00 +11:00
Tris 12beebd486 Updated byostorage 2021-03-22 10:48:24 +11:00
Tris 9094cae63f Updated byostorage 2021-03-22 10:38:33 +11:00
Tris 1581f56b74 Library working: 2021-03-22 10:30:13 +11:00
Tris 35555c3321 Hooked up library app 2021-03-11 09:33:51 +11:00
Tris 526ae4f59b Created library app 2021-03-11 09:32:44 +11:00
Tris 98fe7b77ce Added slug fields to Ensemble and Project 2021-03-03 21:07:36 +11:00
87 changed files with 3221 additions and 1453 deletions
+4 -1
View File
@@ -6,4 +6,7 @@ polyphonic/settings.py
env
test.*
static
teststore
teststore
cache
local_storage
media
+13 -1
View File
@@ -1,6 +1,18 @@
## Polyphonic
A simple web app for managing video uploads to an S3 bucket for virtual ensembles.
A simple web app managing scores, large files and submissions in a manor tailored to musical ensembles.
No registration required for ensemble participants - just a one time code and passphrase.
### Library App
Store all your scores on your own cloud account (Amazon S3, Google Files etc). Tag up the scores so you can generate
custom part sets and assign them to projects so people can easily print just their parts.
### Submissions App
Accept video/audio submissions direct to your cloud storage. Was developed and used during 2020 lockdown period for
virtual choirs/orchestras but could have more uses.
### S3 Setup
+1
View File
@@ -1 +1,2 @@
pylint==2.6.0
django-debug-toolbar
+7 -9
View File
@@ -1,20 +1,19 @@
from django.contrib import admin
# Register your models here.
from . import models
class EnsembleAdmin(admin.ModelAdmin):
list_display = ['name', 'ensemble_code']
list_display = ['name', 'ensemble_code', 'slug']
class ModuleInline(admin.StackedInline):
model = models.Module
extra = 0
class ProjectAdmin(admin.ModelAdmin):
list_display = ['name', 'ensemble', 'deadline', 'active']
list_display = ['name', 'ensemble', 'event_date', 'active']
list_filter = ['ensemble', 'active']
class SubmissionAdmin(admin.ModelAdmin):
list_display = ['name', 'instrument', 'date', 'complete']
list_filter = ['project', 'complete']
inlines = [ModuleInline]
class ResourceAdmin(admin.ModelAdmin):
list_display = ['name', 'media_type', 'project']
@@ -26,6 +25,5 @@ class WikiPageAdmin(admin.ModelAdmin):
admin.site.register(models.Ensemble, EnsembleAdmin)
admin.site.register(models.Project, ProjectAdmin)
admin.site.register(models.Submission, SubmissionAdmin)
admin.site.register(models.Resource, ResourceAdmin)
admin.site.register(models.WikiPage, WikiPageAdmin)
+4
View File
@@ -0,0 +1,4 @@
from crispy_forms.layout import Field
class BulmaFileUpload(Field):
template = 'bulma/file_upload.html'
+40 -11
View File
@@ -1,17 +1,46 @@
from django import forms
from .models import Submission
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit
class CodeForm(forms.Form):
from . import models, fields
class BaseForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = self.get_form_helper()
def get_form_helper(self):
helper = FormHelper(self)
helper.add_input(Submit('submit', 'Submit', css_class='button is-link'))
return helper
class ProjectForm(forms.ModelForm, BaseForm):
class Meta:
model = models.Project
fields = ['name', 'description', 'event_date']
widgets = {
'event_date': forms.DateTimeInput(attrs={'type': 'date'})
}
class ResourceForm(forms.ModelForm, BaseForm):
class Meta:
model = models.Resource
fields = ['name', 'media_type', 'description', 'file']
def get_form_helper(self):
helper = super().get_form_helper()
helper[3].wrap(fields.BulmaFileUpload)
return helper
class CodeForm(BaseForm):
code = forms.CharField(max_length=14,
widget=forms.TextInput(attrs={'placeholder': 'xxx-xxx-xxx', 'inputmode': 'numeric'}))
passphrase = forms.CharField(max_length=32)
class SubmissionForm(forms.ModelForm):
method = forms.ChoiceField(choices=(
('upload', 'I need to upload a file'),
('link', 'I have a link from my own cloud storage provider')
), initial='upload')
class Meta:
model = Submission
fields = ['name', 'instrument', 'method', 'notes']
class ResourceUploadForm(forms.Form):
pass
# file = S3UploadField()
-40
View File
@@ -1,40 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-04 09:59
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Project',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
],
),
migrations.CreateModel(
name='WikiPage',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('markdown', models.TextField()),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='wiki_pages', to='interface.project')),
],
),
migrations.CreateModel(
name='Submission',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('instrument', models.CharField(max_length=100)),
('notes', models.TextField()),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='interface.project')),
],
),
]
@@ -0,0 +1,82 @@
# Generated by Django 3.2.7 on 2022-11-19 10:25
import byostorage.user
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
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 = [
('byostorage', '0004_alter_userstorage_storage'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Ensemble',
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, 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)')),
('admins', models.ManyToManyField(related_name='ensembles', to=settings.AUTH_USER_MODEL)),
('storage', models.ForeignKey(help_text='Default storage for this ensemble', null=True, on_delete=django.db.models.deletion.SET_NULL, to='byostorage.userstorage')),
],
),
migrations.CreateModel(
name='Project',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('description', models.TextField(blank=True, help_text='Markdown format')),
('active', models.BooleanField(default=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')),
],
options={
'ordering': ['active', '-pk'],
},
),
migrations.CreateModel(
name='WikiPage',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
('markdown', models.TextField()),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='wiki_pages', to='interface.project')),
],
),
migrations.CreateModel(
name='Resource',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('description', models.TextField(blank=True)),
('file', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=interface.models.resource_key)),
('media_type', models.CharField(choices=[('audio', 'Audio'), ('video', 'Video'), ('general', 'General')], default='*', max_length=10)),
('visible', models.BooleanField(default=True)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='resources', to='interface.project')),
],
options={
'ordering': ['-visible', '-pk'],
},
),
migrations.CreateModel(
name='Module',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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')),
],
),
]
@@ -1,31 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-04 10:04
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('interface', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='submission',
name='date',
field=models.DateField(auto_created=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='wikipage',
name='title',
field=models.CharField(default='', max_length=255),
preserve_default=False,
),
migrations.AlterField(
model_name='submission',
name='notes',
field=models.TextField(blank=True),
),
]
@@ -1,33 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-05 01:18
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('interface', '0002_auto_20200904_1004'),
]
operations = [
migrations.CreateModel(
name='Ensemble',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('code', models.CharField(max_length=12)),
('password', models.CharField(max_length=100)),
],
),
migrations.AddField(
model_name='project',
name='active',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='project',
name='ensemble',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='interface.ensemble'),
),
]
@@ -1,34 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-05 01:27
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('interface', '0003_auto_20200905_0118'),
]
operations = [
migrations.AddField(
model_name='project',
name='bucket',
field=models.CharField(default='', max_length=100),
preserve_default=False,
),
migrations.AddField(
model_name='project',
name='deadline',
field=models.DateField(blank=True, null=True),
),
migrations.CreateModel(
name='Resource',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('uri', models.CharField(max_length=255)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='resources', to='interface.project')),
],
),
]
@@ -1,23 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-05 06:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('interface', '0004_auto_20200905_0127'),
]
operations = [
migrations.AddField(
model_name='submission',
name='complete',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='submission',
name='date',
field=models.DateField(auto_now_add=True),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-05 09:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('interface', '0005_auto_20200905_0638'),
]
operations = [
migrations.AddField(
model_name='submission',
name='key',
field=models.CharField(blank=True, max_length=255),
),
]
@@ -1,35 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-06 10:09
from django.db import migrations, models
import django.db.models.deletion
import interface.models
class Migration(migrations.Migration):
dependencies = [
('interface', '0006_submission_key'),
]
operations = [
migrations.RemoveField(
model_name='project',
name='bucket',
),
migrations.AddField(
model_name='ensemble',
name='bucket',
field=models.CharField(default='', max_length=100),
preserve_default=False,
),
migrations.AlterField(
model_name='ensemble',
name='code',
field=models.CharField(default=interface.models.generate_code, max_length=12),
),
migrations.AlterField(
model_name='submission',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='all_submissions', to='interface.project'),
),
]
@@ -1,24 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-06 11:22
from django.db import migrations, models
import interface.models
class Migration(migrations.Migration):
dependencies = [
('interface', '0007_auto_20200906_1009'),
]
operations = [
migrations.RenameField(
model_name='ensemble',
old_name='password',
new_name='passphrase',
),
migrations.AlterField(
model_name='ensemble',
name='code',
field=models.CharField(default=interface.models.generate_code, max_length=9),
),
]
@@ -1,27 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-07 01:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('interface', '0008_auto_20200906_1122'),
]
operations = [
migrations.RemoveField(
model_name='submission',
name='key',
),
migrations.AddField(
model_name='submission',
name='location',
field=models.CharField(blank=True, max_length=512),
),
migrations.AlterField(
model_name='ensemble',
name='bucket',
field=models.CharField(max_length=255),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-07 01:48
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('interface', '0009_auto_20200907_0103'),
]
operations = [
migrations.RenameField(
model_name='submission',
old_name='location',
new_name='key',
),
]
@@ -1,18 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-07 02:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('interface', '0010_auto_20200907_0148'),
]
operations = [
migrations.AlterField(
model_name='submission',
name='date',
field=models.DateTimeField(auto_now_add=True),
),
]
@@ -1,17 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-07 04:53
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('interface', '0011_auto_20200907_0234'),
]
operations = [
migrations.RemoveField(
model_name='ensemble',
name='bucket',
),
]
@@ -1,18 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-07 04:55
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('interface', '0012_remove_ensemble_bucket'),
]
operations = [
migrations.RenameField(
model_name='resource',
old_name='uri',
new_name='key',
),
]
@@ -1,23 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-09 00:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('interface', '0013_auto_20200907_1455'),
]
operations = [
migrations.AddField(
model_name='resource',
name='description',
field=models.TextField(blank=True),
),
migrations.AlterField(
model_name='resource',
name='key',
field=models.CharField(blank=True, max_length=255),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-09 01:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('interface', '0014_auto_20200909_1016'),
]
operations = [
migrations.AddField(
model_name='resource',
name='media_type',
field=models.CharField(choices=[('audio', 'Audio'), ('video', 'Video'), ('*', 'General')], default='*', max_length=10),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-10 10:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('interface', '0015_resource_media_type'),
]
operations = [
migrations.AlterField(
model_name='resource',
name='media_type',
field=models.CharField(choices=[('audio', 'Audio'), ('video', 'Video'), ('general', 'General')], default='*', max_length=10),
),
]
@@ -1,25 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-13 23:43
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('interface', '0016_auto_20200910_2025'),
]
operations = [
migrations.AddField(
model_name='ensemble',
name='admins',
field=models.ManyToManyField(to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='submission',
name='instrument',
field=models.CharField(max_length=100, verbose_name='Instrument / Voice'),
),
]
@@ -1,25 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-14 00:09
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('interface', '0017_auto_20200914_0943'),
]
operations = [
migrations.AddField(
model_name='resource',
name='visible',
field=models.BooleanField(default=True),
),
migrations.AlterField(
model_name='ensemble',
name='admins',
field=models.ManyToManyField(related_name='ensembles', to=settings.AUTH_USER_MODEL),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 3.1.1 on 2020-10-03 09:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('interface', '0018_auto_20200914_1009'),
]
operations = [
migrations.AddField(
model_name='project',
name='owner',
field=models.CharField(blank=True, max_length=255),
),
]
@@ -1,23 +0,0 @@
# Generated by Django 3.1.1 on 2020-10-03 11:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('interface', '0019_project_owner'),
]
operations = [
migrations.RenameField(
model_name='submission',
old_name='key',
new_name='url',
),
migrations.AddField(
model_name='submission',
name='private',
field=models.BooleanField(default=False),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 3.1.1 on 2020-10-05 03:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('interface', '0020_auto_20201003_2103'),
]
operations = [
migrations.AddField(
model_name='project',
name='description',
field=models.TextField(blank=True),
),
]
+84 -73
View File
@@ -4,89 +4,148 @@ from django.utils import timezone
from django.conf import settings
from django.shortcuts import resolve_url
from byostorage.user import BYOStorage
import random
import boto3
from datetime import datetime
from urllib.parse import urlparse
import os.path
s3client = boto3.client('s3', **getattr(settings, 'S3_CREDENTIALS', {}))
BUCKET = settings.AWS_BUCKET
MEDIA_TYPES = [
('audio', "Audio"),
('video', "Video"),
('general', "General"),
]
def rough_date(d):
days = (d - timezone.now()).days
in_past = days < 0
if in_past:
days = abs(days)
if days ==0:
return "today!"
if days >= 7:
return in_past, "{0:d} weeks, {1:d} days".format(int(days / 7), int(days % 7))
return in_past, f"{days} days"
def generate_code(length=9):
return "".join([ random.choice('0123456789') for _ in range(length) ])
class Ensemble(models.Model):
name = models.CharField(max_length=100)
code = models.CharField(max_length=9, default=generate_code)
passphrase = models.CharField(max_length=100)
''' A group that plays together
'''
name = models.CharField(max_length=100,
help_text="Display name")
slug = models.SlugField(max_length=100, editable=False, unique=True,
help_text="Short name for the ensemble - used for folders")
code = models.CharField(max_length=9, default=generate_code,
help_text="Ensemble registration code")
passphrase = models.CharField(max_length=100,
help_text="Used to register ensembles")
admins = models.ManyToManyField('auth.User', related_name='ensembles')
details = models.TextField(blank=True,
help_text="Description of the ensemble (markdown)")
storage = models.ForeignKey('byostorage.UserStorage', null=True, on_delete=models.SET_NULL,
help_text="Default storage for this ensemble")
def active_projects(self):
return self.projects.filter(active=True)
return self.projects.filter(active=True).order_by('event_date')
def ensemble_code(self):
code = str(self.code)
return "{}-{}-{}".format(code[:3], code[3:6], code[6:])
def save(self, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super(Ensemble, self).save(**kwargs)
def __str__(self):
return self.name
class Project(models.Model):
''' A Project linked to an ensemble
'''
name = models.CharField(max_length=100)
ensemble = models.ForeignKey(Ensemble, related_name='projects', on_delete=models.CASCADE, null=True)
description = models.TextField(blank=True)
description = models.TextField(blank=True,
help_text="Markdown format")
active = models.BooleanField(default=True)
deadline =models.DateField(null=True, blank=True)
event_date =models.DateTimeField(null=True, blank=True)
owner = models.CharField(max_length=255, blank=True)
class Meta:
ordering = ['active', '-pk']
@property
def submissions(self):
return self.all_submissions.filter(complete=True).order_by('-pk')
return self.all_submissions.order_by('-pk')
def presigned_post(self, object_name, fields=None, conditions=None, expires=3600):
key = os.path.join(slugify(self.name), object_name)
return s3client.generate_presigned_post(BUCKET, key, Fields=fields or {}, Conditions=conditions or [], ExpiresIn=expires)
@property
def days(self):
return (self.event_date - timezone.now().date()).days
@property
def has_happened(self):
return self.event_date < timezone.now()
@property
def rough_date(self):
in_past, s = rough_date(self.event_date)
if in_past:
return f"{s} ago"
return f"In {s}"
@property
def folder(self):
print(f"{self.ensemble.storage_id}:{self.ensemble.slug}/{self.slug}")
return f"{self.ensemble.storage_id}:{self.ensemble.slug}/{self.slug}"
def __str__(self):
return self.name
class Module(models.Model):
''' Enable modules on a oriject
'''
name = models.SlugField(max_length=20, choices=[ (x, x.title()) for x in settings.POLYPHONIC_MODULES ])
project = models.ForeignKey(Project, related_name="modules", on_delete=models.CASCADE)
def resource_key(resource, filename):
return f'{resource.project.folder}/resources/{filename}'
class Resource(models.Model):
''' A viewable file resource attached to a project
e.g PDF instructions, MP3 backing track
'''
project = models.ForeignKey(Project, related_name='resources', on_delete=models.CASCADE)
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
key = models.CharField(max_length=255, blank=True)
file = models.FileField(storage=BYOStorage(), upload_to=resource_key)
media_type = models.CharField(max_length=10, choices=MEDIA_TYPES, default='*')
visible = models.BooleanField(default=True)
def key_template(self):
return "{}/${{filename}}".format(slugify(self.name))
class Meta:
ordering = ['-visible', '-pk']
def accept(self):
if self.media_type == 'general':
return ".*"
return f"{self.media_type}/*"
def presigned_url(self):
if not self.key:
return ""
params = {'Bucket': BUCKET, 'Key': self.key}
return s3client.generate_presigned_url('get_object', Params=params, ExpiresIn=3600*24)
def __str__(self):
return self.name
class WikiPage(models.Model):
''' An editable wiki page for the project in markdown format
'''
project = models.ForeignKey(Project, related_name='wiki_pages', on_delete=models.CASCADE)
title = models.CharField(max_length=255)
markdown = models.TextField()
@@ -95,52 +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='all_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
+35 -251
View File
@@ -1,279 +1,63 @@
@font-face {
font-family: MartinHand;
src: url('/static/fonts/Martinhand3.ttf');
}
:root {
--border-color: #292929;
--gray-blue: #667788;
--light-blue: #c5eff7;
--primary: #485fc7;
}
@font-face {
font-family: 'Quicksand';
src: url('../../fonts/Quicksand_Book.otf');
.fancy {
font-family: MartinHand;
color: var(--primary);
}
@font-face {
font-family: 'QuicksandBold';
src: url('../../fonts/Quicksand_Bold_Oblique.otf');
.has-text-shadow {
text-shadow: 1px 1px 2px #000;
}
.debug DIV {
border: 1px dashed #DDD;
.is-form-group {
max-width: 600px;
align-self: center;
}
HTML {
height: 100%;
.is-centered {
margin: auto;
}
BODY {
background-image: url('../background.png');
background-position: center top;
background-size: 100%;
background-repeat: no-repeat;
margin: 0px;
height: 100%;
display: flex;
align-items: flex-start;
justify-content: center;
}
.main {
max-width: 1000px;
margin: 10px auto;
border: 1px solid var(--border-color);
border-radius: 5px;
font-family: 'Quicksand', Arial, Helvetica, sans-serif;
font-size: 14pt;
background-color: white;
}
.content {
margin: 20px;
flex-direction: column;
}
.narrow {
max-width: 500px;
margin: 0px auto;
}
.collapse {
display: flex;
flex-direction: row;
justify-content: space-around;
}
@media all and (max-width: 900px) {
.mdhide {
display: none;
}
}
@media all and (max-width: 700px) {
.smhide {
display: none;
}
.collapse {
flex-direction: column;
}
}
/* HEADER BAR */
.navigation {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 50px;
background-color: var(--gray-blue);
color: var(--light-blue) !important;
}
.navigation > * {
white-space: nowrap;
}
.navigation A,
.navigation A:visited {
color: var(--light-blue);
text-decoration: none;
}
.navigation .brand {
font-family: 'QuicksandBold', 'Quicksand', Arial, Helvetica, sans-serif;
font-size: 1.5rem;
margin: auto 20px;
}
UL.nav-buttons {
display: flex;
list-style: none;
}
UL.nav-buttons > LI {
margin: 2px 10px;
}
/* FORMS */
FORM.vertical {
display: flex;
flex-direction: column;
max-width: 400px;
margin: 0px auto;
}
LABEL {
margin-top: 20px;
}
TEXTAREA {
height: 50px;
}
INPUT[type=checkbox] {
margin-right: auto;
}
.form-actions {
text-align: right;
margin-top: 20px;
}
.btn {
background-color: var(--gray-blue);
display: inline-block;
border: none;
color: var(--light-blue);
text-decoration: none;
border-radius: 1em;
font-size: 1em;
}
.btn:hover {
.is-action {
cursor: pointer;
color: white;
}
.pills {
display: flex;
flex-wrap: wrap;
justify-content: center;
margin-top: 40px;
.menu-label {
color: var(--primary);
}
.pills A {
border: 1px solid var(--gray-blue);
padding: 4px 10px 2px 10px;
margin: 10px 5px;
border-radius: 10px;
white-space: nowrap;
.button.is-primary, .button.is-primary:hover {
background-color: var(--primary);
}
.pills A:hover {
background-color: var(--light-blue);
text-decoration: none
A.admin-link:after {
content: "*";
}
.list-group {
display: flex;
flex-direction: column;
TEXTAREA.input {
height: 400px;
}
.list-group > * {
border: 1px solid var(--gray-blue);
border-radius: 10px;
padding: 2px 20px;
margin-top: 20px;
}
.list-group > A:hover {
background-color: var(--light-blue);
text-decoration: none;
}
/* PROGRESS BAR */
.progress {
display: relative;
border: 1px solid var(--border-color);
border-radius: 5px;
margin: 20px 10px;
}
.progress-bar {
width: 0%;
height: 1.5em;
background-color: var(--light-blue);
border-radius: 5px;
}
.text-center {
.control INPUT[type='file'] {
border: none;
width: 80%;
text-align: center;
margin: auto 10%;
}
A, A:visited {
text-decoration: none;
color: var(--gray-blue);
font-weight: bold;
}
A:hover {
text-decoration: underline;
}
H1 {
text-align: center;
}
TD {
padding: 5px;
}
TABLE.horizontal TH {
text-align: right;
}
TABLE.horizontal TD,
TABLE.horizontal TH {
padding: 5px;
}
.resource-player {
width: 100%;
border-radius: 10px;
max-width: 640px;
max-height: 640px;
margin: 5px;
border: 1px solid var(--border-color);
}
.dz-clickable {
text-align: center;
}
.scrollable {
max-height: 200px;
overflow: auto;
.project-footer {
position: fixed;
bottom: 0px;
right: 0px;
background-color: #EEE;
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 5px;
}
.admin-tools {
float: right;
padding: 10pt;
}
.disabled {
background-color: #DDD;
}
.dz-image {
width: 240px !important;
}
.dz-progress {
width: 200px !important;
margin-left: -100px !important;
margin-top: 24px !important;
padding: 10px 10px;
margin-top: 30px;
border-top-left-radius: 6px;
}
+36 -35
View File
@@ -1,49 +1,50 @@
{% load static %}
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="{% static 'interface/icon.png' %}" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css">
<link rel="stylesheet" href="{% static 'interface/css/polyphonic.css' %}"></link>
<script src="{% static 'interface/js/interface.js' %}"></script>
<title>{% block title %}Polyphonic{% endblock %}</title>
{% block media %}{% endblock %}
</head>
<body>
<div class="main">
{% block navigation %}
<nav class="navigation">
<div>
<a class="brand" href="/"><i class="fas fa-random smhide"></i> Polyphonic</a>
<span class="mdhide">Virtual Ensemble Manager</span>
</div>
<ul class="nav-buttons">
{% if request.ensemble_id %}
<li class="nav-item">
<a class="nav-link" href="{% url 'ensemble_detail' %}"><i class="fas fa-music" title="Projects"></i> <span class="smhide">My
Projects</span></a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'register' %}"><i class="fas fa-users" title="Ensembles"></i> <span class="smhide">My
Ensembles</span></a>
</li>
{% endif %}
{% if request.is_admin %}
<li class="nav-item">
<a href="{% url 'manage' %}"><i class="fas fa-user-lock" title="Admin"></i></a>
</li>
{% endif %}
</ul>
</nav>
{% endblock %}
<div class="content">
{% block content %}
<h1>No content!</h1>
{% endblock %}
{% block navigation %}
<nav class="navbar" role="navigation">
<div class="navbar-brand has-text-primary">
<a class="navbar-item" href="/">
<span class="icon fancy is-size-2 mx-4"><i class="fas fa-random"></i></span>
<span class="fancy is-size-2">Polyphonic</span>
</a>
<span class="navbar-item is-hidden-mobile fancy is-size-5">Music Ensemble Manager</span>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="projectMenu">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
</div>
<div id="navbarMain" class="navbar-menu">
<div class="navbar-end">
<span class="navbar-item is-size-4">{% firstof ensemble project.ensemble %}</span>
</div>
</div>
</nav>
{% endblock %}
{% block content %}
<h1>No content!</h1>
{% endblock %}
<!-- late load scripts -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
@@ -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>
@@ -1,14 +1,15 @@
{% extends "interface/project_base.html" %}
{% load crispy_forms_tags %}
{% block media %}
{{ form.media }}
{% endblock %}
{% block page %}
<div>
<h3>{{ title }}</h3>
<form class="vertical" method="POST">
{% csrf_token %}
{{ form }}
<div class="form-actions">
<button>Save</button>
</div>
</form>
<h3 class="subtitle">{% firstof title view.title %}</h3>
<div class="columns is-centered">
<div class="column is-form-group">
{% crispy form %}
</div>
</div>
{% endblock %}
@@ -1,18 +1,12 @@
{% extends "base.html" %}
{% block content %}
<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' pk=project.id %}">
<h3>{{ project.name }}</h3>
<p><small>Due in {{ project.deadline|timeuntil }}, {{ project.submissions.count }} submissions.</small></p>
</a>
<h2>{{ ensemble.name }}</h2>
<p>{{ ensemble.details }}</p>
<h4>Administrators</h4>
<ul>
{% for admin in ensemble.admins.all %}
<li><a href="mailto:{{ admin.email }}">{% firstof admin.get_full_name admin.get_username %}</a></li>
{% endfor %}
</div>
<div style="text-align: right; margin-top: 10px; color: #999;">
<small>{{ ensemble.ensemble_code }}</small>
</div>
</div>
{% endblock %}
</ul>
{% endblock %}
@@ -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 %}
@@ -0,0 +1,73 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% block admin %}
<a href="{% url 'project_create' %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add new</span>
</a>
{% endblock %}
{% block page %}
<h3 class="title">Projects for {{ ensemble.name }}</h3>
<div class="columns is-multiline">
{% for project in ensemble.active_projects %}
<div class="column is-half">
<div class="card">
<a class="" href="{% url 'project_detail' project=project.id %}">
<header class="card-header">
<p class="card-header-title">{{ project.name }}</p>
<p class="card-header-icon" style="color: black;">{{ project.rough_date }}</p>
</header>
</a>
<div class="card-content">
<div class="content">
{{ project.description | markdown }}
</div>
<p><small>
{% if project.deadline %}In {{ project.deadline|timeuntil }}<br/>{% endif %}
{% if project.works.count %}
<a href="{% url 'item_list' project=project.pk %}">
{{ project.works.count }} works
</a>
<br/>
{% endif %}
{% if project.submissions.count %}{{ project.submissions.count }} submissions<br/>{% endif %}
</small></p>
</div>
</div>
</div>
{% empty %}
<div class="hero">
<div class="hero-body">
<p class="title">No projects currently planned</p>
<p class="subtitle">Go put your feet up!</p>
</div>
</div>
{% endfor %}
</div>
{% if request.is_admin %}
<div class="">
<div class="card">
<header class="card header">
<p class="card-header-title">Admin Details</p>
</header>
<div class="card-content">
<p>
Joining instructions for participants<br/><br/>
URL: <a href="{{ ensemble_url }}">{{ ensemble_url }}</a><br/>
Code: {{ ensemble.ensemble_code }}<br/>
Passphrase: {{ ensemble.passphrase }}
</p>
</div>
</div>
</div>
{% endif %}
{% endblock %}
@@ -8,6 +8,9 @@
URL: <a href="{{ ensemble_url }}">{{ ensemble_url }}</a><br/>
Passphrase: {{ ensemble.passphrase }}
</p>
<ul>
<li><a href="{% url 'work_list' %}">Library</a></li>
</ul>
<p>
Sorry, not much you can do here yet.
<ul>
+101 -22
View File
@@ -2,30 +2,109 @@
{% block content %}
{% if request.is_admin %}
<div class="admin-tools">
{% block admin %}
{% endblock %}
</div>
{% endif %}
<h1>{{ project.name }}</h1>
{% block page %}
No content
{% endblock %}
<div class="project-links">
<div class="pills" role="tablist">
<a role="tab" href="{% url 'project_detail' pk=project.id %}">Project info</a>
{% for page in project.wiki_pages.all %}
<a class="nav-link {% if page.id == wiki_id %}active{% endif %}"
href="{% url 'wiki' project=project.id pk=page.id %}">{{ page.title }}</a>
{% endfor %}
<a role="tab" href="{% url 'resource_list' project=project.pk %}">Resources</a>
<!--a role="tab" href="">Record a submission</a-->
{% if request.is_admin %}
<a role="tab" href="{% url 'submission_list' project=project.id %}">Submissions</a>
<div class="columns">
<div class="column is-narrow is-hidden-touch" id="projectMenu">
<div style="margin: auto 2em;">
<aside class="menu">
<p class="menu-label">My Things</p>
<ul class="menu-list">
<li><a href="{% url 'ensemble_list' %}">Ensembles</a></li>
<li><a href="{% url 'ensemble_detail' %}">Projects</a></li>
<li><a href="{% url 'work_list' %}">Library</a></li>
</ul>
{% if project %}
<p class="menu-label">This Project</p>
<ul class="menu-list">
<li><a role="tab" href="{% url 'project_detail' project=project.id %}">Project Info</a></li>
{% for page in project.wiki_pages.all %}
<li><a class="nav-link {% if page.id == wiki_id %}active{% endif %}"
href="{% url 'wiki' project=project.id pk=page.id %}">{{ page.title }}</a></li>
{% endfor %}
<li><a role="tab" href="{% url 'resource_list' project=project.pk %}">Resources</a></li>
{% if 'library' in modules %}
<li><a href="{% url 'item_list' project=project.id %}">My Music</a></li>
{% endif %}
{% if 'submission' in modules %}
<!--a role="tab" href="">Record a submission</a-->
{% if request.is_admin %}
<li><a role="tab" class="admin-link" href="{% url 'submission_list' project=project.id %}">Submissions</a></li>
{% endif %}
<li><a role="tab" href="{% url 'submission_create' project=project.id %}">Send a File</a></li>
{% endif %}
</ul>
{% endif %}
<p class="menu-label">Admin</p>
{% if request.is_admin %}
<ul class="menu-list">
<li><a class="admin-link" href="{% url 'collection_list' %}">Collections</a></li>
{% if request.user.is_superuser %}
<li><a class="admin-link" href="/admin" target="polyphonic_admin" rel="noopener noreferrer">Django Admin</a></li>
{% endif %}
</ul>
{% endif %}
<ul class="menu-list">
{% if request.user.is_authenticated %}
<li><a href="{% url 'logout' %}">Logout</a></li>
{% else %}
<li><a href="{% url 'login' %}">Login</a></li>
{% endif %}
</ul>
</aside>
</div>
</div>
<div class="column">
{% if project %}
<div class="tabs is-centered is-hidden-desktop">
<ul>
<li><a href="">Info</a></li>
{% if project.wiki_pages.count %}
<li><a href="">Pages</a></li>
{% endif %}
{% if project.resources.count %}
<li><a href="{% url 'resource_list' project=project.pk %}">Resources</a></li>
{% endif %}
{% if project.enable_library %}
<li><a href="{% url 'item_list' project=project.pk %}">My Music</a></li>
{% endif %}
{% if project.enable_submissions %}
<li><a href="{% url 'submission_create' project=project.pk %}">Send File</a></li>
{% endif %}
</ul>
</div>
{% endif %}
<a role="tab" href="{% url 'submission_create' project=project.id %}">Send a file</a>
<section class="section">
{% if request.is_admin %}
<div class="admin-tools is-pulled-right">
{% block admin %}
{% endblock %}
</div>
{% endif %}
{% if project %}<h3 class="title">{{ project.name }}</h3>{% endif %}
{% block page %}
No content
{% endblock %}
</section>
</div>
</div>
{% if ensemble %}
<div class="project-footer">
{{ ensemble.name }}
</div>
{% endif %}
{% endblock %}
@@ -1,29 +1,62 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% load polyphonic %}
{% block admin %}
<a href="{% url 'wiki_create' project=project.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-file"></i></span>
<span>Add Page</span>
</a>
<a href="{% url 'project_edit' project=project.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-edit"></i></span>
<span>Edit</span>
</a>
{% endblock %}
{% block page %}
<div class="narrow">
<h3 class="text-center">Due in {{ project.deadline|timeuntil }}!</h3>
<p>{{ project.description|markdown }}</p>
{% if project.owner %}
<p>Project email: <a href="mailto:{{ project.owner }}">{{ project.owner }}</a></p>
{% block page %}
<div class="box">
{% if project.event_date %}
<h3 class="subtitle is-centered">
<b>{{ project.event_date|date:"l jS F Y, g:i A" }}</b>
{% if project.has_happened %}
({{ project.event_date|roughtimesince }} ago)
{% else %}
(in {{ project.event_date|roughtimeuntil }}...)
{% endif %}
</h3>
{% endif %}
{% with sub_count=project.submissions.count %}
<p>There have been {{ sub_count }} submission{{ sub_count|pluralize }} so far...</p>
{% if sub_count %}
<h4>Recent submissions</h4>
<table style="width: 100%">
<tbody">
{% for submission in project.submissions|slice:":5" %}
<tr>
<td>{{ submission.date|timesince }} ago</td>
<td>{{ submission.name }} ({{ submission.instrument }})</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endwith %}
<div class="block">
{% if project.description %}
<p class="content">{{ project.description|markdown }}</p>
{% else %}
<p>No description</p>
{% endif %}
</div>
{% endblock %}
{% if 'library' in modules %}
<div class="block">
{% include 'library/project_detail.html' %}
</div>
{% endif %}
{% if 'submission' in modules %}
<div class="block">
{% include 'submissions/project_detail.html' %}
</div>
{% endif %}
{% if project.owner %}
<div class="block">
{% if project.owner.email %}
The project owner is <a href="mailto:{{ project.owner.email }}">{{ project.owner }}</a>
{% else %}
The project owner is {{ project.owner }}.
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
+13 -10
View File
@@ -1,15 +1,18 @@
{% extends "interface/project_base.html" %}
{% load crispy_forms_tags %}
{% block media %}
{{ form.media }}
{% endblock %}
{% block page %}
<div class="narrow">
<h3>{{ title }}</h3>
<p>{{ instructions }}</p>
<form class="vertical" method="POST">
{% csrf_token %}
{{ form }}
<div class="form-actions">
<button>Save</button>
</div>
</form>
<h3 class="subtitle">{% firstof title view.title %}</h3>
<div class="columns is-centered">
<div class="column is-form-group">
{% if instructions %}
<p>{{ instructions }}</p>
{% endif %}
{% crispy form %}
</div>
</div>
{% endblock %}
@@ -1,11 +0,0 @@
ALL = {{ targets|join:" " }}
-include "local.mk"
all: ${ALL}
{% for s in submissions %}
{{ s.name }}:
curl -o $@ -L {{ s.url }}
{% endfor %}
+15 -20
View File
@@ -1,29 +1,24 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<section class="section">
{% if not request.user.is_authenticated %}
<a href="{% url 'login' %}" style="float: right"><i class="fa fa-key"></i></a>
{% endif %}
<div class="collapse">
{% if current %}
<div>
<h3>My Ensembles</h3>
<ul>
{% for ensemble in current %}
<li><a href="/?code={{ ensemble.ensemble_code}}">{{ ensemble.name }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
<div>
<form action="" class="vertical" method="POST">
<h3>Join an ensemble</h3>
{% csrf_token %}
{{ form }}
<div class="form-actions">
<button class="btn btn-primary">Enter</button>
<div class="columns is-centered">
<div class="box is-half">
<h3 class="title">Join an ensemble</h3>
<form action="" method="POST">
{% csrf_token %}
{{ form | crispy }}
<div class="field is-grouped">
<div class="control">
<button class="button is-link">Register</button>
</div>
</form>
</div>
</div>
</form>
</div>
</div>
</section>
{% endblock %}
@@ -2,34 +2,40 @@
{% load md2 %}
{% block admin %}
<a href="{% url 'resource_create' project=project.pk %}"><i class="fas fa-plus-circle"></i> Add new</a>
<a class="button is-link" href="{% url 'resource_create' project=project.pk %}">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add new</span>
</a>
{% endblock %}
{% block page %}
<div class="narrow">
<h3>Resources</h3>
<div class="list-group narrow">
{% for resource in object_list %}
{% with download=resource.presigned_url %}
<div>
{% if request.is_admin %}
<div class="admin-tools">
<a href="{% url 'resource_upload' project=project.pk pk=resource.pk %}">
<i class="fas fa-upload"></i>
</a>
<a href="{% url 'resource_edit' project=project.pk pk=resource.pk %}">
<i class="fas fa-edit"></i>
</a>
{% block page %}
<div class="columns is-multiline">
{% for resource in object_list %}
{% with download=resource.file.url %}
<div class="column is-half">
<div class="card {% if not object.visible %}disabled{% endif %}">
<div class="card-header">
<div class="card-header-title">
{% if download %}
<a href="{{ download }}">{{ resource.name }}</a>
{% else %}
{{ resource.name }}
{% endif %}
</div>
<div class="card-header-icon">
{% if request.is_admin %}
<a href="{% url 'resource_upload' project=project.pk pk=resource.pk %}" class="icon" title="Upload">
<i class="fas fa-upload"></i>
</a>
<a href="{% url 'resource_edit' project=project.pk pk=resource.pk %}" class="icon" title="Edit">
<i class="fas fa-edit"></i>
</a>
{% endif %}
</div>
</div>
{% endif %}
<h3>
{{ resource.name }}
{% if download %}
<small><a href="{{ download }}" target="_blank" rel="noopener noreferrer">
<i class="fas fa-download"></i> Download
</a></small>
{% endif %}
</h3>
<div class="card-content">
<p>
<small>{{ resource.description|markdown }}</small>
{% if not resource.visible %}
@@ -37,11 +43,16 @@
{% endif %}
</p>
{% if download and resource.media_type == 'audio' %}
<audio class="resource-player" controls src="{{ download }}"></audio>
<audio class="resource-player" controls src="{{ download }}" style="width: 100%;"></audio>
{% endif %}
</div>
</div>
{% endwith %}
{% endfor %}
</div>
{% endwith %}
{% empty %}
<div class="column">
<p>There are resources for this project</p>
</div>
{% endfor %}
</div>
{% endblock %}
@@ -1,24 +0,0 @@
{% extends "interface/project_base.html" %}
{% block page %}
<div class="narrow">
<h3>Excellent, you are ready to make a submission!</h3>
<p>
Please enter some basic information so we can identify your submission and
note anything that might be relevant.<br/>
Most people will want to upload their
file directly but if you have your own cloud storage provider you can send a
public link to your submission instead.
</p>
</div>
<div>
<form class="vertical" action="" method="POST" enctype="multipart/form-data">
{% csrf_token %}
{{ form }}
<div class="form-actions">
<a href="{% url 'project_detail' project.pk %}">Cancel</a>
<button type="submit" class="btn-primary">Continue</button>
</div>
</form>
</div>
{% endblock %}
@@ -1,17 +0,0 @@
{% extends "interface/project_base.html" %}
{% block page %}
<div class="narrow">
<h3>Thankyou for your submission!</h3>
<table class="horizontal">
<tbody>
<tr><th>From:</th><td>{{ submission.name }}</td></tr>
<tr><th>Instrument:</th><td>{{ submission.instrument }}</td></tr>
<tr><th>Notes:</th><td>{{ submission.notes }}</td></tr>
{% if can_download %}
<tr><th>Download:</th><td><a href="{{ submission.download_url }}" target="_blank" rel="noopener noreferrer">{{ submission.download_name }}</a></td></tr>
{% endif %}
</tbody>
</table>
</div>
{% endblock %}
@@ -1,18 +0,0 @@
{% extends "interface/project_base.html" %}
{% block page %}
<div class="narrow">
<h3>Link to cloud storage</h3>
<p>Please paste the full link from your storage provider</p>
</div>
<div>
<form class="vertical" action="" method="POST" enctype="multipart/form-data">
{% csrf_token %}
{{ form }}
<div class="form-actions">
<a href="{{ cancel_url }}">Cancel</a>
<button type="submit" class="btn-primary">Continue</button>
</div>
</form>
</div>
{% endblock %}
@@ -1,36 +0,0 @@
{% extends "interface/project_base.html" %}
{% block page %}
<div class="admin-tools">
<a href="{{ signed_url }}">
<i class="fas fa-list"></i>
</a>
</div>
<table style="max-width: 800px; margin: 10pt auto;">
<thead>
<tr>
<th>Date</th><th>Time</th><th>Name</th><th>Instrument</th><th></th></tr>
</tr>
</thead>
<tbody>
{% for submission in object_list %}
<tr>
<td>{{ submission.date.date }}</td>
<td>{{ submission.date.time }}</td>
<td>{{ submission.name }}</td>
<td>{{ submission.instrument }}</td>
<td>
<a href="{% url 'submission_detail' project=project.pk pk=submission.pk %}"><i class="fas fa-info-circle" title="Info"></i></a>
{% if submission.private %}
<i style="color: #999" class="fas fa-video" title="No preview available"></i>
{% else %}
<a href="{% url 'submission_preview' project=project.pk pk=submission.pk %}"><i class="fas fa-video" title="Preview"></i></a>
{% endif %}
<a href="{{ submission.download_url }}" target="_blank" rel="noopener noreferrer"><i class="fas fa-save" title="Download"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
@@ -1,17 +0,0 @@
{% extends "interface/project_base.html" %}
{% block page %}
<div class="text-center">
<div style="text-align: right">
<a href="{% url 'submission_list' project=project.pk %}"><i class="fas fa-arrow-left"></i> Back</a>
</div>
{% with object.download_url as url %}
<video class="resource-player" src="{{ url }}" controls></video>
<p style="text-align: right">
<b>{{ object.name }}</b> ({{ object.instrument }})
<small>{{ object.date }}</small>
<a href="{{ url }}" target="_blank" rel="noopener noreferrer" download><i class="fas fa-save"></i> Download</a>
</p>
{% endwith %}
</div>
{% endblock %}
+6 -2
View File
@@ -1,11 +1,15 @@
{% extends "interface/project_base.html" %}
{% block admin %}
<a href="{% url 'wiki_edit' project=project.pk pk=object.pk %}" class="admin-tool"><i class="fas fa-edit"></i></a>
<a href="{% url 'wiki_edit' project=project.pk pk=wikipage.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-edit"></i></span>
<span>Edit</span>
</a>
{% endblock %}
{% block page %}
<div class="wiki-page">
<h3 class="subtitle">{{ wikipage.title }}</h3>
<div class="box content wiki-page">
{{ wiki_html|safe }}
</div>
{% endblock %}
@@ -1,4 +1,5 @@
{% extends "interface/project_base.html" %}
{% load crispy_forms_tags %}
{% block page %}
<style>
@@ -14,10 +15,7 @@ FORM.vertical {
<p>{{ instructions }}</p>
<form class="vertical" method="POST">
{% csrf_token %}
{{ form }}
<div class="form-actions">
<button>Save</button>
</div>
{{ form | crispy }}
</form>
</div>
{% endblock %}
+13 -5
View File
@@ -1,17 +1,25 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="narrow">
<p style="text-align: center">
<section class="section">
<div class="columns is-centered">
<div class="box is-half">
<p class="block">
Login is only required to administer a project.<br/>
If you have an ensemble code <a href="{% url 'register' %}">enter it here</a> instead.
</p>
<form method="POST" class="vertical">
{% csrf_token %}
{{ form }}
<div class="form-actions">
<button type="submit">Login</button>
{{ form | crispy }}
<div class="field is-grouped">
<div class="control">
<button class="button is-link">Login</button>
<a href="{% url 'ensemble_detail' %}" class="button is-light">Cancel</a>
</div>
</div>
</form>
</div>
</div>
</section>
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
from django import template
import os.path
register = template.Library()
def basename(value):
return os.path.basename(value)
register.filter('basename', basename)
+14
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)
+10
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()
+6
View File
@@ -0,0 +1,6 @@
from django.test import TestCase
class IntegrationTestCase(TestCase):
def test_runs(self):
self.assertTrue(True)
-53
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)
+11 -16
View File
@@ -8,28 +8,23 @@ urlpatterns = [
path('login', auth_views.LoginView.as_view(), name='login'),
path('logout', views.logout, name='logout'),
path('register', views.register, name="register"),
path('manage', views.ManageView.as_view(), name="manage"),
path('', views.EnsembleDetailView.as_view(), name='ensemble_detail'),
path('projects/<int:pk>', views.ProjectDetailView.as_view(), name="project_detail"),
path('projects/<int:pk>/submissions.mk', views.ProjectMakefileView.as_view(), name="project_makefile"),
path('', views.EnsembleProjectListView.as_view(), name='ensemble_detail'),
path('ensembles', views.EnsembleListView.as_view(), name="ensemble_list"),
path('ensembles/<int:pk>', views.EnsembleDetailView.as_view(), name='ensemble_detail'),
path('ensembles/<int:pk>/forget', views.EnsembleForgetView.as_view(), name='ensemble_forget'),
path('projects/create', views.ProjectCreateView.as_view(), name="project_create"),
path('projects/<int:project>', views.ProjectDetailView.as_view(), name="project_detail"),
path('projects/<int:project>/edit', views.ProjectUpdateView.as_view(), name="project_edit"),
#path('projects/<int:project>/submissions.mk', views.ProjectMakefileView.as_view(), name="project_makefile"),
path('projects/<int:project>/page/create', views.WikiCreateView.as_view(), name="wiki_create"),
path('projects/<int:project>/page/<int:pk>', views.WikiView.as_view(), name="wiki"),
path('projects/<int:project>/page/<int:pk>/edit', views.WikiEditView.as_view(), name="wiki_edit"),
path('projects/<int:project>/submission', views.SubmissionCreateView.as_view(), name="submission_create"),
path('projects/<int:project>/submission/<int:pk>', views.SubmissionDetailView.as_view(), name="submission_detail"),
path('projects/<int:project>/submission/<int:pk>/preview', views.SubmissionPreview.as_view(), name="submission_preview"),
path('projects/<int:project>/submission/<int:pk>/link', views.SubmissionLinkView.as_view(), name="submission_link"),
path('projects/<int:project>/submission/<int:pk>/upload', views.SubmissionUploadView.as_view(), name="submission_upload"),
path('projects/<int:project>/submission/<int:pk>/cancel', views.SubmissionCancelView.as_view(), name="submission_cancel"),
path('projects/<int:project>/submission/<int:pk>/complete', views.SubmissionCompleteView.as_view(), name="submission_complete"),
path('projects/<int:project>/submission/<int:pk>/download', views.SubmissionDownloadView.as_view(), name="submission_download"),
path('projects/<int:project>/submissions', views.SubmissionListView.as_view(), name="submission_list"),
path('projects/<int:project>/resources', views.ResourceListView.as_view(), name="resource_list"),
path('projects/<int:project>/resources/add', views.ResourceCreateView.as_view(), name="resource_create"),
path('projects/<int:project>/resources/<int:pk>', views.ResourceUploadView.as_view(), name="resource_upload"),
path('projects/<int:project>/resources/<int:pk>/upload', views.ResourceUploadView.as_view(), name="resource_upload"),
path('projects/<int:project>/resources/<int:pk>/edit', views.ResourceEditView.as_view(), name="resource_edit"),
path('projects/<int:project>/resources/<int:pk>/complete', views.ResourceCompleteView.as_view(), name="resource_complete"),
]
+134 -225
View File
@@ -1,23 +1,16 @@
from django.shortcuts import render, get_object_or_404, redirect, resolve_url
from django.views.generic import TemplateView, View, RedirectView
from django.views.generic.detail import DetailView, SingleObjectMixin
from django.views.generic import TemplateView, RedirectView
from django.views.generic.detail import DetailView
from django.views.generic.list import ListView
from django.views.generic.edit import CreateView, UpdateView, FormView
from django.views.generic.base import ContextMixin
from django.http import HttpResponseRedirect
from django.views.generic.edit import CreateView, UpdateView
from django.core.exceptions import SuspiciousOperation
from django.core.signing import Signer
from django.contrib import auth
from markdown2 import markdown
from datetime import datetime
from urllib.parse import urlparse, urlencode
import os.path
from . import models, forms
from base64 import b64decode
import logging
logger = logging.getLogger(__name__)
@@ -36,6 +29,11 @@ class EnsembleMixin(object):
request.ensemble_id = request.session.get('ensemble')
request.is_admin = request.user.is_superuser
if request.is_admin:
if request.ensemble_id is None:
return redirect('ensemble_list')
return super().dispatch(request, *args, **kwargs)
if 'auth' in request.GET:
sig = signer.sign(request.path)
if sig[len(request.path)+1:] == request.GET['auth']:
@@ -45,21 +43,30 @@ class EnsembleMixin(object):
else:
raise SuspiciousOperation("Bad auth code")
if not request.ensemble_id:
return redirect('register')
if not request.is_admin and request.user.is_authenticated:
if request.user.is_authenticated:
try:
request.user.ensembles.get(pk=request.ensemble_id)
request.is_admin = True
except models.Ensemble.DoesNotExist:
pass
if not request.ensemble_id:
return redirect('register')
if self.admin_required and not request.is_admin:
return redirect('login')
return super().dispatch(request, *args, **kwargs)
@property
def ensemble(self):
return models.Ensemble.objects.get(pk=self.request.ensemble_id)
#def get_context_data(self, **kwargs):
# context = super().get_context_data(**kwargs)
# context['ensemble'] = self.ensemble
# return context
class ProjectMixin(EnsembleMixin):
def get_project(self):
@@ -77,57 +84,9 @@ class ProjectMixin(EnsembleMixin):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['project'] = self.get_project()
context['modules'] = context['project'].modules.values_list('name', flat=True)
return context
class S3UploadMixin(ProjectMixin):
accept_files = ''
def get_accept_files(self):
return self.accept_files
def get_cancel_url(self):
return self.cancel_url
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
success_url = self.request.build_absolute_uri(self.get_success_url())
key_template = self.object.key_template()
project = self.get_project()
context['upload'] = project.presigned_post(key_template,
fields={'success_action_redirect': success_url},
conditions=[["starts-with", "$success_action_redirect", ""]])
context['ajax_upload'] = project.presigned_post(key_template)
context['success_url'] = success_url
context['cancel_url'] = self.get_cancel_url()
context['accept_files'] = self.accept_files
return context
class S3CompleteView(SingleObjectMixin, RedirectView):
def complete(self, key):
self.object.key = key
self.object.save()
def get(self, request, *args, **kwargs):
self.object = self.get_object()
if 'key' in request.GET:
self.complete(request.GET['key'])
elif 'location' in request.GET:
uri = urlparse(request.GET['location'])
bucket, key = uri.path[1:].split('/', 1)
if bucket != models.BUCKET:
key = uri.path[1:]
self.complete(key)
else:
raise KeyError("No key or location found")
return super().get(request, *args, **kwargs)
def register(request):
if 'clear' in request.GET:
@@ -135,16 +94,13 @@ def register(request):
request.ensemble_id = request.session.get('ensemble')
registered = request.session.setdefault('registered', {})
registered = request.session.setdefault('registered', [])
code = request.GET.get('code', '').replace('-', '')
print("Registering with code %s", code)
# check if already joined
if code in registered:
request.session['ensemble'] = registered[code]
return redirect('ensemble_detail')
if request.user.is_superuser and code:
if code in registered or request.user.is_superuser:
request.session['ensemble'] = models.Ensemble.objects.get(code=code).pk
return redirect('ensemble_detail')
@@ -158,7 +114,7 @@ def register(request):
ensemble = models.Ensemble.objects.get(code=data['code'].replace('-', ''))
if ensemble.passphrase.lower() == data['passphrase'].lower():
request.session['ensemble'] = ensemble.pk
registered[ensemble.code] = ensemble.pk
registered.append(ensemble.code)
return redirect('ensemble_detail')
except models.Ensemble.DoesNotExist:
form.add_error(None, "Incorrect code or passphrase")
@@ -171,18 +127,17 @@ def register(request):
if request.user.is_superuser:
current = models.Ensemble.objects.all()
else:
current = models.Ensemble.objects.filter(pk__in=registered.values())
current = models.Ensemble.objects.filter(pk__in=registered)
return render(request, 'interface/register.html', {'form': form, 'current': current})
def on_login(sender, **kwargs):
user = kwargs['user']
request = kwargs['request']
registered = request.session.get('registered', {})
registered = request.session.get('registered', [])
for e in user.ensembles.all():
if not e.code in registered:
registered[e.code] = e.pk
registered.append(e.code)
request.session['registered'] = registered
auth.signals.user_logged_in.connect(on_login)
@@ -194,8 +149,42 @@ def logout(request):
request.session['registered'] = registered
return redirect('/')
class EnsembleForgetView(EnsembleMixin, RedirectView):
class EnsembleDetailView(EnsembleMixin, DetailView):
def get_redirect_url(self, *args, **kwargs):
registered = self.request.session.setdefault('registered', [])
ensemble = models.Ensemble.objects.get(pk=self.kwargs['pk'])
try:
registered.remove(ensemble.code)
self.request.session['registered'] = registered
except KeyError:
pass
if self.request.ensemble_id == ensemble.pk:
del(self.request.session['ensemble'])
return resolve_url('ensemble_detail')
class EnsembleListView(ListView):
def get_queryset(self):
registered = self.request.session.get('registered', [])
if self.request.user.is_superuser:
current = models.Ensemble.objects.all()
else:
current = models.Ensemble.objects.filter(code__in=registered)
return current
#def get_context_data(self, **kwargs):
# context = super().get_context_data(**kwargs)
# context['ensemble_id'] = self.request.session.get('ensemble')
# return context
class EnsembleProjectListView(EnsembleMixin, DetailView):
template_name = 'interface/ensemble_project_list.html'
def dispatch(self, request, *args, **kwargs):
# capture provided urls
@@ -207,36 +196,69 @@ class EnsembleDetailView(EnsembleMixin, DetailView):
def get_object(self):
return models.Ensemble.objects.get(pk=self.request.ensemble_id)
class ProjectDetailView(EnsembleMixin, DetailView):
def get_queryset(self):
return models.Project.objects.filter(ensemble=self.request.ensemble_id)
class ProjectMakefileView(EnsembleMixin, DetailView):
template_name = 'interface/project_submissions.mk'
content_type = 'text/plain'
def get_queryset(self):
if self.request.is_admin:
return models.Project.objects.all()
return models.Project.objects.filter(ensemble=self.request.ensemble_id)
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
data['submissions'] = []
data['targets'] = []
for s in self.object.submissions:
name = s.short_name
data['targets'].append(name)
data['submissions'].append({
'url': self.request.build_absolute_uri(signed_url('submission_download', project=self.kwargs['pk'], pk=s.pk)),
'name': name,
})
if self.request.is_admin:
data['ensemble_url'] = self.request.build_absolute_uri('/?code={0}'.format(self.ensemble.ensemble_code()))
return data
class EnsembleDetailView(DetailView):
model = models.Ensemble
class ProjectDetailView(ProjectMixin, DetailView):
def get_object(self):
return self.get_project()
class ProjectCreateView(EnsembleMixin, CreateView):
model = models.Project
template_name = "interface/default_form.html"
title = "Add a new project"
form_class = forms.ProjectForm
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.ensemble_id = self.request.ensemble_id
self.object.owner = self.request.user
self.object.save()
return redirect('project_detail', project=self.object.pk)
class ProjectUpdateView(EnsembleMixin, UpdateView):
model = models.Project
template_name = "interface/default_form.html"
#fields = ['name', 'description', 'event_date', 'enable_library', 'enable_submissions', 'active']
pk_url_kwarg = 'project'
form_class = forms.ProjectForm
def get_success_url(self):
return resolve_url('project_detail', project=self.kwargs['project'])
#class ProjectMakefileView(EnsembleMixin, DetailView):
# template_name = 'interface/project_submissions.mk'
# content_type = 'text/plain'
#
# def get_queryset(self):
# if self.request.is_admin:
# return models.Project.objects.all()
#
# return models.Project.objects.filter(ensemble=self.request.ensemble_id)
#
# def get_context_data(self, **kwargs):
# data = super().get_context_data(**kwargs)
#
# data['submissions'] = []
# data['targets'] = []
# for s in self.object.submissions:
# name = s.short_name
# data['targets'].append(name)
# data['submissions'].append({
# 'url': self.request.build_absolute_uri(signed_url('submission_download', project=self.kwargs['pk'], pk=s.pk)),
# 'name': name,
# })
#
# return data
class WikiView(ProjectMixin, DetailView):
template_name = 'interface/wiki.html'
model = models.WikiPage
@@ -250,154 +272,41 @@ class WikiCreateView(ProjectMixin, CreateView):
admin_required = True
model = models.WikiPage
fields = ['title', 'markdown']
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.project = self.get_project()
self.object.save()
return redirect('wiki', project=self.object.project_id, pk=self.object.pk)
class WikiEditView(ProjectMixin, UpdateView):
admin_required = True
model = models.WikiPage
fields = ['title', 'markdown']
class SubmissionCreateView(ProjectMixin, FormView):
#model = models.Submission
#fields = ['name', 'instrument', 'url', 'notes']
form_class = forms.SubmissionForm
template_name = "interface/submission_create.html"
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.project = self.get_project()
self.object.save()
self.request.session['name'] = self.object.name
self.request.session['instrument'] = self.object.instrument
if form.cleaned_data['method'] == 'link':
return redirect('submission_link', project=self.object.project.pk, pk=self.object.pk)
return redirect('submission_upload', project=self.object.project.pk, pk=self.object.pk)
def get_initial(self):
return { k: self.request.session.get(k) for k in ('name', 'instrument') }
class SubmissionCompleteView(ProjectMixin, S3CompleteView):
model = models.Submission
def complete(self, key):
self.object.url = key
self.object.private = False
self.object.complete = True
self.object.save()
def get_redirect_url(self, **kwargs):
return resolve_url('submission_detail', **self.kwargs)
class SubmissionDownloadView(ProjectMixin, SingleObjectMixin, RedirectView):
model = models.Submission
admin_required = True
def get_redirect_url(self, **kwargs):
return self.get_object().download_url
class SubmissionDetailView(ProjectMixin, DetailView):
model = models.Submission
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['can_download'] = self.request.is_admin
return context
class SubmissionPreview(ProjectMixin, DetailView):
model = models.Submission
template_name = 'interface/submission_preview.html'
admin_required = True
class SubmissionUploadView(S3UploadMixin, DetailView):
template_name = 'interface/s3_upload.html'
model = models.Submission
accept_files = "video/*"
def get_success_url(self):
return resolve_url('submission_complete', **self.kwargs)
def get_cancel_url(self):
return resolve_url('submission_cancel', **self.kwargs)
class SubmissionLinkView(ProjectMixin, UpdateView):
model = models.Submission
template_name = 'interface/submission_link.html'
fields = ['url']
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['cancel_url'] = self.get_cancel_url()
return context
def get_success_url(self):
return resolve_url('submission_detail', **self.kwargs)
def get_cancel_url(self):
return resolve_url('submission_cancel', **self.kwargs)
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.complete = True
self.object.private = True
self.object.save()
return redirect(self.get_success_url())
class SubmissionCancelView(ProjectMixin, SingleObjectMixin, View):
model = models.Submission
def get(self, request, *args, **kwargs):
self.object = self.get_object()
self.object.delete()
return redirect('project_detail', pk=kwargs['project'])
class SubmissionListView(ProjectMixin, ListView):
model = models.Submission
admin_required = True
def get_queryset(self):
return super().get_queryset().filter(complete=True).order_by('-pk')
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
data['signed_url'] = self.request.build_absolute_uri(signed_url('project_makefile', pk=self.kwargs['project']))
return data
class ResourceCreateView(ProjectMixin, CreateView):
model = models.Resource
fields = ['name', 'media_type', 'description']
form_class = forms.ResourceForm
template_name = 'interface/project_form.html'
title = "Add a new resource"
admin_required = True
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.project = self.get_project()
self.object.save()
return redirect('resource_upload', project=self.object.project_id, pk=self.object.pk)
class ResourceUploadView(S3UploadMixin, DetailView):
class ResourceUploadView(ProjectMixin, UpdateView):
admin_required = True
model = models.Resource
template_name = 'interface/s3_upload.html'
def get_accept_files(self):
return self.object.accept()
fields = ['file']
template_name = 'interface/default_form.html'
def get_success_url(self):
return resolve_url('resource_complete', **self.kwargs)
def get_cancel_url(self):
return resolve_url('resource_list', project=self.kwargs['project'])
class ResourceCompleteView(ProjectMixin, S3CompleteView):
model = models.Resource
def get_redirect_url(self, **kwargs):
return resolve_url('resource_list', project=self.kwargs['project'])
class ResourceListView(ProjectMixin, ListView):
model = models.Resource
+13
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
+55
View File
@@ -0,0 +1,55 @@
from django.contrib import admin
from . import models
class EnsembleAccessInline(admin.StackedInline):
model = models.EnsembleAccess
extra = 0
class CollectionAdmin(admin.ModelAdmin):
list_display = ['name', 'location', 'storage', 'prefix']
inlines = [EnsembleAccessInline]
admin.site.register(models.Collection, CollectionAdmin)
class ItemInline(admin.TabularInline):
model = models.ProjectItem
extra = 0
class DocInline(admin.TabularInline):
model = models.Document
extra = 0
class MetaInline(admin.TabularInline):
model = models.WorkMeta
extra = 0
class WorkAdmin(admin.ModelAdmin):
list_display = ['name', 'composer', 'edition', 'identifier', 'running_time']
list_filter = ['collection']
inlines = [MetaInline, DocInline, ItemInline]
admin.site.register(models.Work, WorkAdmin)
class SectionInline(admin.TabularInline):
model = models.Section
fields = ['tag', 'start', 'end']
class DocumentAdmin(admin.ModelAdmin):
list_display = ['work', '__str__']
list_filter = ['work__collection']
inlines = [SectionInline]
admin.site.register(models.Document, DocumentAdmin)
class ItemAdmin(admin.ModelAdmin):
list_display = ['project', 'work', 'order']
list_filter = ['project']
admin.site.register(models.ProjectItem, ItemAdmin)
class EnsembleAccessAdmin(admin.ModelAdmin):
list_display = ['ensemble', 'collection', 'access_type']
list_filter = ['ensemble']
admin.site.register(models.EnsembleAccess, EnsembleAccessAdmin)
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class LibraryConfig(AppConfig):
name = 'library'
+32
View File
@@ -0,0 +1,32 @@
from django import forms
from .models import Work
from interface.models import Project
from interface.widgets import DatePickerInput
from django.db.models import Q
from interface.forms import BaseForm
class WorkCreateForm(forms.ModelForm, BaseForm):
#uploads = forms.FileField(label="PDFs to upload", widget=forms.ClearableFileInput(attrs={'multiple': True}), required=False)
class Meta:
model = Work
fields = ['name', 'code', 'running_time', 'notes']
class PlaylistAddForm(forms.Form):
work = forms.ModelChoiceField(queryset=Work.objects.all())
def __init__(self, instance, *args, **kwargs):
super(PlaylistAddForm, self).__init__(*args, **kwargs)
existing = [ x[0] for x in instance.works.values_list('pk') ]
qs = Work.objects.filter(ensemble_id=instance.ensemble_id).exclude(id__in=existing)
self.fields['work'].queryset = qs
self.instance = instance
def save(self):
self.instance.works.add(self.cleaned_data['work'])
class ProjectSelectForm(forms.Form):
project = forms.ModelChoiceField(queryset=Project.objects.all())
+197
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
@@ -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'])
@@ -0,0 +1,119 @@
# Generated by Django 3.2.7 on 2022-11-19 10:24
import byostorage.user
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
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 = [
('byostorage', '0004_alter_userstorage_storage'),
('interface', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Collection',
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', 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='User storage for documents', null=True, on_delete=django.db.models.deletion.CASCADE, to='byostorage.userstorage')),
],
),
migrations.CreateModel(
name='ProjectItem',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('checkout', models.DateTimeField()),
('due', models.DateTimeField(blank=True, null=True)),
('returned', models.DateTimeField(blank=True, null=True)),
('order', models.SmallIntegerField(default=0)),
('approved_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='interface.project')),
],
options={
'ordering': ['order', 'work'],
},
),
migrations.CreateModel(
name='Work',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(editable=False, 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 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.AddField(
model_name='projectitem',
name='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, related_name='allowed_ensembles', to='library.collection')),
('ensemble', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_collections', to='interface.ensemble')),
],
options={
'verbose_name_plural': 'Ensemble access',
},
),
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
+303
View File
@@ -0,0 +1,303 @@
from os import SCHED_OTHER
from django.conf import settings
from django.db import models
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.db.models import Q, Count, Min, Max
from byostorage.user import BYOStorage
from .imslp import Instrument
import logging
#from polyphonic.settings import LIBRARY_STORAGE
logger = logging.getLogger(__name__)
#try:
# library_storage = get_storage_class(settings.LIBRARY_STORAGE)()
#except (ImportError, AttributeError):
# logger.exception("Failed to load library storage")
# library_storage = get_storage_class()()
#logger.info("Library storage: %s", library_storage.__class__.__name__)
'''
ORCHESTRATIONS = {
'SATB': ('S', 'A', 'T', 'B'),
'String Quartet': ('Vln1', 'Vln2', 'Vla', 'Vc'),
'String Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb'),
'Chamber Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb',
'Fl1', 'Fl2', 'Cl1', 'Cl2', 'Hn1', 'Hn2',
'Tpt1', 'Tpt2', 'Tbn1', 'Tbn2', 'Tuba',
'Timp', 'Drum', 'Perc'),
'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'),
]
'''
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)
name = models.CharField(max_length=100)
instruments = models.TextField()
def as_list(self):
tags = [ t.strip() for t in self.instruments.split(' ') ]
return [ (t, tag_to_instrument(t)) for t in tags if t ]
def save(self):
self.as_list()
super(Orchestration, self).save()
def __str__(self):
return self.name
'''
class ProjectItem(models.Model):
"""
ProjectItem represents a Work attached to a Project e.g. item in set list or programme
It also allows works to be shared from one ensemble to another on a per-project basis.
"""
project = models.ForeignKey('interface.Project', on_delete=models.CASCADE, related_name='items')
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)
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:
ordering = ['order', 'work']
def __str__(self):
return f"<{self.project_id}:{self.work.slug}>"
class Collection(models.Model):
"""
A logical collection of works, typically owned by an organisation or person (physical or virtual)
"""
name = models.CharField(max_length=255,
help_text="Name of the collection")
prefix = models.SlugField(max_length=30, default="default",
help_text="Folder to store works in")
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...)", blank=True)
storage = models.ForeignKey('byostorage.UserStorage', on_delete=models.CASCADE, null=True, blank=True,
help_text="User storage for documents")
notes = models.TextField(blank=True,
help_text="Publicly visible notes about collection and loans policy (markdown format)")
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"
META_TAGS = (
('tag', 'Tag'),
('arr', 'Arranger'),
('lyrics', 'Lyracist'),
('genre', 'Genre'),
('style', 'Style'),
('orchestration', 'Orchestration'),
)
class Work(models.Model):
"""
A musical work 'owned' by a collection from a licencing perspective.
"""
slug = models.SlugField(max_length=100, editable=False,
help_text="Used as folder name")
name = models.CharField(max_length=255, help_text="Original name of the work")
edition = models.CharField(max_length=255, blank=True,
help_text="Edition details to distinguish multiple versions")
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="Surname, First Name/Initials")
original_parts = models.JSONField(default=dict, blank=True, help_text="Original printed parts (IMSLP format)")
# 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_projects = models.IntegerField(default=1, help_text="How many active projects can this work be attached to")
# Extra info
running_time = models.DurationField(null=True, blank=True, help_text="Running time in seconds")
notes = models.TextField(blank=True)
# Allocation to projects
projects = models.ManyToManyField('interface.Project', through='ProjectItem', related_name="works", help_text="Current usage")
def extract(self, *tags):
qs = self.docs.filter(sections__tag__in=tags)
qs = qs.annotate(Count('sections'), end=Min('sections__end'), start=Max('sections__start')) \
.filter(sections__count=len(tags))
return list(qs.values_list('upload', 'start', 'end'))
@property
def digital_parts(self):
return Section.objects.filter(doc__work=self.pk)
@property
def physical_parts(self):
if not self.original_parts:
return []
return [ (Instrument.from_tag(k), v) for (k, v) in self.original_parts.items() ]
@property
def tags(self):
return self.meta_info.filter(name='tag').values_list('value', flat=True)
@property
def meta(self):
return self.meta_info.exclude(name='tag')
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_projects < 0:
return True
return self.max_projects > self.loans
@property
def available(self):
if self.max_projects < 0:
return 'Unlimited'
a = self.max_projects - self.loans
return '{0} of {1}'.format(max(a, 0), self.max_projects)
@property
def identifier(self):
if self.code:
return self.code;
composer = self.composer or "Anon"
words = self.name.split()
if len(words) > 2:
work = ''.join([ x[0] for x in self.name.split() ])
else:
work = words[0][:3]
return f"{composer[:4]}-{work}-{self.pk:03d}".upper()
def __str__(self):
return f"{self.name} ({self.composer})"
class WorkMeta(models.Model):
work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name='meta_info')
name = models.SlugField(max_length=20, choices=META_TAGS)
value = models.CharField(max_length=255)
def doc_upload_filename(doc, filename):
collection = doc.work.collection
storage = collection.storage
if not storage:
raise RuntimeError("Collection has no storage attached")
return f'{storage}:library/{collection.prefix}/{doc.work.slug}-{doc.work.pk}/{filename}'
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")
doctype = models.PositiveSmallIntegerField(choices=DOCTYPES, default=1)
upload = models.FileField(upload_to=doc_upload_filename, storage=BYOStorage())
created = models.DateTimeField(auto_now_add=True)
version = models.CharField(max_length=30, blank=True)
def __str__(self):
return self.upload.name
class Section(models.Model):
"""
Section is a tagged portion of a Document
"""
doc = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="sections")
tag = models.CharField(max_length=50)
start = models.SmallIntegerField(null=True, blank=True)
end = models.SmallIntegerField(null=True, blank=True)
class Meta:
ordering = ['doc', 'start', 'pk']
@property
def instrument(self):
return Instrument.from_tag(self.tag)
@property
def filename(self):
return slugify(f'{self.doc.work.name}_{self.instrument}') + '.pdf'
@property
def pagerange(self):
if self.start:
if self.end:
return f"{self.start}-{self.end}"
return str(self.start)
return "all"
def __str__(self):
return f'{self.doc.upload} [{self.pagerange}]'
+65
View File
@@ -0,0 +1,65 @@
import tempfile
import subprocess
import os.path
import string
SAFECHARS = string.ascii_letters + string.digits + " _-"
def extract_pages(source, bookmark, start=None, end=None, count=1):
return extract_and_concat([(source, bookmark, start, end, count)])
def extract_and_concat(items):
# create a temporary directory for our sections
d = tempfile.TemporaryDirectory(prefix="polyphonic_")
pdfmarks = os.path.join(d.name, 'pdfmarks.txt')
marks = open(pdfmarks, 'w')
sections = []
current_page = 1
for i, (source, bookmark, start, end, count) in enumerate(items):
if count == 0:
continue
if start is None:
sections.append(source)
else:
if end is None:
end = start
dest = os.path.join(d.name, f'section_{i}.pdf')
cmd = ['gs', '-sDEVICE=pdfwrite', '-dBATCH', '-dNOPAUSE',
f'-dFirstPage={start}', f'-dLastPage={end}',
f'-sOutputFile={dest}',
source]
bookmark = "".join(filter(lambda c: c in SAFECHARS, bookmark))
marks.write(f'[/Title ({bookmark}) /Page {current_page} /OUT pdfmark\n')
p = subprocess.run(cmd, check=True, capture_output=True)
pages = len([ x for x in p.stdout.splitlines() if x.decode('utf8').startswith('Page ')])
for j in range(count):
sections.append(dest)
current_page += pages
marks.close()
# concat the items
output = tempfile.NamedTemporaryFile(prefix="polyphonic_", suffix='.pdf')
cmd = ['gs', '-sDEVICE=pdfwrite', '-q', '-dBATCH', '-dNOPAUSE',
'-sOutputFile=-']
cmd.extend(sections)
cmd.append(pdfmarks)
subprocess.run(cmd, stdout=output)
output.seek(0)
return output
@@ -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 %}
@@ -0,0 +1,389 @@
{% extends "interface/project_base.html" %}
{% block admin %}
<a href="#" onclick="saveTags()" class="button is-link">
<span class="icon"><i class="fas fa-save"></i></span>
<span>Save</span>
</a>
<a href="{% url 'work_detail' pk=object.work.pk %}" class="button is-link is-light">
<span>Cancel</span>
</a>
{% endblock %}
{% block media %}
<style>
.tag-grid {
display: flex;
}
.grid-column {
margin-left: 1em;
margin-right: 1em;
}
.grid-page {
height: 25px;
margin-bottom: 10px;
border: 1px solid #999;
border-radius: 5px;
text-align: center;
width: 50px;
cursor: pointer;
}
.grid-page.is-active {
background-color: var(--primary);
color: white;
}
.grid-tag {
border: 2px solid var(--primary);
background-color: rgba(240, 240, 240, 0.5);
border-radius: 5px;
padding-left: 1em;
padding-right: 1em;
margin-bottom: 5px;
min-width: 200px;
position: absolute;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
#tag-area {
min-width: 220px;
}
</style>
{% endblock %}
{% block page %}
<h3 class="subtitle"><a href="{% url 'work_detail' document.work.pk %}">{{ document.work.name }}</a></h3>
<div id="annotation-area" class="columns is-centered">
<div class="column is-narrow">
<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;">
<canvas id="inline-viewer" style="width: 500px;"></canvas>
</div>
</div>
</div>
<div class="column is-narrow">
<div class="tag-grid">
<div class="grid-column" id="page-list">
</div>
<div class="grid-column" id="tag-area">
</div>
</div>
</div>
</div>
<p>{{ document.upload.name }}</p>
{% endblock %}
{% block scripts %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.7.570/pdf.min.js"
integrity="sha512-g4FwCPWM/fZB1Eie86ZwKjOP+yBIxSBM/b2gQAiSVqCgkyvZ0XxYPDEcN2qqaKKEvK6a05+IPL1raO96RrhYDQ=="
crossorigin="anonymous"></script>
{{ json_data|json_script:"data" }}
<script type="text/javascript">
let url = "{{ document.upload.url|safe }}";
// Loaded via <script> tag, create shortcut to access PDF.js exports.
let pdfjsLib = window['pdfjs-dist/build/pdf'];
// The workerSrc property shall be specified.
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.7.570/pdf.worker.min.js';
// get current page tags
let data = JSON.parse(document.getElementById('data').textContent);
let tagArea = document.getElementById('tag-area');
var dirty = false;
//document.getElementById('tag-list').onclick = (e) => setTag(e.target.dataset.tag);
var pdfDoc = null,
pageNum = 1,
pageRendering = false,
pageNumPending = null,
scale = 1,
canvas = document.getElementById('inline-viewer'),
ctx = canvas.getContext('2d');
/**
* Get page info from document, resize canvas accordingly, and render page.
* @param num Page number.
*/
function renderPage(num) {
pageRendering = true;
// Using promise to fetch the page
pdfDoc.getPage(num).then(function (page) {
var viewport = page.getViewport({ scale: scale });
canvas.height = viewport.height;
canvas.width = viewport.width;
// Render PDF page into canvas context
var renderContext = {
canvasContext: ctx,
viewport: viewport
};
var renderTask = page.render(renderContext);
try {
document.getElementById('grid-page-' + pageNum).classList.remove('is-active');
} catch(e) {}
pageNum = num;
document.getElementById('grid-page-' + pageNum).classList.add('is-active');
// Wait for rendering to finish
renderTask.promise.then(function () {
pageRendering = false;
if (pageNumPending !== null) {
// New page rendering is pending
renderPage(pageNumPending);
pageNumPending = null;
}
});
});
// Update page counters
document.getElementById('page-num').textContent = num;
}
/**
* If another page rendering in progress, waits until the rendering is
* finised. Otherwise, executes rendering immediately.
*/
function queueRenderPage(num) {
if (pageRendering) {
pageNumPending = num;
} else {
renderPage(num);
}
}
/**
* Displays previous page.
*/
function onPrevPage() {
if (pageNum <= 1) {
return;
}
pageNum--;
queueRenderPage(pageNum);
}
//document.getElementById('prev').addEventListener('click', onPrevPage);
/**
* Displays next page.
*/
function onNextPage() {
if (pageNum >= pdfDoc.numPages) {
return;
}
pageNum++;
queueRenderPage(pageNum);
}
//document.getElementById('next').addEventListener('click', onNextPage);
/**
* Asynchronously downloads PDF.
*/
pdfjsLib.getDocument(url).promise.then(function (pdfDoc_) {
pdfDoc = pdfDoc_;
const pageList = document.getElementById('page-list');
pageList.innerHTML = '';
for (var i=0; i<pdfDoc.numPages; i++) {
let page = i+1;
let el = document.createElement('div');
el.className = 'grid-page';
el.id = 'grid-page-' + page;
el.innerHTML = page;
el.addEventListener('click', (evt) => {
queueRenderPage(page);
});
pageList.appendChild(el);
}
document.getElementById('page-count').textContent = pdfDoc.numPages;
// Initial/first page rendering
renderPage(pageNum);
tagArea.innerHTML = '';
for (let pageTag of data.pageTags) {
addTag(pageTag[0], pageTag[1], pageTag[2]);
}
dirty = false;
});
function addInstrument() {
let name = document.getElementById('add-instrument-name');
let variant = document.getElementById('add-instrument-variant');
let inst_name = name.value;
var tag = null;
for (let key in data.instruments) {
if (data.instruments[key] == inst_name) {
tag = key;
break;
}
}
if (!tag) {
alert("Unknown tag: " + name);
return;
}
if (variant.value) {
tag += "-" + variant.value;
}
variant.value = '';
name.value = '';
addTag(tag, pageNum, pageNum);
}
function addTag(tag, start, end) {
console.log("addTag", tag, start, end);
const el = document.createElement('div');
el.className = 'grid-tag';
el.dataset.tag = tag;
el.dataset.start = start;
el.dataset.end = end;
let setStart = document.createElement('span');
setStart.className = "icon is-action";
setStart.innerHTML = '<i class="fas fa-sort-amount-down" title="Set start page"></i>';
setStart.addEventListener('click', () => setTagStart(el));
el.appendChild(setStart);
let label = document.createElement('span');
let name = document.createElement('b');
name.innerHTML = get_instrument(tag);
label.appendChild(name);
let del = document.createElement('span');
del.className = "icon is-action";
del.innerHTML = '<i class="fas fa-trash-alt" title="Remove this tag"></i>';
del.addEventListener('click', () => {el.remove(), dirty=true});
label.appendChild(del)
el.appendChild(label);
let setEnd = document.createElement('span');
setEnd.className = "icon is-action";
setEnd.innerHTML = '<i class="fas fa-sort-amount-up" title="Set end page"></i>';
setEnd.addEventListener('click', () => setTagEnd(el));
el.appendChild(setEnd);
updateTag(el);
tagArea.appendChild(el);
}
function updateTag(tag) {
let start = tag.dataset.start;
let end = tag.dataset.end;
let span = end-start+1;
let height = span * 25 + (span-1) * 10;
let top = (start-1) * 35;
tag.style.height = height + 'px';
tag.style.marginTop = top + 'px';
dirty = true;
}
function setTagStart(el) {
if (pageNum > el.dataset.end) {
alert("Select the first page and click extend");
return;
}
el.dataset.start = pageNum;
updateTag(el);
}
function setTagEnd(el) {
if (pageNum < el.dataset.start) {
alert("Select the last page and click extend");
return;
}
el.dataset.end = pageNum;
updateTag(el);
}
function saveTags() {
const pageTags = [];
for (let pageTag of tagArea.children ) {
let start = pageTag.dataset.start;
let end = pageTag.dataset.end;
if (start == 1 && end == pdfDoc.numPages) {
start = null;
end = null;
}
pageTags.push([pageTag.dataset.tag, start, end])
}
console.log(pageTags);
fetch("", {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRFToken": "{{ csrf_token }}" },
body: JSON.stringify(pageTags)
}
).then((response) => {
if (response.ok) {
window.location = "{% url 'work_detail' document.work.pk %}"
} else {
alert("Failed: " + response.statusText)
}
});
dirty = false;
}
function get_instrument(s) {
let parts = s.split('-');
let instrument = data.instruments[parts[0]];
if (parts.length == 2) {
return instrument + " " + parts[1];
}
return instrument;
}
function checkSaved(e) {
if (dirty) {
e.preventDefault();
}
}
window.addEventListener('beforeunload', checkSaved);
</script>
{% endblock %}
@@ -0,0 +1,11 @@
{% extends 'interface/project_base.html' %}
{% block page %}
<h2>{{ folder.name }}</h2>
<h3>Works</h3>
<ul>
{% for work in folder.works.all %}
<li>{{ work.name }}</li>
{% endfor %}
</ul>
{% endblock %}
+163
View File
@@ -0,0 +1,163 @@
{% extends "interface/project_base.html" %}
{% block admin %}
<a class="button is-link" href="{% url 'item_list_manage' project.pk %}">
<span class="icon"><i class="fas fa-list"></i></span>
<span>Change items</span>
</a>
{% endblock %}
{% block page %}
<form action="" method="post" target="_blank">
{% csrf_token %}
<div>
<div class="field is-grouped is-grouped-centered">
<div class="field has-addons control">
<span class="control">
<a class="button is-static">Your Instrument</a>
</span>
<span class="control">
<input type="text" class="input" list="instrument-list" id="instrument-name" onchange="updateParts()"/>
<datalist id="instrument-list">
{% for inst in instruments %}
<option value="{{inst.1}}"/>
{% endfor %}
</datalist>
</span>
<span class="control">
<span class="select" onchange="updateParts()">
<select id="part-preference">
<option value="0">Any</option>
<option value="1">1st</option>
<option value="2">2nd</option>
<option value="3">3rd</option>
<option value="4">4th</option>
</select>
</span>
</span>
</div>
<span class="control">
<button type="submit" class="button is-primary">
<span class="icon"><i class="fas fa-copy"></i></span>
<span>Get My Parts!</span>
</button>
</span>
</div>
</div>
<table class="table is-striped is-fullwidth">
<thead>
<tr>
<th/>
<th>Piece</th>
<th>Running time</th>
<th>Part</th>
<th/>
</tr>
</thead>
<tbody>
{% for item in object_list %}
<tr>
<td>{{ forloop.counter }}.</td>
<td>
{% if request.is_admin %}
<a href="{% url 'work_detail' item.work.pk %}">{{ item.work.name }}</a>
{% else %}
{{ item.work.name }}
{% endif %}
</td>
<td>{% firstof item.work.running_time "------" %}</td>
<td class="select-cell">
<input type="hidden" name="works" value="{{ item.work.pk }}"/>
<span class="select is-small">
<select name="instruments">
<option value='-'>None</option>
{% for part in item.work.digital_parts %}
<option value='{{ part.tag }}'>{{ part.instrument }}</option>
{% endfor %}
</select>
</span>
</td>
<td>
<a href=""><i class="fas fa-download"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td/>
<td/>
<td>{{ running_time }}</td>
<td/>
<td>
<button class="button is-link is-small" type="submit"><span class="icon"><i class="fas fa-copy"></i></span><span>Single combined PDF</span></button>
<a class="button is-link is-small"><span class="icon"><i class="fas fa-archive"></i></span><span>Individual files (zipped)</span></a>
</td>
</tr>
</tfoot>
</table>
</form>
<p class="content">
This page lets you download a complete set of music for your part by selecting your instrument
and optionally a part (e.g. Flute 1 or 2).<br/>
You can also tweak which parts you get for each piece, or click on a piece for more download options.
</p>
{% endblock %}
{% block scripts %}
{{ instruments|json_script:'instruments' }}
<script type="text/javascript">
const INSTRUMENTS = JSON.parse(document.getElementById('instruments').innerText);
function updateParts() {
var inst = document.getElementById("instrument-name").value;
window.localStorage.setItem('instrument-name', inst);
for (let i of INSTRUMENTS) {
if (i[1] === inst) inst = i[0];
}
let part = document.getElementById("part-preference").value;
window.localStorage.setItem('part-preference', part);
selectParts(inst, part);
}
function selectParts(inst, part) {
let prefix = inst + "-" + part;
let instruments = document.getElementsByName("instruments");
for(let i=0; i<instruments.length; i++) {
var result = "-"
let options = instruments[i].children;
for(let j=0; j<options.length; j++) {
let value = options[j].value;
if (value.startsWith(prefix)) {
result = value;
break;
}
if (result==="-" && value.startsWith(inst)) {
result = value;
}
}
instruments[i].value = result;
}
}
document.getElementById("instrument-name").value = localStorage.getItem('instrument-name', 'All');
document.getElementById("part-preference").value = localStorage.getItem('part-preference', 'Any');
updateParts();
</script>
{% endblock %}
@@ -0,0 +1,109 @@
{% extends "interface/project_base.html" %}
{% block admin %}
<a href="{% url 'item_list_append' project.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add</span>
</a>
<a href="#" onclick="save()" class="button is-link">
<span class="icon"><i class="fas fa-save"></i></span>
<span>Save</span>
</a>
{% endblock %}
{% block page %}
<table style="max-width: 600px; margin: 10pt auto;" class="zebra">
<thead>
<tr>
<th>Item</th>
<th>Time</th>
<th/>
</tr>
</thead>
<tbody id="work-list">
{% for item in object_list %}
<tr data-pk="{{ item.pk }}" data-order="{{ forloop.counter }}">
<td>{{ item.work.name }}</td>
<td>{{ item.work.duration }}</td>
<td style="text-align: center;">
<i class="fas fa-arrow-up clickable" title="Move up" onclick="moveItem({{ item.pk }}, -1)"></i>
<i class="fas fa-arrow-down clickable" title="Move down" onclick="moveItem({{ item.pk }}, 1)"></i>
<i class="fas fa-trash clickable" title="Remove" onClick="moveItem({{ item.pk }}, 0)"></i>
</td>
</tr>
{% empty %}
<tr><td colspan="2">No items</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
{% block scripts %}
<script type="text/javascript">
let workList = document.getElementById('work-list');
var dirty = false;
function moveItem(pk, dir) {
let items = Array.prototype.slice.call(workList.children, 0);
var i=0;
pk = "" + pk;
for(i=0; i<items.length; i++) {
if(items[i].dataset.pk === pk) break;
}
if(i >= items.length) return;
// check the direction is sensible
if (i + dir < 0 || i + dir >= items.length) return;
if (dir === 0) {
items[i].dataset.order = -1;
items[i].style = "display: none";
} else {
items[i].dataset.order = parseInt(items[i].dataset.order) + dir;
items[i+dir].dataset.order = parseInt(items[i+dir].dataset.order) - dir;
}
items.sort((a, b) => parseInt(a.dataset.order) - parseInt(b.dataset.order))
workList.innerHTML = ""
for(let j=0; j<items.length; j++) {
workList.appendChild(items[j]);
}
dirty = true;
}
function save() {
let items = Array.prototype.slice.call(workList.children, 0);
let data = {};
for(var i=0; i<items.length; i++) {
let item = items[i].dataset;
data[item.pk] = item.order;
}
console.log(data);
fetch("", {
method: "POST",
headers: {"Content-Type": "application/json", "X-CSRFToken": "{{ csrf_token }}"},
body: JSON.stringify(data)
}).then((response) => {
if (response.ok) {
dirty=false;
window.location = "{% url 'item_list' project=project.pk %}";
} else {
alert("Failed: " + response.statusText)
}
})
}
function checkSaved(e) {
if(dirty) {
e.preventDefault();
}
}
window.addEventListener('beforeunload', checkSaved);
</script>
{% endblock %}
@@ -0,0 +1,12 @@
{% with project.items.count as items %}
{% if items %}
<p>
There {{ items|pluralize:"is,are" }} currently {{ items}} item{{ items|pluralize }} for this project.<br/>
Click the '<a href="{% url 'item_list' project=project.pk %}">My Music</a>' button below to access your parts...
</p>
{% else %}
<p>
There are no items listed yet for this project - please check back later...
</p>
{% endif %}
{% endwith %}
@@ -0,0 +1,9 @@
<p class='menu-label'>Library</p>
<ul class="menu-list">
{% if project %}
<li><a href="{% url 'item_list' project=project.pk %}">My Music</a></li>
{% endif %}
{% if request.is_admin %}
<li><a href="{% url 'work_list' %}">Library</a></li>
{% endif %}
</ul>
+211
View File
@@ -0,0 +1,211 @@
{% extends 'interface/project_base.html' %}
{% load path_filters %}
{% block media %}
<script src="https://unpkg.com/dropzone@5/dist/min/dropzone.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/dropzone@5/dist/min/dropzone.min.css" type="text/css" />
{% endblock %}
{% block admin %}
<a href="{% url 'work_edit' work.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-edit"></i></span>
<span>Edit</span>
</a>
<a href="{% url 'work_add_to_project' work.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add to project</span>
</a>
{% endblock %}
{% block page %}
<h3 class="title">
{{ work.name }}
{% for tag in work.tags %}
<span class="tag is-success">{{ tag }}</span>
{% endfor %}
</h3>
<p class="subtitle">{% firstof work.composer "Unattributed" %}{% if work.edition %} - {{ work.edition }}{% endif %}</p>
<section class="block">
<p class="block">{{ work.notes }}</p>
<p class="block">
Location: <a href="{% url 'collection_work_list' work.collection.pk %}">{{ work.collection }}</a> [{{ work.identifier }}]<br/>
Running time: {{ work.duration }}<br/>
Licence: {{ work.get_licence_display }}<br/>
{% for meta in work.meta %}
{{ meta.get_name_display }}: {{ meta.value }}<br/>
{% endfor %}
</p>
{% 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 %}
</section>
<section class="block">
<div class="columns">
<div class="column is-half">
<div class="box">
<h4 class="subtitle is-size-4">
<span class="icon"><i class="fas fa-book"></i></span>
Printed Parts
</h4>
<div class="tags">
{% for inst, c in work.physical_parts %}
<span class="tag is-warning">{{ inst }} ({{ c }})</span>
{% empty %}
<p class="is-italic">No printed parts listed</p>
{% endfor %}
</div>
</div>
</div>
<div class="column is-half">
<div class="box">
<h4 class="subtitle is-size-4">
<span class="icon"><i class="fas fa-print"></i></span>
Digital Parts
</h4>
<div class="tags">
{% if work.digital_parts %}
<a class="tag is-danger" href="{% url 'work_partset' pk=work.pk %}">Full Set</a>
{% endif %}
{% for part in work.digital_parts %}
<a class="tag is-info" href="{% url 'part_download' pk=part.pk filename=part.filename %}"
target="part_{{ part.pk }}" rel="">{{ part.instrument }}</a>
{% empty %}
<p class="is-italic">No digital parts available</p>
{% endfor %}
</div>
</div>
</div>
</div>
</section>
<section class="block">
<div class="box">
<div class="level">
<h4 class="subtitle is-size-4">
<span class="icon"><i class="fas fa-file"></i></span>
Files
</h4>
</div>
<div class="columns">
<div class="column">
<table class="table is-fullwidth">
<thead>
<tr>
<th>Filename</th>
<th>Sections</th>
<th/>
</tr>
</thead>
<tbody id="doc-list">
{% for doc in work.docs.all %}
<tr>
<td><a href="{% url 'document_download' pk=doc.pk %}" target="_blank">
{{ doc.upload.name|basename }}</a></td>
<td>
{% for part in doc.sections.all %}
<a class="tag is-info" href="{% url 'part_download' pk=part.pk filename=part.filename %}">{{ part.instrument }}</a>
{% endfor %}
</td>
<td class="has-text-right">
{% if request.is_admin %}
<a href="{% url 'document_annotate' pk=doc.pk %}"><i class="fas fa-tags"
title="Manage Tags"></i></a>
<a href=""><i class="fas fa-trash-alt" title="Delete Document"></i></a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if request.is_admin %}
<div class="column is-one-quarter">
<h4 class="is-size-5">Upload files</h4>
<form action="{% url 'document_add' object.pk %}" class="dropzone" id="doc-upload">
{% csrf_token %}
</form>
</div>
{% endif %}
</div>
</div>
</section>
{% if request.is_admin %}
<section class="block">
<div class="box">
<div class="level">
<h4 class="is-size-4">
<span class="icon"><i class="fas fa-book-reader"></i></span>
Loans
</h4>
<span class="level-right">
<a class="icon-text" href="{% url 'work_add_to_project' work.pk %}"><span class="icon"><i
class="fas fa-plus-circle"></i></span> Checkout</a>
</span>
</div>
<table class="table is-fullwidth">
<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>
</div>
</section>
{% endif %}
{% block scripts %}
<script>
Dropzone.options.docUpload = { // camelized version of the `id`
paramName: "upload", // The name that will be used to transfer the file
maxFilesize: 12, // MB
createImageThumbnails: false,
thumbnailWidth: 60,
thumbnailHeight: 60,
init: function () {
this.on("complete", file => {
console.log(file);
let data = JSON.parse(file.xhr.response);
console.log(data);
let tbody = document.getElementById('doc-list');
let tr = document.createElement('tr');
tr.innerHTML = data.entry;
tbody.appendChild(tr);
this.removeFile(file);
});
}
};
</script>
{% endblock %}
{% endblock %}
+71
View File
@@ -0,0 +1,71 @@
{% extends "interface/project_base.html" %}
{% load url_tools %}
{% block admin %}
{% if collection_id %}
<a href="{% url 'work_add' pk=collection_id %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add a work</span>
</a>
{% endif %}
{% endblock %}
{% block page %}
<h3 class="title">{{ title }}</h3>
<form method="GET">
<div class="field has-addons">
<div class="control is-expanded">
<input class="input" name="filter" type="text" placeholder="Filter" value="{{ request.GET.filter }}"/>
</div>
<div class="control">
<a class="button" href="?"><i class="fas fa-times"></i></a>
</div>
</div>
</form>
<table class="table is-striped is-fullwidth">
<thead>
<tr>
<th>Work</th>
<th>Composer</th>
<th class="is-hidden-mobile">Edition</th>
{% if request.is_admin %}
<th class="is-hidden-touch">Collection</th>
<th class="is-hidden-mobile">Copies</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for work in object_list %}
<tr>
<td><a href="{% url 'work_detail' pk=work.pk %}">{{ work.name }}</a></td>
<td title="{{ work.composer }}">{{ work.composer|truncatewords:3 }}</td>
<td class="is-hidden-mobile" title="{{ work.edition }}">{{ work.edition|truncatewords:2 }}</td>
{% if request.is_admin %}
<td class="is-hidden-touch">{{ work.collection.name }}</td>
<td class="is-hidden-mobile {{ work.is_available|yesno:'has-text-success,has-text-danger' }}">{{ work.available }}</td>
{% endif %}
</tr>
{% empty %}
<tr><td colspan="4">No works found</td></tr>
{% endfor %}
</tbody>
</table>
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
{% if page_obj.has_previous %}
<a class="pagination-previous" href="{% url_update page=page_obj.prev_page_number %}">Previous</a>
{% endif %}
{% if page_obj.has_next %}
<a class="pagination-next" href="{% url_update page=page_obj.next_page_number %}">Next page</a>
{% endif %}
<ul class="pagination-list">
{% for page in page_obj.paginator.page_range %}
<li>
<a class="pagination-link {% if forloop.counter == page_obj.number %}is-current{% endif %}" href="{% url_update page=forloop.counter %}" aria-label="Goto page {{ forloop.counter }}">{{ forloop.counter }}</a>
</li>
{% endfor %}
</ul>
</nav>
{% endblock %}
@@ -0,0 +1,37 @@
{% extends "interface/project_base.html" %}
{% block page %}
<form action="" method="post" target="_blank">
{% csrf_token %}
<div class="admin-tools is-pulled-right">
<button type="submit" class="button is-link">
<span class="icon"><i class="fas fa-print"></i></span>
<span>Print Set</span>
</button>
<a class="button is-link is-light" href="{% url 'work_detail' pk=object.pk %}">
<span>Cancel</span>
</a>
</div>
<h3 class="subtitle"><a href="{% url 'work_detail' pk=work.pk %}">{{ work.name }}</a></h3>
<p class="block">
You can generate a custom partset for printing - select the number of copies of each you want...
</p>
<div class="columns is-multiline is-mobile">
{% for part in work.digital_parts %}
<div class="column is-3 has-text-right">
<span style="white-space: nowrap">{{ part.instrument }}</span>
<input name="parts" type="hidden" value="{{ part.tag }}">
<input name="copies" type="number" value="{% if part.tag == 'Score' %}0{% else %}1{% endif %}" size="1">
</div>
{% endfor %}
</div>
</form>
{% endblock %}
+50
View File
@@ -0,0 +1,50 @@
from django.test import TestCase
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
def test_movement_from_large_work(self):
'''
Will be common to store a work which has several movements, but the project is only going to play one.
This also should give us the ability to store an anthology as one Work have Project reference 'no:23'
'''
work = self.sel.works.create(name="Some Quartet", composer="Beethoven")
for g in ('vl1', 'vl2', 'vla', 'vc'):
doc = work.docs.create(upload=f'sel/beethoven/some_quartet/some_quartet_{g}.pdf')
doc.sections.create(tag='mvmt:1', start=1, end=3)
doc.sections.create(tag='mvmt:2', start=4, end=8)
doc.sections.create(tag='mvmt:3', start=9, end=12)
doc.sections.create(tag=f'inst:{g}')
# no tags - get nothing (should it be everything?)
self.assertEqual(work.extract(), [])
# single tag - should get just that range
self.assertEqual(work.extract('inst:vl1'), [('sel/beethoven/some_quartet/some_quartet_vl1.pdf', None, None)])
# single tag - returns all documents with that range
result = work.extract('mvmt:2')
self.assertEqual(len(result), 4)
# multiple tags - returns the overlapping portion of all documents that have all tags
self.assertEqual(work.extract('inst:vl1', 'mvmt:2'), [('sel/beethoven/some_quartet/some_quartet_vl1.pdf', 4, 8)])
self.assertEqual(work.extract('inst:vl1', 'inst:vl2'), [])
+29
View File
@@ -0,0 +1,29 @@
from django.urls import path
from django.contrib.auth import views as auth_views
from . import views
urlpatterns = [
path('projects/<int:project>/items', views.ProjectItemListView.as_view(), name="item_list"),
path('projects/<int:project>/items/manage', views.ProjectItemManageView.as_view(), name="item_list_manage"),
path('projects/<int:project>/items/append', views.ProjectItemAddView.as_view(), name="item_list_append"),
path('library/collections', views.CollectionListView.as_view(), name="collection_list"),
path('library/collections/<int:pk>', views.CollectionWorkListView.as_view(), name="collection_work_list"),
path('library/collection/<int:pk>/create', views.WorkAddView.as_view(), name="work_add"),
path('library/works', views.WorkListView.as_view(), name="work_list"),
path('library/works/<int:pk>', views.WorkDetailView.as_view(), name="work_detail"),
path('library/works/<int:pk>/edit', views.WorkUpdateView.as_view(), name="work_edit"),
path('library/works/<int:pk>/partset', views.WorkPartSetView.as_view(), name="work_partset"),
path('library/works/<int:pk>/add_to_project', views.WorkAddToProject.as_view(), name="work_add_to_project"),
path('library/works/<int:pk>/upload', views.WorkAddDocumentView.as_view(), name="document_add"),
path('library/documents/<int:pk>/download', views.DocumentDownloadView.as_view(), name="document_download"),
path('library/documents/<int:pk>/annotate', views.DocumentAnnotateView.as_view(), name="document_annotate"),
path('library/parts/<int:pk>/<str:filename>', views.PartDownloadView.as_view(), name="part_download"),
]
from django.views.static import serve
urlpatterns.append(path('docs/<path:path>', serve, {'document_root': 'local_storage'}))
+361
View File
@@ -0,0 +1,361 @@
from django.shortcuts import render, redirect, resolve_url
from django.views.generic.detail import DetailView, SingleObjectMixin, View
from django.views.generic.list import ListView, MultipleObjectMixin
from django.views.generic.edit import CreateView, FormView, UpdateView
from django.http import FileResponse, HttpResponse, JsonResponse
from django.db import IntegrityError
from django.db.models import Q, Count, Sum
from django.utils.timezone import now
from django.urls import reverse
import json
import os.path
from interface.views import EnsembleMixin, ProjectMixin
from interface.models import Project
from .models import Collection, Work, Document, Section
from .imslp import INSTRUMENTS
from . import forms, models
from .pdf_utils import extract_pages, extract_and_concat
class ProjectItemListView(ProjectMixin, ListView):
template_name = "library/item_list.html"
model = models.ProjectItem
def post(self, request, **kwargs):
project = self.get_project()
project_works = project.works.all()
instruments = request.POST.getlist('instruments')
works = request.POST.getlist('works')
self.request.session['part'] = request.POST.get('part', '')
self.request.session['instrument'] = request.POST.get('instrument')
valid_pks = [ x.pk for x in project_works ]
sections = []
for i, pk in enumerate(works):
if int(pk) not in valid_pks:
raise Exception(f"Not a valid work pk: {pk}")
tag = instruments[i]
if tag == '-':
continue
part = Section.objects.filter(tag=tag, doc__work=pk).select_related('doc').get()
sections.append((part.doc.upload.path, part.doc.work.name, part.start, part.end, 1))
result = extract_and_concat(sections)
download_name = f'{project.name}.pdf'
response = FileResponse(result, content_type="application/pdf")
response['Content-Disposition'] = f'inline; filename="{download_name}"'
return response
def get_queryset(self):
return super(ProjectItemListView, self).get_queryset().select_related('project', 'work')
def get_context_data(self, **kwargs):
data = super(ProjectItemListView, self).get_context_data(**kwargs)
data['instruments'] = INSTRUMENTS
data['instrument'] = self.request.session.get('instrument', 'Score')
data['part'] = self.request.session.get('part', '0')
data['running_time'] = self.get_queryset().aggregate(Sum('work__running_time'))['work__running_time__sum']
#if running_time:
# data['running_time'] = "{0:d}:{1:02d}".format(int(running_time / 60), running_time % 60)
#else:
# data['running_time'] = "-:--"
return data
class ProjectItemManageView(ProjectMixin, ListView):
template_name = "library/item_list_manage.html"
model = models.ProjectItem
def post(self, request, **kwargs):
self.request = request
self.kwargs = kwargs
data = json.loads(request.body)
q = self.get_queryset()
for pk, order in data.items():
order = int(order)
if order == -1:
q.filter(pk=pk).delete()
else:
i = q.filter(pk=pk).update(order=order)
return HttpResponse(status=204)
def get_queryset(self):
return super(ProjectItemManageView, self).get_queryset().select_related('project', 'work')
class ProjectItemAddView(ProjectMixin, UpdateView):
form_class = forms.PlaylistAddForm
template_name = "interface/default_form.html"
def get_success_url(self):
return resolve_url('item_list_manage', project=self.kwargs['project'])
def get_object(self):
return self.get_project()
class CollectionListView(EnsembleMixin, ListView):
def get_queryset(self):
return Collection.objects.filter(administrators=self.request.user)
class WorkListView(EnsembleMixin, ListView):
paginate_by = 20
def get_works(self):
return Work.objects.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id).order_by('name').distinct().select_related('collection')
def get_context_data(self, *args, **kwargs):
data = super(WorkListView, self).get_context_data(*args, **kwargs)
data['title'] = f'Music available to {self.ensemble.name}'
return data
def get_queryset(self):
works = self.get_works().order_by('name')
q = self.request.GET.get('filter')
if q:
if ":" in q:
name, _, value = q.partition(":")
works = works.filter(meta_info__name=name, meta_info__value__contains=value)
else:
works = works.filter(Q(name__contains=q) | Q(composer__contains=q) | Q(meta_info__value__contains=q))
return works
class CollectionWorkListView(WorkListView):
def get_works(self):
works = Work.objects.filter(collection=self.kwargs['pk']).distinct()
loan_count = Count('project_items', Q(project_items__checkout__lte=now(), project_items__returned=None))
works = works.annotate(loan_count=loan_count)
return works
def get_context_data(self, *args, **kwargs):
data = super(CollectionWorkListView, self).get_context_data(*args, **kwargs)
data['title'] = Collection.objects.get(pk=self.kwargs['pk']).name
data['collection_id'] = self.kwargs['pk']
return data
class WorkAddView(EnsembleMixin, FormView):
template_name = "interface/default_form.html"
form_class = forms.WorkCreateForm
title = "Add a new work"
def form_valid(self, form):
work = form.save(commit=False)
work.ensemble_id = self.request.ensemble_id
work.collection_id = self.kwargs['pk']
work.save()
# handle the files
uploads = self.request.FILES.getlist('uploads')
docs = []
for f in uploads:
docs.append(work.docs.create(upload=f).pk)
if len(docs) == 1:
return redirect('document_annotate', docs[0])
else:
return redirect('work_detail', pk=work.pk)
class WorkMixin(object):
def get_queryset(self):
if self.request.is_admin:
return Work.objects.all()
return Work.objects.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id)
class WorkDetailView(EnsembleMixin, WorkMixin, DetailView):
pass
class WorkUpdateView(EnsembleMixin, WorkMixin, UpdateView):
fields = ['name', 'composer', 'edition', 'code', 'licence', 'max_projects', 'running_time', 'notes']
template_name = 'interface/default_form.html'
def get_success_url(self):
return resolve_url('work_detail', self.kwargs['pk'])
class WorkAddToProject(EnsembleMixin, FormView):
admin_required = True
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):
template_name = "library/work_partset.html"
def post(self, request, *args, **kwargs):
work = self.get_object()
parts = request.POST.getlist('parts')
copies = request.POST.getlist('copies')
sections = []
for i, tag in enumerate(parts):
part = work.digital_parts.select_related('doc').get(tag=tag)
sections.append((part.doc.upload.path, part.instrument, part.start, part.end, int(copies[i])))
result = extract_and_concat(sections)
download_name = f'{work.name}.pdf'
response = FileResponse(result, content_type="application/pdf")
response['Content-Disposition'] = f'inline; filename="{download_name}"'
return response
def get_queryset(self):
works = Work.objects.all()
if not self.request.is_admin:
works = works.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id)
return works
class WorkAddDocumentView(EnsembleMixin, CreateView):
template_name = "interface/default_form.html"
model = Document
fields = ['upload']
def title(self):
work = Work.objects.get(pk=self.kwargs['pk'])
return f"Add a document to {work.name}"
def form_invalid(self, form):
if self.request.headers['Accept'] == 'application/json':
return HttpResponse(status=400)
return super().form_invalid(form)
def form_valid(self, form):
doc = form.save(commit=False)
doc.work_id = self.kwargs['pk']
doc.save()
if self.request.headers['Accept'] == 'application/json':
filename = os.path.basename(doc.upload.name)
return JsonResponse({
"message": "created",
"id": doc.pk,
"entry": f"""
<td><a href="{reverse('document_download', args=[doc.pk])}">{filename}</a></td>
<td/>
<td class="has-text-right">
<a href="{reverse('document_annotate', args=[doc.pk])}"><i class="fas fa-tags"
title="Manage Tags"></i></a>
<a href=""><i class="fas fa-trash-alt" title="Delete Document"></i></a>
</td>
"""
}, status=201)
return redirect('document_annotate', doc.pk)
class DocumentMixin(object):
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(EnsembleMixin, DocumentMixin, DetailView):
pass
class DocumentDownloadView(EnsembleMixin, DocumentMixin, SingleObjectMixin, View):
def get(self, request, **args):
self.request = request
self.args = args
self.object = self.get_object()
#response = FileResponse(self.object.upload, content_type="application/pdf")
#return response
return redirect(self.object.upload.url)
class DocumentAnnotateView(EnsembleMixin, DocumentMixin, DetailView):
template_name = 'library/document_annotate.html'
def post(self, request, **args):
self.request = request
self.args = args
self.object = self.get_object()
data = json.loads(request.body)
self.object.sections.all().delete()
for tag, start, end in data:
#pages.sort()
#end = pages[-1] if len(pages) > 1 else None
o = self.object.sections.create(tag=tag, start=start, end=end)
return HttpResponse(status=204)
def get_context_data(self, **kwargs):
data = super(DocumentAnnotateView, self).get_context_data(**kwargs)
pages = []
for part in data['document'].sections.all():
pages.append((part.tag, part.start, part.end))
data['json_data'] = {'pageTags': pages, 'instruments': dict(INSTRUMENTS)}
return data
class PartDownloadView(EnsembleMixin, SingleObjectMixin, View):
def get(self, request, **args):
self.request = request
self.args = args
self.object = self.get_object()
result = extract_pages(self.object.doc.upload.path, self.object.doc.work.name, self.object.start, self.object.end)
download_name = f'{self.object.doc.work.name}_{self.object.instrument}.pdf'
response = FileResponse(result, content_type="application/pdf")
response['Content-Disposition'] = f'inline; filename="foo.pdf"'
return response
def get_queryset(self):
if self.request.is_admin:
parts = Section.objects.all()
else:
parts = Section.objects.filter(doc__work__collection__allowed_ensembles__ensemble=self.request.ensemble_id)
return parts.select_related('doc', 'doc__work')
+15 -2
View File
@@ -23,13 +23,17 @@ BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = None
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
DEBUG = False
ALLOWED_HOSTS = ['localhost']
# Application definition
POLYPHONIC_MODULES = [
'library'
]
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
@@ -38,8 +42,14 @@ INSTALLED_APPS = [
'django.contrib.messages',
'django.contrib.staticfiles',
'django_markdown2',
'crispy_forms',
'crispy_bulma',
'byostorage',
'interface',
]
] + POLYPHONIC_MODULES
CRISPY_ALLOWED_TEMPLATE_PACKS = ("bulma",)
CRISPY_TEMPLATE_PACK = "bulma"
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
@@ -82,6 +92,7 @@ DATABASES = {
}
}
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
# Password validation
# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators
@@ -123,3 +134,5 @@ STATIC_URL = '/static/'
# Need to set this
AWS_BUCKET = ''
MEDIA_ROOT = 'media'
+9 -1
View File
@@ -14,9 +14,17 @@ Including another URLconf
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
from django.urls import path, re_path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('interface.urls')),
#path('', include('submissions.urls')),
path('', include('library.urls')),
]
try:
import debug_toolbar
urlpatterns.append(path('__debug__', include(debug_toolbar.urls)))
except ImportError:
pass
+12 -11
View File
@@ -1,13 +1,14 @@
asgiref==3.2.10
boto3==1.15.11
botocore==1.18.11
Django==3.1.1
asgiref==3.4.1
boto3==1.18.34
botocore==1.21.34
Django==3.2.7
django-byostorage @ git+https://gitea.tfconsulting.com.au/tris/django-byostorage.git@c67d636d2457faa57644cd812ca1b5a916e23766
django-markdown2==0.3.1
jmespath==0.10.0
markdown2==2.3.9
python-dateutil==2.8.1
pytz==2020.1
s3transfer==0.3.3
six==1.15.0
sqlparse==0.3.1
urllib3==1.25.10
markdown2==2.4.1
python-dateutil==2.8.2
pytz==2021.1
s3transfer==0.5.0
six==1.16.0
sqlparse==0.4.1
urllib3==1.26.6