From 1581f56b740998c45be79ccf3b5cacd506891e9b Mon Sep 17 00:00:00 2001 From: Tris Date: Mon, 22 Mar 2021 10:30:13 +1100 Subject: [PATCH] Library working: --- README.md | 14 ++- interface/admin.py | 2 +- .../0001_squashed_0021_project_description.py | 93 ++++++++++++++++++ interface/migrations/0023_ensemble_storage.py | 19 ++++ .../migrations/0024_auto_20210312_1712.py | 23 +++++ .../migrations/0025_auto_20210312_1713.py | 23 +++++ .../migrations/0026_auto_20210313_0926.py | 18 ++++ interface/models.py | 25 ++++- interface/static/interface/css/polyphonic.css | 3 +- interface/templates/base.html | 1 + .../templates/interface/ensemble_detail.html | 8 +- .../templates/interface/project_base.html | 13 ++- .../templates/interface/project_detail.html | 37 ++++--- interface/templatetags/path_filters.py | 9 ++ interface/urls.py | 4 +- interface/views.py | 6 +- library/admin.py | 17 +++- library/migrations/0001_initial.py | 31 +++--- library/migrations/0003_orchestrations.py | 27 ++++++ library/models.py | 40 ++++++-- library/pdf_utils.py | 31 ++++-- library/storage.py | 96 ------------------- .../{playlist_view.html => item_list.html} | 33 ++++--- ...list_manage.html => item_list_manage.html} | 22 +++-- library/templates/library/project_detail.html | 1 + library/templates/library/project_menu.html | 7 +- library/templates/library/work_detail.html | 8 +- library/templates/library/work_partset.html | 31 ++++++ library/urls.py | 9 +- library/views.py | 69 +++++++++---- polyphonic/settings_default.py | 1 + requirements.txt | 1 + 32 files changed, 511 insertions(+), 211 deletions(-) create mode 100644 interface/migrations/0001_squashed_0021_project_description.py create mode 100644 interface/migrations/0023_ensemble_storage.py create mode 100644 interface/migrations/0024_auto_20210312_1712.py create mode 100644 interface/migrations/0025_auto_20210312_1713.py create mode 100644 interface/migrations/0026_auto_20210313_0926.py create mode 100644 interface/templatetags/path_filters.py create mode 100644 library/migrations/0003_orchestrations.py delete mode 100644 library/storage.py rename library/templates/library/{playlist_view.html => item_list.html} (87%) rename library/templates/library/{playlist_manage.html => item_list_manage.html} (76%) create mode 100644 library/templates/library/project_detail.html create mode 100644 library/templates/library/work_partset.html diff --git a/README.md b/README.md index db65d0e..4624996 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/interface/admin.py b/interface/admin.py index c7dd140..d97d5a4 100644 --- a/interface/admin.py +++ b/interface/admin.py @@ -7,7 +7,7 @@ class EnsembleAdmin(admin.ModelAdmin): class ProjectAdmin(admin.ModelAdmin): - list_display = ['name', 'ensemble', 'deadline', 'active', 'slug'] + list_display = ['name', 'ensemble', 'event_date', 'active', 'slug'] list_filter = ['ensemble', 'active'] class SubmissionAdmin(admin.ModelAdmin): diff --git a/interface/migrations/0001_squashed_0021_project_description.py b/interface/migrations/0001_squashed_0021_project_description.py new file mode 100644 index 0000000..51bab54 --- /dev/null +++ b/interface/migrations/0001_squashed_0021_project_description.py @@ -0,0 +1,93 @@ +# Generated by Django 3.1.1 on 2021-03-21 23:10 + +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_auto_20200904_1004'), ('interface', '0003_auto_20200905_0118'), ('interface', '0004_auto_20200905_0127'), ('interface', '0005_auto_20200905_0638'), ('interface', '0006_submission_key'), ('interface', '0007_auto_20200906_1009'), ('interface', '0008_auto_20200906_1122'), ('interface', '0009_auto_20200907_0103'), ('interface', '0010_auto_20200907_0148'), ('interface', '0011_auto_20200907_0234'), ('interface', '0012_remove_ensemble_bucket'), ('interface', '0013_auto_20200907_1455'), ('interface', '0014_auto_20200909_1016'), ('interface', '0015_resource_media_type'), ('interface', '0016_auto_20200910_2025'), ('interface', '0017_auto_20200914_0943'), ('interface', '0018_auto_20200914_1009'), ('interface', '0019_project_owner'), ('interface', '0020_auto_20201003_2103'), ('interface', '0021_project_description')] + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + 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)), + ('active', models.BooleanField(default=True)), + ], + ), + 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')), + ('title', models.CharField(default='', max_length=255)), + ], + ), + 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(default=interface.models.generate_code, max_length=9)), + ('passphrase', models.CharField(max_length=100)), + ('admins', models.ManyToManyField(related_name='ensembles', to=settings.AUTH_USER_MODEL)), + ], + ), + 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'), + ), + 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)), + ('key', models.CharField(blank=True, max_length=255)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='resources', to='interface.project')), + ('description', models.TextField(blank=True)), + ('media_type', models.CharField(choices=[('audio', 'Audio'), ('video', 'Video'), ('general', 'General')], default='*', max_length=10)), + ('visible', models.BooleanField(default=True)), + ], + ), + migrations.AddField( + model_name='project', + name='owner', + field=models.CharField(blank=True, max_length=255), + ), + 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, verbose_name='Instrument / Voice')), + ('notes', models.TextField(blank=True)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='all_submissions', to='interface.project')), + ('date', models.DateTimeField(auto_now_add=True)), + ('complete', models.BooleanField(default=False)), + ('url', models.CharField(blank=True, max_length=512)), + ('private', models.BooleanField(default=False)), + ], + ), + migrations.AddField( + model_name='project', + name='description', + field=models.TextField(blank=True), + ), + ] diff --git a/interface/migrations/0023_ensemble_storage.py b/interface/migrations/0023_ensemble_storage.py new file mode 100644 index 0000000..ae04499 --- /dev/null +++ b/interface/migrations/0023_ensemble_storage.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1.1 on 2021-03-12 02:56 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('interface', '0022_auto_20210303_2043'), + ] + + operations = [ + migrations.AddField( + model_name='ensemble', + name='storage', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='byostorage.userstorage'), + ), + ] diff --git a/interface/migrations/0024_auto_20210312_1712.py b/interface/migrations/0024_auto_20210312_1712.py new file mode 100644 index 0000000..19daf29 --- /dev/null +++ b/interface/migrations/0024_auto_20210312_1712.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.1 on 2021-03-12 06:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('interface', '0023_ensemble_storage'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='accept_submissions', + field=models.BooleanField(default=False, help_text='Allow media submissions from participants'), + ), + migrations.AddField( + model_name='project', + name='has_items', + field=models.BooleanField(default=True, help_text='Enable items to be added from the library'), + ), + ] diff --git a/interface/migrations/0025_auto_20210312_1713.py b/interface/migrations/0025_auto_20210312_1713.py new file mode 100644 index 0000000..5eadfaa --- /dev/null +++ b/interface/migrations/0025_auto_20210312_1713.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.1 on 2021-03-12 06:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('interface', '0024_auto_20210312_1712'), + ] + + operations = [ + migrations.RenameField( + model_name='project', + old_name='has_items', + new_name='enable_library', + ), + migrations.RenameField( + model_name='project', + old_name='accept_submissions', + new_name='enable_submissions', + ), + ] diff --git a/interface/migrations/0026_auto_20210313_0926.py b/interface/migrations/0026_auto_20210313_0926.py new file mode 100644 index 0000000..e8c6b19 --- /dev/null +++ b/interface/migrations/0026_auto_20210313_0926.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.1 on 2021-03-12 22:26 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('interface', '0025_auto_20210312_1713'), + ] + + operations = [ + migrations.RenameField( + model_name='project', + old_name='deadline', + new_name='event_date', + ), + ] diff --git a/interface/models.py b/interface/models.py index de74a1b..aded9d7 100644 --- a/interface/models.py +++ b/interface/models.py @@ -23,6 +23,18 @@ MEDIA_TYPES = [ ('general', "General"), ] +def rough_date(d): + days = (self.event_date - timezone.now().date()).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(days / 7, days % 7) + return in_past, f"{days} days" + + def generate_code(length=9): return "".join([ random.choice('0123456789') for _ in range(length) ]) @@ -32,6 +44,7 @@ class Ensemble(models.Model): passphrase = models.CharField(max_length=100) admins = models.ManyToManyField('auth.User', related_name='ensembles') slug = models.SlugField(max_length=100, editable=False) + storage = models.ForeignKey('byostorage.UserStorage', null=True, on_delete=models.SET_NULL) def active_projects(self): return self.projects.filter(active=True) @@ -53,9 +66,11 @@ class Project(models.Model): ensemble = models.ForeignKey(Ensemble, related_name='projects', on_delete=models.CASCADE, null=True) description = models.TextField(blank=True) active = models.BooleanField(default=True) - deadline =models.DateField(null=True, blank=True) + event_date =models.DateField(null=True, blank=True) owner = models.CharField(max_length=255, blank=True) slug = models.SlugField(max_length=100, editable=False) + enable_library = models.BooleanField(default=True, help_text="Enable items to be added from the library") + enable_submissions = models.BooleanField(default=False, help_text="Allow media submissions from participants") @property def submissions(self): @@ -65,6 +80,14 @@ class Project(models.Model): key = os.path.join(self.slug, 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() + def save(self): if not self.slug: self.slug = slugify(self.name) diff --git a/interface/static/interface/css/polyphonic.css b/interface/static/interface/css/polyphonic.css index 1afc8c3..4dadfdc 100644 --- a/interface/static/interface/css/polyphonic.css +++ b/interface/static/interface/css/polyphonic.css @@ -313,7 +313,8 @@ TD.select-cell { } DIV.selected { - background-color: #EEE; + background-color: var(--gray-blue); + color: white; border: 1px solid #999; border-radius: 5px; } \ No newline at end of file diff --git a/interface/templates/base.html b/interface/templates/base.html index 8ff933c..e12acb2 100644 --- a/interface/templates/base.html +++ b/interface/templates/base.html @@ -5,6 +5,7 @@ + {% block title %}Polyphonic{% endblock %} diff --git a/interface/templates/interface/ensemble_detail.html b/interface/templates/interface/ensemble_detail.html index 9609504..326a6d7 100644 --- a/interface/templates/interface/ensemble_detail.html +++ b/interface/templates/interface/ensemble_detail.html @@ -1,11 +1,11 @@ -{% extends "base.html" %} +{% extends "interface/project_base.html" %} -{% block content %} +{% block page %}

Projects for {{ ensemble.name }}

-
+
{{ ensemble.ensemble_code }}
diff --git a/interface/templates/interface/project_base.html b/interface/templates/interface/project_base.html index a595797..efc5742 100644 --- a/interface/templates/interface/project_base.html +++ b/interface/templates/interface/project_base.html @@ -10,25 +10,32 @@ {% endif %}

{{ project.name }}

+ {% block page %} No content {% endblock %} -{% if project %} + -{% endif %} {% endblock %} diff --git a/interface/templates/interface/project_detail.html b/interface/templates/interface/project_detail.html index 8dc466e..16fe6dc 100644 --- a/interface/templates/interface/project_detail.html +++ b/interface/templates/interface/project_detail.html @@ -2,30 +2,25 @@ {% load md2 %} - {% block page %} +{% block page %}
- {% if project.deadline %} -

In {{ project.deadline|timeuntil }}

+ {% if project.event_date %} +

{{ project.when }}

{% endif %} -

{{ project.description|markdown }}

+ +

{{ project.description|markdown }}

+ {% if project.owner %} -

Project email: {{ project.owner }}

+

Project email: {{ project.owner }}

{% endif %} - {% with sub_count=project.submissions.count %} -

There have been {{ sub_count }} submission{{ sub_count|pluralize }} so far...

- {% if sub_count %} -

Recent submissions

- - - {% for submission in project.submissions|slice:":5" %} - - - - - {% endfor %} - -
{{ submission.date|timesince }} ago{{ submission.name }} ({{ submission.instrument }})
+ + {% if project.enable_submissions %} + {% include 'submissions/project_detail.html' %} {% endif %} - {% endwith %} + + {% if project.enable_library %} + {% include 'library/project_detail.html' %} + {% endif %} +
- {% endblock %} +{% endblock %} diff --git a/interface/templatetags/path_filters.py b/interface/templatetags/path_filters.py new file mode 100644 index 0000000..804ac1f --- /dev/null +++ b/interface/templatetags/path_filters.py @@ -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) \ No newline at end of file diff --git a/interface/urls.py b/interface/urls.py index 324572c..9254873 100644 --- a/interface/urls.py +++ b/interface/urls.py @@ -11,8 +11,8 @@ urlpatterns = [ path('manage', views.ManageView.as_view(), name="manage"), path('', views.EnsembleDetailView.as_view(), name='ensemble_detail'), - path('projects/', views.ProjectDetailView.as_view(), name="project_detail"), - path('projects//submissions.mk', views.ProjectMakefileView.as_view(), name="project_makefile"), + path('projects/', views.ProjectDetailView.as_view(), name="project_detail"), + path('projects//submissions.mk', views.ProjectMakefileView.as_view(), name="project_makefile"), path('projects//page/', views.WikiView.as_view(), name="wiki"), path('projects//page//edit', views.WikiEditView.as_view(), name="wiki_edit"), diff --git a/interface/views.py b/interface/views.py index ea6bef5..af149d8 100644 --- a/interface/views.py +++ b/interface/views.py @@ -207,10 +207,10 @@ class EnsembleDetailView(EnsembleMixin, DetailView): def get_object(self): return models.Ensemble.objects.get(pk=self.request.ensemble_id) -class ProjectDetailView(EnsembleMixin, DetailView): +class ProjectDetailView(ProjectMixin, DetailView): - def get_queryset(self): - return models.Project.objects.filter(ensemble=self.request.ensemble_id) + def get_object(self): + return self.get_project() class ProjectMakefileView(EnsembleMixin, DetailView): template_name = 'interface/project_submissions.mk' diff --git a/library/admin.py b/library/admin.py index c3317e1..01930ee 100644 --- a/library/admin.py +++ b/library/admin.py @@ -2,12 +2,17 @@ from django.contrib import admin from . import models -class PlaylistInline(admin.TabularInline): - model = models.Playlist +class OrchestrationAdmin(admin.ModelAdmin): + list_display = ['name', 'ensemble'] + list_filter = ['ensemble'] + +class ItemInline(admin.TabularInline): + model = models.Item class WorkAdmin(admin.ModelAdmin): list_display = ['name', 'orchestration'] - inlines = [PlaylistInline] + list_filter = ['ensemble'] + inlines = [ItemInline] class PartInline(admin.TabularInline): model = models.Part @@ -15,12 +20,14 @@ class PartInline(admin.TabularInline): class DocumentAdmin(admin.ModelAdmin): list_display = ['work', '__str__'] + list_filter = ['work__ensemble'] inlines = [PartInline] -class PlaylistAdmin(admin.ModelAdmin): +class ItemAdmin(admin.ModelAdmin): list_display = ['project', 'work', 'order'] list_filter = ['project'] +admin.site.register(models.Orchestration, OrchestrationAdmin) admin.site.register(models.Work, WorkAdmin) admin.site.register(models.Document, DocumentAdmin) -admin.site.register(models.Playlist, PlaylistAdmin) \ No newline at end of file +admin.site.register(models.Item, ItemAdmin) \ No newline at end of file diff --git a/library/migrations/0001_initial.py b/library/migrations/0001_initial.py index 8c1d547..5747cd8 100644 --- a/library/migrations/0001_initial.py +++ b/library/migrations/0001_initial.py @@ -1,9 +1,9 @@ -# Generated by Django 3.1.1 on 2021-03-10 22:20 +# Generated by Django 3.1.1 on 2021-03-11 07:07 from django.db import migrations, models import django.db.models.deletion import library.models -import library.storage +import byostorage.cached class Migration(migrations.Migration): @@ -19,11 +19,11 @@ class Migration(migrations.Migration): name='Document', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('upload', models.FileField(storage=library.storage.RemoteCachedStorage(), upload_to=library.models.upload_filename)), + ('upload', models.FileField(storage=byostorage.cached.CachedStorage(), upload_to=library.models.doc_upload_filename)), ], ), migrations.CreateModel( - name='Playlist', + name='Item', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('order', models.SmallIntegerField(default=0)), @@ -33,27 +33,31 @@ class Migration(migrations.Migration): 'ordering': ['order', 'work'], }, ), + migrations.CreateModel( + name='Orchestration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('instruments', models.TextField()), + ('ensemble', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='orchestrations', to='interface.ensemble')), + ], + ), migrations.CreateModel( name='Work', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('slug', models.SlugField(editable=False, max_length=100)), ('name', models.CharField(max_length=255)), - ('orchestration', models.CharField(choices=[('SATB', 'SATB'), ('String Quartet', 'String Quartet'), ('Chamber Orchestra', 'Chamber Orchestra'), ('RWE', 'RWE'), ('Custom', 'Custom')], max_length=100)), ('running_time', models.IntegerField(blank=True, null=True)), ('notes', models.TextField(blank=True)), ('ensemble', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='works', to='interface.ensemble')), - ('projects', models.ManyToManyField(related_name='works', through='library.Playlist', to='interface.Project')), + ('orchestration', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='works', to='library.orchestration')), + ('projects', models.ManyToManyField(related_name='works', through='library.Item', to='interface.Project')), ], options={ 'unique_together': {('ensemble', 'slug')}, }, ), - migrations.AddField( - model_name='playlist', - name='work', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='library.work'), - ), migrations.CreateModel( name='Part', fields=[ @@ -68,6 +72,11 @@ class Migration(migrations.Migration): 'ordering': ['doc', 'start', 'pk'], }, ), + migrations.AddField( + model_name='item', + name='work', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='library.work'), + ), migrations.AddField( model_name='document', name='work', diff --git a/library/migrations/0003_orchestrations.py b/library/migrations/0003_orchestrations.py new file mode 100644 index 0000000..4f40154 --- /dev/null +++ b/library/migrations/0003_orchestrations.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1.1 on 2021-03-11 06:26 + +from django.db import migrations + +ORCHESTRATIONS = { + 'SATB': ('S', 'A', 'T', 'B'), + 'String Quartet': ('Vln1', 'Vln2', 'Vla', 'Vc'), + 'Chamber Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb', + 'Fl1', 'Fl2', 'Cl1', 'Cl2', 'Ob1', 'Ob2', 'Hn1', 'Hn2', + 'Tpt1', 'Tpt2', 'Tbn1', 'Tbn2', 'Tuba', + 'Timp', 'Drum', 'Perc'), +} + +def add_orchestrations(apps, schema_editor): + q = apps.get_model('library', 'Orchestration').objects + for name, instruments in ORCHESTRATIONS.items(): + q.create(name=name, instruments=", ".join(instruments)) + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0001_initial'), + ] + + operations = [ + migrations.RunPython(add_orchestrations, lambda apps, schema_editor: None), + ] diff --git a/library/models.py b/library/models.py index 3a8ae42..4dd5f01 100644 --- a/library/models.py +++ b/library/models.py @@ -34,7 +34,7 @@ INSTRUMENTS = [ ('Tbn', 'Trombone'), ('Tuba', 'Tuba'), ('Timp', 'Timpani'), - ('Drum', 'Drupset'), + ('Drum', 'Drumset'), ('Perc', 'Percussion'), ('Pno', 'Piano'), ('Hp', 'Harp'), @@ -58,14 +58,30 @@ def tag_to_instrument(tag): l = m.groups() return "{0} {1}".format(dict(INSTRUMENTS).get(l[0],l[0]), l[1]).strip() +class Orchestration(models.Model): + 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 Work(models.Model): ensemble = models.ForeignKey('interface.Ensemble', on_delete=models.CASCADE, related_name="works") slug = models.SlugField(max_length=100, editable=False) name = models.CharField(max_length=255) - orchestration = models.CharField(max_length=100, choices=[ (k, k) for k, v in ORCHESTRATIONS.items() ]) + orchestration = models.ForeignKey(Orchestration, null=True, on_delete=models.SET_NULL, related_name='works') running_time = models.IntegerField(null=True, blank=True) notes = models.TextField(blank=True) - projects = models.ManyToManyField('interface.Project', through='Playlist', related_name="works") + projects = models.ManyToManyField('interface.Project', through='Item', related_name="works") class Meta: unique_together = ['ensemble', 'slug'] @@ -76,8 +92,7 @@ class Work(models.Model): @property def instruments(self): - tags = ORCHESTRATIONS.get(self.orchestration, 'Custom') - return [ (tag, tag_to_instrument(tag)) for tag in tags ] + return self.orchestration.as_list() def save(self): if not self.slug: @@ -87,7 +102,11 @@ class Work(models.Model): def __str__(self): return self.name -class Playlist(models.Model): +class Item(models.Model): + """ + Item represents a Work in a Project e.g. item in set list or programme + It also allows works to be shared from one ensemble to another on a per-project basis. + """ project = models.ForeignKey('interface.Project', on_delete=models.CASCADE) work = models.ForeignKey('Work', on_delete=models.CASCADE) order = models.SmallIntegerField(default=0) @@ -98,12 +117,15 @@ class Playlist(models.Model): def __str__(self): return f"<{self.project.slug}:{self.work.slug}>" -def upload_filename(instance, filename): - return f'{instance.work.ensemble.slug}/works/{instance.work.slug}/{filename}' +def doc_upload_filename(doc, filename): + storage = doc.work.ensemble.storage + if not storage: + raise RuntimeError("Storage not set") + return f'{storage}:{doc.work.ensemble.slug}/works/{doc.work.slug}/{filename}' class Document(models.Model): work = models.ForeignKey('Work', on_delete=models.CASCADE, related_name="docs") - upload = models.FileField(upload_to=upload_filename, storage=library_storage) + upload = models.FileField(upload_to=doc_upload_filename, storage=library_storage) def __str__(self): return self.upload.name diff --git a/library/pdf_utils.py b/library/pdf_utils.py index b7ceba4..c0e5b67 100644 --- a/library/pdf_utils.py +++ b/library/pdf_utils.py @@ -1,19 +1,28 @@ import tempfile import subprocess import os.path +import string -def extract_pages(source, bookmark, start=None, end=None): +SAFECHARS = string.ascii_letters + string.digits + " _-" - return extract_and_concat([(source, bookmark, start, end)]) +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) in enumerate(items): + for i, (source, bookmark, start, end, count) in enumerate(items): + + if count == 0: + continue if start is None: sections.append(source) @@ -25,13 +34,22 @@ def extract_and_concat(items): dest = os.path.join(d.name, f'section_{i}.pdf') - cmd = ['gs', '-sDEVICE=pdfwrite', '-q', '-dBATCH', '-dNOPAUSE', + cmd = ['gs', '-sDEVICE=pdfwrite', '-dBATCH', '-dNOPAUSE', f'-dFirstPage={start}', f'-dLastPage={end}', f'-sOutputFile={dest}', source] - subprocess.run(cmd, check=True) - sections.append(dest) + 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') @@ -39,6 +57,7 @@ def extract_and_concat(items): cmd = ['gs', '-sDEVICE=pdfwrite', '-q', '-dBATCH', '-dNOPAUSE', '-sOutputFile=-'] cmd.extend(sections) + cmd.append(pdfmarks) subprocess.run(cmd, stdout=output) diff --git a/library/storage.py b/library/storage.py deleted file mode 100644 index 822e511..0000000 --- a/library/storage.py +++ /dev/null @@ -1,96 +0,0 @@ -from django.core.files.storage import Storage, get_storage_class -from django.utils.deconstruct import deconstructible -from django.conf import settings -from hashlib import sha1 -import os.path -import shutil -import tempfile -import time - -import logging -logger = logging.getLogger(__name__) - -@deconstructible -class RemoteCachedStorage(Storage): - - CACHE_EXPIRES = 30 - - def __init__(self, remote=None, cachedir=None): - if not remote: - remote = settings.CACHED_STORAGE_REMOTE - if not cachedir: - cachedir = settings.CACHED_STORAGE_DIR - self.remote = get_storage_class(remote)() - self.cachedir = cachedir - os.makedirs(self.cachedir, exist_ok=True) - self.clean() - - def _filepath(self, name): - base, ext = os.path.splitext(name) - filename = sha1(base.encode('utf8')).hexdigest() + ext - return os.path.join(self.cachedir, filename) - - def _cached(self, name): - p = self._filepath(name) - if not os.path.exists(p): - logger.debug("Caching %s to %s", name, p) - source = self.remote._open(name, 'rb') - dest = tempfile.NamedTemporaryFile(dir=self.cachedir, delete=False, prefix="_") - shutil.copyfileobj(source, dest) - source.close() - dest.close() - os.rename(dest.name, p) - now = time.time() - os.utime(p, (now, now)) - - if now > self.next_check: - self.clean() # wont get this file as we just touched it - - return p - - - def _open(self, name, mode='rb'): - p = self._cached(name) - return open(p, mode) - - def path(self, name): - return self._cached(name) - - def _save(self, name, content): - return self.remote._save(name, content) - - def delete(self, name): - return self.remote.delete(name) - - def exists(self, name): - return self.remote.exists(name) - - def listdir(self, name): - return self.remote.listdir(name) - - def size(self, name): - return self.remote.size(name) - - def url(self, name): - return self.remote.url(name) - - def get_valid_name(self, name): - return self.remote.get_valid_name(name) - - def get_available_name(self, name, max_length=None): - return self.remote.get_available_name(name, max_length) - - def get_alternative_name(self, file_root, file_ext): - return self.remote.get_alternative_name(file_root, file_ext) - - def clean(self): - now = time.time() - threshold = now - self.CACHE_EXPIRES - logger.info("Removing cached files older than %d seconds", self.CACHE_EXPIRES) - for f in os.listdir(self.cachedir): - f = os.path.join(self.cachedir, f) - s = os.stat(f) - if s.st_atime < threshold: - logger.debug("Removing %s", f) - os.unlink(f) - self.next_check = now + 300 \ No newline at end of file diff --git a/library/templates/library/playlist_view.html b/library/templates/library/item_list.html similarity index 87% rename from library/templates/library/playlist_view.html rename to library/templates/library/item_list.html index a2c782e..da6ea16 100644 --- a/library/templates/library/playlist_view.html +++ b/library/templates/library/item_list.html @@ -1,27 +1,37 @@ {% extends "interface/project_base.html" %} {% block admin %} -Change works +Change items {% endblock %} {% block page %}

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).
- You can also tweak which parts you get in the list or click on a piece for more download options. + You can also tweak which parts you get for each piece, or click on a piece for more download options.

{% csrf_token %} - - - +
+ +

+ +

+ +
+
{% for item in object_list %} + -
{{ forloop.counter }}) {{ item.work.name }} +
-
- -

- -

- -
{% endblock %} diff --git a/library/templates/library/playlist_manage.html b/library/templates/library/item_list_manage.html similarity index 76% rename from library/templates/library/playlist_manage.html rename to library/templates/library/item_list_manage.html index 4bdc2ca..dadae56 100644 --- a/library/templates/library/playlist_manage.html +++ b/library/templates/library/item_list_manage.html @@ -2,28 +2,31 @@ {% block admin %} Save -Add +Add {% endblock %} {% block page %} - - + + {% for item in object_list %} + + {% empty %} + {% endfor %}
PiecePartItemTime
{{ item.work.name }}{% firstof item.work.running_time '?' %} - +
No items
@@ -47,8 +50,13 @@ function moveItem(pk, dir) { // check the direction is sensible if (i + dir < 0 || i + dir >= items.length) return; - items[i].dataset.order = parseInt(items[i].dataset.order) + dir; - items[i+dir].dataset.order = parseInt(items[i+dir].dataset.order) - dir; + 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)) @@ -76,7 +84,7 @@ function save() { }).then((response) => { if (response.ok) { dirty=false; - window.location = "{% url 'project_playlist' project=project.pk %}"; + window.location = "{% url 'item_list' project=project.pk %}"; } else { alert("Failed: " + response.statusText) } diff --git a/library/templates/library/project_detail.html b/library/templates/library/project_detail.html new file mode 100644 index 0000000..b0046a2 --- /dev/null +++ b/library/templates/library/project_detail.html @@ -0,0 +1 @@ +

Click the 'My Music' button below to access your parts...

\ No newline at end of file diff --git a/library/templates/library/project_menu.html b/library/templates/library/project_menu.html index 940e22f..0f3a2d4 100644 --- a/library/templates/library/project_menu.html +++ b/library/templates/library/project_menu.html @@ -1 +1,6 @@ -My Music \ No newline at end of file +{% if project %} +My Music +{% endif %} +{% if request.is_admin %} +Works +{% endif %} \ No newline at end of file diff --git a/library/templates/library/work_detail.html b/library/templates/library/work_detail.html index 0d16c0c..01410f1 100644 --- a/library/templates/library/work_detail.html +++ b/library/templates/library/work_detail.html @@ -1,4 +1,5 @@ {% extends 'interface/project_base.html' %} +{% load path_filters %} {% block admin %} Upload file @@ -13,14 +14,17 @@
  • {{ part.instrument }}
  • {% endfor %} + Print part set

    Original Documents

      {% for doc in work.docs.all %}
    • - {{ doc.upload.name }} [{{ doc.parts.count }} parts] + {{ doc.upload.name|basename }} +   + [{{ doc.parts.count }} parts] {% if request.is_admin %} - + {% endif %}
    • {% endfor %} diff --git a/library/templates/library/work_partset.html b/library/templates/library/work_partset.html new file mode 100644 index 0000000..33785a7 --- /dev/null +++ b/library/templates/library/work_partset.html @@ -0,0 +1,31 @@ +{% extends "interface/project_base.html" %} + +{% block page %} +

      Works / {{ work.name }}

      + +
      + {% csrf_token %} + + + + + + + + + {% for part in work.parts %} + + + + + {% endfor %} + + + + +
      PartCopies
      {{ part.instrument }} + + +
      +
      +{% endblock %} \ No newline at end of file diff --git a/library/urls.py b/library/urls.py index bce8fbe..b9789fd 100644 --- a/library/urls.py +++ b/library/urls.py @@ -5,13 +5,14 @@ from . import views urlpatterns = [ - path('projects//works', views.PlaylistView.as_view(), name="project_playlist"), - path('projects//works/manage', views.PlaylistManageView.as_view(), name="playlist_manage"), - path('projects//works/append', views.PlaylistAddView.as_view(), name="playlist_append"), + path('projects//items', views.ProjectItemListView.as_view(), name="item_list"), + path('projects//items/manage', views.ProjectItemManageView.as_view(), name="item_list_manage"), + path('projects//items/append', views.ProjectItemAddView.as_view(), name="item_list_append"), path('library/works', views.WorkListView.as_view(), name="work_list"), - path('library/works/add', views.WorkAddView.as_view(), name="work_add"), + path('library/works/create', views.WorkAddView.as_view(), name="work_add"), path('library/works/', views.WorkDetailView.as_view(), name="work_detail"), + path('library/works//partset', views.WorkPartSetView.as_view(), name="work_partset"), path('library/works//upload', views.DocumentAddView.as_view(), name="document_add"), path('library/documents//download', views.DocumentDownloadView.as_view(), name="document_download"), path('library/documents//annotate', views.DocumentAnnotateView.as_view(), name="document_annotate"), diff --git a/library/views.py b/library/views.py index 4f67cc4..4222b6e 100644 --- a/library/views.py +++ b/library/views.py @@ -1,6 +1,6 @@ from django.shortcuts import render, redirect, resolve_url from django.views.generic.detail import DetailView, SingleObjectMixin, View -from django.views.generic.list import ListView +from django.views.generic.list import ListView, MultipleObjectMixin from django.views.generic.edit import CreateView, FormView, UpdateView from django.http import FileResponse, HttpResponse from django.db import IntegrityError @@ -9,13 +9,13 @@ import json from interface.views import EnsembleMixin, ProjectMixin from interface.models import Project -from .models import Work, Document, Part, Playlist, INSTRUMENTS -from . import forms +from .models import Work, Document, Part, INSTRUMENTS +from . import forms, models from .pdf_utils import extract_pages, extract_and_concat -class PlaylistView(ProjectMixin, ListView): - template_name = "library/playlist_view.html" - model = Playlist +class ProjectItemListView(ProjectMixin, ListView): + template_name = "library/item_list.html" + model = models.Item def post(self, request, **kwargs): @@ -43,7 +43,7 @@ class PlaylistView(ProjectMixin, ListView): continue part = Part.objects.filter(tag=tag, doc__work=pk).select_related('doc').get() - sections.append((part.doc.upload.path, part.doc.work.name, part.start, part.end)) + sections.append((part.doc.upload.path, part.doc.work.name, part.start, part.end, 1)) result = extract_and_concat(sections) @@ -55,18 +55,18 @@ class PlaylistView(ProjectMixin, ListView): def get_queryset(self): - return super(PlaylistView, self).get_queryset().select_related('project', 'work') + return super(ProjectItemListView, self).get_queryset().select_related('project', 'work') def get_context_data(self, **kwargs): - data = super(PlaylistView, self).get_context_data(**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') return data -class PlaylistManageView(ProjectMixin, ListView): - template_name = "library/playlist_manage.html" - model = Playlist +class ProjectItemManageView(ProjectMixin, ListView): + template_name = "library/item_list_manage.html" + model = models.Item def post(self, request, **kwargs): self.request = request @@ -76,19 +76,23 @@ class PlaylistManageView(ProjectMixin, ListView): q = self.get_queryset() for pk, order in data.items(): - i = q.filter(pk=pk).update(order=order) + 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(PlaylistManageView, self).get_queryset().select_related('project', 'work') + return super(ProjectItemManageView, self).get_queryset().select_related('project', 'work') -class PlaylistAddView(ProjectMixin, UpdateView): +class ProjectItemAddView(ProjectMixin, UpdateView): form_class = forms.PlaylistAddForm template_name = "interface/default_form.html" def get_success_url(self): - return resolve_url('playlist_manage', project=self.kwargs['project']) + return resolve_url('item_list_manage', project=self.kwargs['project']) def get_object(self): return self.get_project() @@ -102,6 +106,12 @@ class WorkAddView(EnsembleMixin, FormView): template_name = "interface/default_form.html" form_class = forms.WorkCreateForm + def get_form(self): + f = super(WorkAddView, self).get_form() + qs = f.fields['orchestration'].queryset + f.fields['orchestration'].queryset = qs.filter(ensemble_id__isnull=True) | qs.filter(ensemble_id=self.request.ensemble_id) + return f + def form_valid(self, form): obj = form.save(commit=False) obj.ensemble_id = self.request.ensemble_id @@ -127,6 +137,32 @@ class WorkDetailView(EnsembleMixin, DetailView): def get_queryset(self): return Work.objects.filter(ensemble=self.request.ensemble_id) +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.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): + return Work.objects.filter(ensemble_id=self.request.ensemble_id) class DocumentDetailView(EnsembleMixin, DetailView): @@ -198,6 +234,7 @@ class DocumentAnnotateView(EnsembleMixin, DetailView): def get_queryset(self): return Document.objects.filter(work__ensemble=self.request.ensemble_id).select_related('work') + class PartDownloadView(EnsembleMixin, SingleObjectMixin, View): def get(self, request, **args): diff --git a/polyphonic/settings_default.py b/polyphonic/settings_default.py index fe78487..a1d8b75 100644 --- a/polyphonic/settings_default.py +++ b/polyphonic/settings_default.py @@ -38,6 +38,7 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'django_markdown2', + 'byostorage', 'interface', 'library', ] diff --git a/requirements.txt b/requirements.txt index 7849108..93e4969 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ s3transfer==0.3.3 six==1.15.0 sqlparse==0.3.1 urllib3==1.25.10 +git+https://gitea.tfconsulting.com.au/tris/django-byostorage.git@165cca64c19176ca282147cdacc1f92c3a6ceb2f#egg=django_byostorage