Author SHA1 Message Date
tris 4d964291b2 Fixed poetry deps 2025-08-29 12:51:01 +10:00
tris ee5305ba6c Merge branch 'master' of gitea.tfconsulting.com.au:projects/polyphonic 2025-08-29 11:39:58 +10:00
tris 147c84550c Switching to poetry 2025-08-29 11:37:57 +10:00
Tris Forster 596445061f Upgraded to Django 5.1 2024-08-26 12:06:18 +10:00
Tris Forster 02858a76c0 Cleanup 2024-08-05 15:49:39 +10:00
Tris Forster 1bcec919cf PEP Cleanup 2024-08-05 12:53:58 +10:00
Tris Forster 78789c02ed Minor UI Tweaks 2024-06-21 15:42:09 +10:00
14 changed files with 185 additions and 76 deletions
+2
View File
@@ -1,10 +1,12 @@
__pycache__ __pycache__
*.pyc *.pyc
*.sqlite3 *.sqlite3
*.swp
credentials.json credentials.json
credentials credentials
local_settings.py local_settings.py
.coverage .coverage
Session.vim
/env /env
/data /data
/old /old
+19
View File
@@ -0,0 +1,19 @@
## Polyphonic TODO
## Core interface
* Shift from crispy forms to native component templates
* Make long running calls async (Django 5)
* Deprecate Django 4 portions
### Library App
* Remove music tags and replace with strings vn1 -> 'Violin 1'
* GDrive selector
* Move upload to modal from 'Upload' button
* Tagging app - migrate to AlpineJS
* Allow other tags (movements, sections, pieces)
### Submissions App
* None currently pending
+1 -1
View File
@@ -55,4 +55,4 @@
{% block scripts %} {% block scripts %}
{% endblock %} {% endblock %}
</body> </body>
</html> </html>
@@ -0,0 +1,23 @@
# Generated by Django 5.0.6 on 2024-06-18 05:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0014_auto_20230223_1422'),
]
operations = [
migrations.AddField(
model_name='collection',
name='settings',
field=models.JSONField(default=dict, help_text='Storage specific settings'),
),
migrations.AlterField(
model_name='document',
name='doctype',
field=models.PositiveSmallIntegerField(choices=[(1, 'PDF'), (2, 'Audio'), (3, 'Video'), (4, 'Misc')], default=1),
),
]
+21 -20
View File
@@ -5,7 +5,6 @@ from django.db import models
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.functional import cached_property 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 django.db.models import Q, Count, Min, Max
import re import re
@@ -44,13 +43,13 @@ class Orchestration(models.Model):
def as_list(self): def as_list(self):
tags = [ t.strip() for t in self.instruments.split(' ') ] tags = [ t.strip() for t in self.instruments.split(' ') ]
return [ (t, MusicTag.from_tag(t)) for t in tags if t ] return [ (t, MusicTag.from_tag(t)) for t in tags if t ]
def tag_order(self): def tag_order(self):
tags = [ t.strip() for t in self.instruments.split(' ') if t ] tags = [ t.strip() for t in self.instruments.split(' ') if t ]
order = {'score': 0} order = {'score': 0}
for i, t in enumerate(tags): for i, t in enumerate(tags):
order.setdefault(t.strip('-0123456789'), i*2+1) order.setdefault(t.strip('-0123456789'), i*2+1)
return order return order
def sorter(self): def sorter(self):
@@ -79,7 +78,6 @@ class ProjectItem(models.Model):
approved_by = models.ForeignKey('auth.User', on_delete=models.CASCADE) approved_by = models.ForeignKey('auth.User', on_delete=models.CASCADE)
order = models.SmallIntegerField(default=0) order = models.SmallIntegerField(default=0)
section = models.CharField(max_length=100, blank=True) section = models.CharField(max_length=100, blank=True)
class Meta: class Meta:
ordering = ['order', 'work'] ordering = ['order', 'work']
@@ -93,16 +91,18 @@ class Collection(models.Model):
""" """
name = models.CharField(max_length=255, name = models.CharField(max_length=255,
help_text="Name of the collection") help_text="Name of the collection")
prefix = models.SlugField(max_length=30, default="default", prefix = models.CharField(max_length=255, default="default",
help_text="Folder to store works in") help_text="Folder to store works in")
administrators = models.ManyToManyField('auth.User', related_name="collections", administrators = models.ManyToManyField('auth.User', related_name="collections",
help_text="Administrators for this collection") help_text="Administrators for this collection")
location = models.CharField(max_length=100, location = models.CharField(max_length=100,
help_text="Physical location (institution, town...)", blank=True) help_text="Physical location (institution, town...)", blank=True)
storage = models.ForeignKey('byostorage.UserStorage', on_delete=models.CASCADE, null=True, blank=True, storage = models.ForeignKey('byostorage.UserStorage', on_delete=models.CASCADE, null=True, blank=True,
help_text="User storage for documents") help_text="User storage for documents")
notes = models.TextField(blank=True, notes = models.TextField(blank=True,
help_text="Publicly visible notes about collection and loans policy (markdown format)") help_text="Publicly visible notes about collection and loans policy (markdown format)")
settings = models.JSONField(default=dict, blank=True,
help_text="Storage specific settings")
nonce = models.SmallIntegerField(default=1, nonce = models.SmallIntegerField(default=1,
help_text="Increment this to reset the authentication links") help_text="Increment this to reset the authentication links")
@@ -116,8 +116,8 @@ class Collection(models.Model):
@property @property
def genres(self): def genres(self):
return self.meta('genre') return self.meta('genre')
def has_administrator(self, user): def has_administrator(self, user):
if not user.is_authenticated: if not user.is_authenticated:
return False return False
@@ -130,7 +130,7 @@ class Collection(models.Model):
def auth(self): def auth(self):
return sign_data(f'{self.pk}-{self.nonce}', 12) return sign_data(f'{self.pk}-{self.nonce}', 12)
def __str__(self): def __str__(self):
return self.name return self.name
@@ -174,7 +174,7 @@ class Work(models.Model):
(LICENCE_PERUSAL, 'Perusal Licence'), (LICENCE_PERUSAL, 'Perusal Licence'),
(LICENCE_NONE, 'Internal use only'), (LICENCE_NONE, 'Internal use only'),
) )
name = models.CharField(max_length=255, help_text="Original name of the work") name = models.CharField(max_length=255, help_text="Original name of the work")
edition = models.CharField(max_length=255, blank=True, edition = models.CharField(max_length=255, blank=True,
help_text="Edition details to distinguish multiple versions") help_text="Edition details to distinguish multiple versions")
@@ -191,7 +191,7 @@ class Work(models.Model):
code = models.CharField(max_length=100, blank=True, help_text="Collection specific code or number. Will be auto-generated if not supplied") code = models.CharField(max_length=100, blank=True, help_text="Collection specific code or number. Will be auto-generated if not supplied")
licence = models.PositiveSmallIntegerField(choices=LICENCE_TYPES, default=6, help_text="Copyright status") 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") max_projects = models.IntegerField(default=1, help_text="How many active projects can this work be attached to")
# Extra info # Extra info
running_time = models.DurationField(null=True, blank=True, help_text="Running time in mm:ss format") running_time = models.DurationField(null=True, blank=True, help_text="Running time in mm:ss format")
notes = models.TextField(blank=True) notes = models.TextField(blank=True)
@@ -219,7 +219,7 @@ class Work(models.Model):
#return [ s[1] for s in sections ] #return [ s[1] for s in sections ]
sections = list(dict(sections).items()) # primitive unique() sections = list(dict(sections).items()) # primitive unique()
return sections return sections
def pdfs(self): def pdfs(self):
return self.docs.filter(doctype=Document.DOCTYPE_PDF) return self.docs.filter(doctype=Document.DOCTYPE_PDF)
@@ -304,7 +304,7 @@ class WorkMeta(models.Model):
('style', 'Style'), ('style', 'Style'),
('orchestration', 'Orchestration'), ('orchestration', 'Orchestration'),
) )
work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name='meta_info') work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name='meta_info')
name = models.SlugField(max_length=20, choices=META_CHOICES) name = models.SlugField(max_length=20, choices=META_CHOICES)
value = models.CharField(max_length=255) value = models.CharField(max_length=255)
@@ -348,7 +348,7 @@ class Document(models.Model):
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
self.upload.delete(save=False) self.upload.delete(save=False)
return super().delete(*args, **kwargs) return super().delete(*args, **kwargs)
def filename(self): def filename(self):
return os.path.basename(self.upload.name) return os.path.basename(self.upload.name)
@@ -359,7 +359,7 @@ class Section(models.Model):
""" """
Section is a tagged portion of a Document Section is a tagged portion of a Document
""" """
PAGE_AUTO = 0 PAGE_AUTO = 0
PAGE_LEFT = 1 PAGE_LEFT = 1
PAGE_RIGHT = 2 PAGE_RIGHT = 2
@@ -396,7 +396,7 @@ class Section(models.Model):
def filename(self): def filename(self):
return slugify(f'{self.doc.work.name} - {self.name}').title() + '.pdf' return slugify(f'{self.doc.work.name} - {self.name}').title() + '.pdf'
@property @property
def pagerange(self): def pagerange(self):
if self.start: if self.start:
if self.end: if self.end:
@@ -405,4 +405,5 @@ class Section(models.Model):
return "all" return "all"
def __str__(self): def __str__(self):
return self.name return self.name
@@ -139,7 +139,7 @@
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
{{ json_data|json_script:"data" }} {{ json_data|json_script:"data" }}
<script type="text/javascript"> <script type="text/javascript">
let url = "{{ document.upload.url|safe }}"; let url = "{% url 'document_download' collection.pk document.pk %}";
// Loaded via <script> tag, create shortcut to access PDF.js exports. // Loaded via <script> tag, create shortcut to access PDF.js exports.
let pdfjsLib = window['pdfjs-dist/build/pdf']; let pdfjsLib = window['pdfjs-dist/build/pdf'];
@@ -447,4 +447,4 @@
window.addEventListener('beforeunload', checkSaved); window.addEventListener('beforeunload', checkSaved);
</script> </script>
{% endblock %} {% endblock %}
@@ -0,0 +1,12 @@
{% load path_filters %}
<div>
<h3>Listing for {{ collection }}</h3>
<ul>
{% for item in folders %}
<li><a href="{% url 'storage_browser_folder' collection=collection.pk folder=item %}">{{ item|basename }}</a></li>
{% endfor %}
{% for item in files %}
<li>{{ item|basename }}</li>
{% endfor %}
</ul>
</div>
+38 -40
View File
@@ -25,46 +25,46 @@
{% endfor %} {% endfor %}
</h3> </h3>
<p class="subtitle">{% firstof work.composer "Unattributed" %}{% if work.edition %} - {{ work.edition }}{% endif %}</p> <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">
<table class="table">
<tr>
<th>Location:</th><td><a href="{% url 'collection_work_list' work.collection.pk %}">{{ work.collection }}</a> [{{ work.identifier }}]</td>
<th>Orchestration:</th><td>{{ work.orchestration }}</td>
</tr><tr>
<th>Running time:</th><td>{% firstof work.duration 'Unknown' %}</td>
<th>Licence:</th><td>{{ work.get_licence_display }}</td>
</tr><tr>
<td colspan="4">
{% for meta in work.meta %}
<a href="{% url 'collection_work_list' work.collection.pk %}?filter={{ meta.name}}:{{ meta.value }}" class="tag" >
{{ meta.get_name_display }}:
{{ meta.value }}
</a>
{% endfor %}
</td>
</tr>
</table>
{% if work.parent %}
<p>From <a href="{% url 'work_detail' work.parent.collection.pk 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.collection.pk related.pk %}">{{ related.name }} - {{ related.composer }}</a></li>
{% endfor %}
</ul>
{% endif %}
</section>
<section class="block"> <section class="block">
<div class="columns"> <div class="columns">
<div class="column is-half">
<p class="block">{{ work.notes }}</p>
<p class="block">
<table class="table">
<tr>
<th>Location:</th><td><a href="{% url 'collection_work_list' work.collection.pk %}">{{ work.collection }}</a> [{{ work.identifier }}]</td>
<th>Orchestration:</th><td>{{ work.orchestration }}</td>
</tr><tr>
<th>Running time:</th><td>{% firstof work.duration 'Unknown' %}</td>
<th>Licence:</th><td>{{ work.get_licence_display }}</td>
</tr><tr>
<td colspan="4">
{% for meta in work.meta %}
<a href="{% url 'collection_work_list' work.collection.pk %}?filter={{ meta.name}}:{{ meta.value }}" class="tag" >
{{ meta.get_name_display }}:
{{ meta.value }}
</a>
{% endfor %}
</td>
</tr>
</table>
{% if work.parent %}
<p>From <a href="{% url 'work_detail' work.parent.collection.pk 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.collection.pk related.pk %}">{{ related.name }} - {{ related.composer }}</a></li>
{% endfor %}
</ul>
{% endif %}
</div>
<div class="column is-half"> <div class="column is-half">
<div class="box"> <div class="box">
<h4 class="subtitle is-size-4"> <h4 class="subtitle is-size-4">
@@ -79,8 +79,6 @@
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
</div>
<div class="column is-half">
<div class="box"> <div class="box">
<h4 class="subtitle is-size-4"> <h4 class="subtitle is-size-4">
<span class="icon"><i class="fas fa-print"></i></span> <span class="icon"><i class="fas fa-print"></i></span>
@@ -206,4 +204,4 @@
</script> </script>
{% endblock %} {% endblock %}
{% endblock %} {% endblock %}
@@ -1,10 +1,11 @@
<h3 class="subtitle">{{ work.name }}</h3> <h3 class="subtitle">{{ work.name }}</h3>
<div class="block"> <div class="block tags">
{% for tag, name in work.digital_parts %} {% for tag, name in work.digital_parts %}
<a class="tag is-info" href="{% url 'work_download' work.collection_id work.pk %}?tag={{ tag }}">{{ name }}</a> <a class="tag is-info" href="{% url 'work_download' work.collection_id work.pk %}?tag={{ tag }}" target="polyphonic_parts">{{ name }}</a>
{% endfor %} {% endfor %}
</div> </div>
<!--
<h3 class="subtitle">Files</h3> <h3 class="subtitle">Files</h3>
<table class="table is-narrow is-fullwidth"> <table class="table is-narrow is-fullwidth">
<tbody> <tbody>
@@ -19,6 +20,7 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
-->
<p> <p>
<a href="{% url 'work_detail' work.collection_id work.pk %}">More details...</a> <a href="{% url 'work_detail' work.collection_id work.pk %}">More details...</a>
+3
View File
@@ -35,10 +35,13 @@ urlpatterns = [
path('collections/<int:collection>/docs/<int:pk>/annotate', views.DocumentAnnotateView.as_view(), name="document_annotate"), path('collections/<int:collection>/docs/<int:pk>/annotate', views.DocumentAnnotateView.as_view(), name="document_annotate"),
path('collections/<int:collection>/download/<int:section>/<str:filename>', views.PartDownloadView.as_view(), name="part_download"), path('collections/<int:collection>/download/<int:section>/<str:filename>', views.PartDownloadView.as_view(), name="part_download"),
path('collections/<int:collection>/browse', views.StorageBrowserView.as_view(), name="storage_browser"),
path('collections/<int:collection>/browse/<path:folder>', views.StorageBrowserView.as_view(), name="storage_browser_folder"),
#path('api/', include(router.urls)) #path('api/', include(router.urls))
path('api/collections/<int:pk>', api.CollectionExportView.as_view(), name="collection_export"), path('api/collections/<int:pk>', api.CollectionExportView.as_view(), name="collection_export"),
path('api/collections/<int:collection>/works/<int:pk>', api.WorkExportView.as_view(), name="work_export"), path('api/collections/<int:collection>/works/<int:pk>', api.WorkExportView.as_view(), name="work_export"),
path('api/collections/<int:collection>/import', api.WorkImportView.as_view(), name="work_import"), path('api/collections/<int:collection>/import', api.WorkImportView.as_view(), name="work_import"),
path('api/collections/<int:collection>/bulk_import', api.CollectionImportView.as_view(), name="collection_import"), path('api/collections/<int:collection>/bulk_import', api.CollectionImportView.as_view(), name="collection_import"),
] ]
+21 -4
View File
@@ -1,4 +1,5 @@
from django.shortcuts import get_object_or_404, redirect, resolve_url from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.views.generic import TemplateView
from django.views.generic.detail import DetailView, SingleObjectMixin, View from django.views.generic.detail import DetailView, SingleObjectMixin, View
from django.views.generic.list import ListView, MultipleObjectMixin from django.views.generic.list import ListView, MultipleObjectMixin
from django.views.generic.edit import CreateView, FormView, UpdateView, DeleteView from django.views.generic.edit import CreateView, FormView, UpdateView, DeleteView
@@ -18,6 +19,7 @@ import re
from interface.views import EnsembleMixin, ProjectMixin, AuthorizedResourceMixin from interface.views import EnsembleMixin, ProjectMixin, AuthorizedResourceMixin
from interface.models import Project from interface.models import Project
from interface.utils import signed_url
from library.models import Collection, Work, Document, Section from library.models import Collection, Work, Document, Section
from library.music_tags import MUSIC_TAGS, MusicTag, auto_tag from library.music_tags import MUSIC_TAGS, MusicTag, auto_tag
from library import forms, models from library import forms, models
@@ -416,9 +418,11 @@ class DocumentDownloadView(DocumentMixin, SingleObjectMixin, View):
self.args = args self.args = args
self.object = self.get_object() self.object = self.get_object()
#response = FileResponse(self.object.upload, content_type="application/pdf") if request.GET.get('method') == 'direct':
#return response return redirect(self.object.upload.url)
return redirect(self.object.upload.url)
response = FileResponse(self.object.upload.open('rb'), content_type="application/pdf")
return response
class DocumentAnnotateView(DocumentMixin, DetailView): class DocumentAnnotateView(DocumentMixin, DetailView):
template_name = 'library/document_annotate.html' template_name = 'library/document_annotate.html'
@@ -446,6 +450,8 @@ class DocumentAnnotateView(DocumentMixin, DetailView):
for part in data['document'].sections.all(): for part in data['document'].sections.all():
pages.append((part.tag, part.start, part.end)) pages.append((part.tag, part.start, part.end))
data['url'] = signed_url('document_download', collection=data['collection'].pk, pk=data['document'].pk)
data['json_data'] = {'pageTags': pages, 'instruments': dict(MUSIC_TAGS)} data['json_data'] = {'pageTags': pages, 'instruments': dict(MUSIC_TAGS)}
return data return data
@@ -479,4 +485,15 @@ class PartDownloadView(CollectionMixin, SingleObjectMixin, View):
def get_object(self): def get_object(self):
return Section.objects.filter(doc__work__collection=self.collection).select_related('doc', 'doc__work').get(pk=self.kwargs['section']) return Section.objects.filter(doc__work__collection=self.collection).select_related('doc', 'doc__work').get(pk=self.kwargs['section'])
class StorageBrowserView(CollectionMixin, TemplateView):
template_name = "library/storage_browser.html"
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
folder = self.kwargs.get('folder') or data['collection'].prefix
data['folders'], data['files'] = data['collection'].storage.instance().listdir(folder)
return data
+3 -3
View File
@@ -1,7 +1,7 @@
from .default_settings import *
try: try:
from .local_settings import * from .local_settings import *
except ImportError: except ImportError:
from .default_settings import * pass
INSTALLED_APPS += POLYPHONIC_MODULES
INSTALLED_APPS += POLYPHONIC_MODULES
+6 -4
View File
@@ -1,12 +1,11 @@
asgiref==3.4.1 asgiref==3.8.1
boto3==1.18.34 boto3==1.18.34
botocore==1.21.34 botocore==1.21.34
certifi==2022.12.7 certifi==2022.12.7
charset-normalizer==2.1.1 charset-normalizer==2.1.1
crispy-bulma==0.8.0 crispy-bulma==0.8.0
Django==3.2.7 Django==5.1
#-e git+ssh://git@gitea.tfconsulting.com.au/tris/django-byostorage.git@227ce5fccb5864657f080c25528263b655c2b6b7#egg=django_byostorage django-byostorage @ git+https://gitea.tfconsulting.com.au/tris/django-byostorage.git@9903bb00888f20dfd2d39754e5ee22eeb5f36298
git+https://gitea.tfconsulting.com.au/tris/django-byostorage.git
django-crispy-forms==1.14.0 django-crispy-forms==1.14.0
django-markdown2==0.3.1 django-markdown2==0.3.1
django-rest-framework==0.1.0 django-rest-framework==0.1.0
@@ -14,11 +13,14 @@ django-storages==1.13.1
django_debug_toolbar==3.8.1 django_debug_toolbar==3.8.1
djangorestframework==3.14.0 djangorestframework==3.14.0
djantic==0.7.0 djantic==0.7.0
greenlet==3.0.3
idna==3.4 idna==3.4
jmespath==0.10.0 jmespath==0.10.0
markdown2==2.4.1 markdown2==2.4.1
msgpack==1.0.8
pydantic==1.10.2 pydantic==1.10.2
pydantic-django==0.1.1 pydantic-django==0.1.1
pynvim==0.5.0
python-dateutil==2.8.2 python-dateutil==2.8.2
pytz==2021.1 pytz==2021.1
requests==2.28.1 requests==2.28.1
+30
View File
@@ -0,0 +1,30 @@
[project]
name = "polyphonic"
version = "0.1.0"
description = "Polyphonic Ensemble Manager"
authors = [
{name = "Tris Forster",email = "tris@tfconsulting.com.au"}
]
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"django (==5.1)",
"django-crispy-forms",
"django-markdown2",
"django-rest-framework",
"crispy-bulma (>=0.12.0,<0.13.0)",
"django-byostorage @ git+https://gitea.tfconsulting.com.au/tris/django-byostorage.git@9903bb00888f20dfd2d39754e5ee22eeb5f36298",
"requests (>=2.32.5,<3.0.0)",
"django-storages (>=1.14.6,<2.0.0)",
"boto3 (>=1.40.20,<2.0.0)"
]
[tool.poetry]
packages = [{include = "*", from="app"}]
[tool.poetry.group.dev.dependencies]
django-debug-toolbar = "5.2"
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"