Author SHA1 Message Date
tris b1ef2b9dac Switch to Django 6.0 2026-04-30 17:09:05 +10:00
tris 5c56d40bf8 Cleanup 2026-04-30 16:01:53 +10:00
tris a1341d1edc Cleanup 2025-08-29 16:40:36 +10:00
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
16 changed files with 345 additions and 210 deletions
+3
View File
@@ -1,10 +1,13 @@
__pycache__
*.pyc
*.sqlite3
*.swp
credentials.json
credentials
local_settings.py
.coverage
Session.vim
poetry.lock
/env
/data
/old
+10 -5
View File
@@ -6,20 +6,23 @@ No registration required for ensemble participants - just a one time code and pa
### 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.
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.
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
#### Bucket setup [virtual-orchestra]
Default block public access
Permissions -> CORS
```xml
<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
@@ -40,6 +43,7 @@ Permissions -> CORS
User
Create with programatic access (copy keys) and an inline policy for the bucket.
```json
{
"Version": "2012-10-17",
@@ -63,4 +67,5 @@ Create with programatic access (copy keys) and an inline policy for the bucket.
}
```
3.
3.
+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 %}
{% endblock %}
</body>
</html>
</html>
+145 -129
View File
@@ -1,17 +1,15 @@
"""
"""
""" """
# pyright: basic
from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.views.generic import RedirectView
from django.views.generic.detail import DetailView, SingleObjectMixin
from django.views.generic.list import ListView
from django.views.generic.edit import CreateView, UpdateView, FormMixin
from django.views.generic.edit import CreateView, UpdateView
from django.core.exceptions import SuspiciousOperation
from django.http import Http404, HttpResponseRedirect
from django.db.models import Q
from django.utils import timezone
from django.http.request import HttpRequest
from markdown2 import markdown
@@ -19,8 +17,10 @@ from . import models, forms
from interface.utils import check_signed_url
import logging
logger = logging.getLogger(__name__)
class AuthorizedResourceMixin(object):
"""
Handles these parts of the permission system:
@@ -29,47 +29,51 @@ class AuthorizedResourceMixin(object):
* Admin enforcing
"""
SESSION_KEY = 'authorized'
request: HttpRequest
kwargs: dict
_authorized: dict
SESSION_KEY = "authorized"
admin_required = False
def is_authorized(self):
"By default check if superuser or a signed request"
if self.request.is_admin:
#logger.debug("is_authorized: superuser")
if self.request.is_admin: # type: ignore
# logger.debug("is_authorized: superuser")
return True
if 'sig' in self.request.GET:
if "sig" in self.request.GET:
check_signed_url(self.request.get_full_path())
self.on_signed_request()
return True
return False
def on_signed_request(self):
pass
def is_authorized_key(self, resource, key, auth):
current = self.get_authorized_keys(resource).get(str(key), None)
if current is None:
#logger.debug("is_authorized_key: %s %s not in session", resource, key)
# logger.debug("is_authorized_key: %s %s not in session", resource, key)
return False
if auth == current:
return True
#logger.info("Authorisation revoked")
# logger.info("Authorisation revoked")
self.del_authorized_key(resource, key)
return False
def get_authorized_keys(self, resource):
'Returns a set of authorized keys for this resource'
"Returns a set of authorized keys for this resource"
return self._authorized.get(resource, {})
def add_authorized_key(self, resource, key, auth):
'Adds a key to the authorized list for this resource'
"Adds a key to the authorized list for this resource"
current = self.get_authorized_keys(resource)
current[str(key)] = auth
self._authorized[resource] = current
self.request.session[self.SESSION_KEY] = self._authorized
self.request.session[self.SESSION_KEY] = self._authorized # type: ignore
def del_authorized_key(self, resource, key):
logger.info("Revoking authorization for %s %s", resource, key)
@@ -80,39 +84,41 @@ class AuthorizedResourceMixin(object):
self._authorized[resource] = current
else:
self._authorized.pop(resource)
self.request.session[self.SESSION_KEY] = self._authorized
self.request.session[self.SESSION_KEY] = self._authorized # type: ignore
return True
def request_denied(self):
raise Http404("Either the given resource doesn't exist or you dont have access to it.")
raise Http404(
"Either the given resource doesn't exist or you dont have access to it."
)
def dispatch(self, request, *args, **kwargs):
self._authorized = request.session.get('authorized', {})
self._authorized = request.session.get("authorized", {})
request.is_admin = request.user.is_superuser
if not self.is_authorized():
return self.request_denied()
if self.admin_required and not request.is_admin:
return self.request_denied()
return super().dispatch(request, *args, **kwargs)
return super().dispatch(request, *args, **kwargs) # type: ignore
# TODO: RevokeResourceView - increment nonce
class ForgetResourceView(AuthorizedResourceMixin, RedirectView):
def is_authorized(self):
return True
def get_redirect_url(self, resource, key):
self.del_authorized_key(resource, key)
return "/"
return "/"
class EnsembleMixin(AuthorizedResourceMixin):
ensemble_slug_kwarg = 'ensemble'
ensemble_slug_kwarg = "ensemble"
limited_project_access = False
def is_authorized(self):
@@ -121,16 +127,16 @@ class EnsembleMixin(AuthorizedResourceMixin):
if super().is_authorized():
return True
if self.ensemble.has_admin(self.request.user):
self.request.is_admin = True
if self.ensemble.has_admin(self.request.user): # type: ignore
self.request.is_admin = True # type: ignore
return True
if self.is_authorized_key('ensemble', ensemble_slug, self.ensemble.nonce):
if self.is_authorized_key("ensemble", ensemble_slug, self.ensemble.nonce):
return True
authorized = set([ int(x) for x in self.get_authorized_keys('project').keys() ])
projects = set(self.ensemble.projects.values_list('pk', flat=True))
authorized = set([int(x) for x in self.get_authorized_keys("project").keys()])
projects = set(self.ensemble.projects.values_list("pk", flat=True))
logger.debug("is_authorized: %r & %r", authorized, projects)
if authorized & projects:
self.limited_project_access = True
@@ -138,131 +144,131 @@ class EnsembleMixin(AuthorizedResourceMixin):
return True
return False
def get_object(self):
def get_object(self, queryset=None):
return self.ensemble
class ProjectMixin(AuthorizedResourceMixin):
project_kwarg = 'project'
project_kwarg = "project"
def is_authorized(self):
project_id = self.kwargs[self.project_kwarg]
self.project = get_object_or_404(models.Project.objects.select_related('ensemble'), pk=project_id)
self.project = get_object_or_404(
models.Project.objects.select_related("ensemble"), pk=project_id
)
if super().is_authorized():
return True
# check if the current user is an admin on the ensemble
if self.project.ensemble.has_admin(self.request.user):
if self.project.ensemble.has_admin(self.request.user): # type: ignore
logger.debug("is_authorized: ensemble admin for project")
self.request.is_admin = True
return True
if self.is_authorized_key('ensemble', self.project.ensemble.pk, self.project.ensemble.nonce):
logger.debug('is_authorized: has ensemble link for project')
self.request.is_admin = True # type: ignore
return True
if self.is_authorized_key('project', project_id, self.project.nonce):
logger.debug('is_authorized: has project link')
if self.is_authorized_key(
"ensemble", self.project.ensemble.pk, self.project.ensemble.nonce
):
logger.debug("is_authorized: has ensemble link for project")
return True
if self.is_authorized_key("project", project_id, self.project.nonce):
logger.debug("is_authorized: has project link")
return True
return False
# filter any generated querysets
def get_queryset(self):
return super().get_queryset().filter(project=self.project)
return super().get_queryset().filter(project=self.project) # type: ignore
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if 'project' in self.kwargs:
context['project'] = self.project
context['modules'] = self.project.active_modules
context = super().get_context_data(**kwargs) # type: ignore
if "project" in self.kwargs:
context["project"] = self.project
context["modules"] = self.project.active_modules
return context
class CrispyFormMixin(object):
class CrispyFormMixin(object):
cancel_url = None
def get_cancel_url(self):
return self.cancel_url
""" ENSEMBLE VIEWS """
class EnsembleListView(AuthorizedResourceMixin, ListView):
class EnsembleListView(AuthorizedResourceMixin, ListView):
def is_authorized(self):
return True
def get_queryset(self):
return models.Ensemble.objects.for_user(self.request.user,
self.get_authorized_keys('ensemble').keys(),
self.get_authorized_keys('project').keys())
#ensembles = models.Ensemble.objects.all()
return models.Ensemble.objects.for_user(
self.request.user, # type: ignore
self.get_authorized_keys("ensemble").keys(),
self.get_authorized_keys("project").keys(),
)
#if self.request.is_admin:
# return ensembles
# limit to registered ensembles
#f = Q(slug__in=self.get_authorized_keys('ensemble').keys()) | Q(projects__in=self.get_authorized_keys('project').keys())
# or ensembles where the user is admin
#if self.request.user.is_authenticated:
# f |= Q(admins=self.request.user.pk)
#return ensembles.filter(f).distinct()
class EnsembleDetailView(EnsembleMixin, DetailView):
def request_denied(self):
if 'auth' in self.request.GET:
if self.request.GET['auth'] != self.ensemble.auth():
raise SuspiciousOperation("Bad ensemble link")
self.add_authorized_key('ensemble', self.ensemble.slug, self.ensemble.nonce)
return HttpResponseRedirect(resolve_url('ensemble_detail', self.ensemble.slug))
if "auth" in self.request.GET:
if self.request.GET["auth"] != self.ensemble.auth():
raise SuspiciousOperation("Bad ensemble link")
self.add_authorized_key("ensemble", self.ensemble.slug, self.ensemble.nonce)
return HttpResponseRedirect(
resolve_url("ensemble_detail", self.ensemble.slug)
)
return super().request_denied()
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
inactive = 'inactive' in self.request.GET and self.request.is_admin
inactive = "inactive" in self.request.GET and self.request.is_admin # type: ignore
projects = self.ensemble.projects.all()
if self.limited_project_access:
projects = projects.filter(pk__in=self.get_authorized_keys('project').keys())
projects = projects.filter(
pk__in=self.get_authorized_keys("project").keys()
)
if inactive:
projects = projects.order_by('-pk')
projects = projects.order_by("-pk")
else:
projects = projects.active().current()
data['inactive'] = inactive
data['object_list'] = projects
if self.request.is_admin:
data['ensemble_link'] = self.request.path + "?auth=" + self.ensemble.auth()
data["inactive"] = inactive
data["object_list"] = projects
if self.request.is_admin: # type: ignore
data["ensemble_link"] = self.request.path + "?auth=" + self.ensemble.auth()
return data
class EnsembleRevokeView(SingleObjectMixin, RedirectView):
class EnsembleRevokeView(SingleObjectMixin, RedirectView):
def get_redirect_url(self):
return
return
""" PROJECT VIEWS """
class ProjectListView(ProjectMixin, ListView):
class ProjectListView(ProjectMixin, ListView):
def is_authorized(self):
return True
def get_project_queryset(self):
return models.Project.objects.for_user(self.request.user,
self.get_authorized_keys('project'),
self.get_authorized_keys('ensemble'))
return models.Project.objects.for_user(
self.request.user, # type: ignore
self.get_authorized_keys("project"),
self.get_authorized_keys("ensemble"),
)
def get_queryset(self):
return self.get_project_queryset().current().active()
class ProjectCreateView(EnsembleMixin, CreateView):
admin_required = True
model = models.Project
@@ -270,26 +276,25 @@ class ProjectCreateView(EnsembleMixin, CreateView):
title = "Add a new project"
form_class = forms.ProjectForm
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.ensemble = self.ensemble
self.object.owner = self.request.user
self.object.owner = self.request.user # type: ignore
self.object.save()
for module in form.cleaned_data['modules']:
for module in form.cleaned_data["modules"]:
self.object.modules.create(name=module)
return redirect('project_detail', project=self.object.pk)
return redirect("project_detail", project=self.object.pk)
class ProjectDetailView(ProjectMixin, DetailView):
def request_denied(self):
if 'auth' in self.request.GET:
if self.request.GET['auth'] != self.project.auth():
if "auth" in self.request.GET:
if self.request.GET["auth"] != self.project.auth():
raise SuspiciousOperation("Bad project link")
self.add_authorized_key('project', self.project.pk, self.project.nonce)
return HttpResponseRedirect(resolve_url('project_detail', self.project.pk))
self.add_authorized_key("project", self.project.pk, self.project.nonce)
return HttpResponseRedirect(resolve_url("project_detail", self.project.pk))
return super().request_denied()
def get_object(self, queryset=None):
@@ -297,15 +302,16 @@ class ProjectDetailView(ProjectMixin, DetailView):
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
if self.request.is_admin:
#data['project_link'] = signed_url(f'{self.request.path}?nonce={self.project.nonce}')
data['project_link'] = self.request.path + "?auth=" + self.project.auth()
if self.request.is_admin: # type: ignore
# data['project_link'] = signed_url(f'{self.request.path}?nonce={self.project.nonce}')
data["project_link"] = self.request.path + "?auth=" + self.project.auth()
return data
class ProjectUpdateView(ProjectMixin, UpdateView):
admin_required = True
template_name = "interface/default_form.html"
pk_url_kwarg = 'project'
pk_url_kwarg = "project"
form_class = forms.ProjectForm
def get_object(self):
@@ -313,26 +319,26 @@ class ProjectUpdateView(ProjectMixin, UpdateView):
def get_initial(self):
data = super().get_initial()
data['modules'] = self.object.active_modules
data["modules"] = self.object.active_modules
print(data)
return data
def form_valid(self, form):
self.object = form.save()
current = set(self.object.active_modules)
desired = set(form.cleaned_data['modules'])
desired = set(form.cleaned_data["modules"])
self.object.modules.exclude(name__in=desired).delete()
for module in desired-current:
for module in desired - current:
self.object.modules.create(name=module)
return redirect('project_detail', self.kwargs['project'])
return redirect("project_detail", self.kwargs["project"])
@property
def cancel_url(self):
return resolve_url('project_detail', self.kwargs['project'])
return resolve_url("project_detail", self.kwargs["project"])
# Old Makefile from submission module
#class ProjectMakefileView(EnsembleMixin, DetailView):
# class ProjectMakefileView(EnsembleMixin, DetailView):
# template_name = 'interface/project_submissions.mk'
# content_type = 'text/plain'
#
@@ -359,29 +365,32 @@ class ProjectUpdateView(ProjectMixin, UpdateView):
""" WIKI VIEWS """
class WikiView(ProjectMixin, DetailView):
template_name = 'interface/wiki.html'
template_name = "interface/wiki.html"
model = models.WikiPage
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
data['wiki_html'] = markdown(self.object.markdown)
data["wiki_html"] = markdown(self.object.markdown)
return data
class WikiCreateView(ProjectMixin, CreateView):
admin_required = True
model = models.WikiPage
template_name = 'interface/default_form.html'
template_name = "interface/default_form.html"
form_class = forms.WikiForm
def cancel_url(self):
return resolve_url('project_detail', self.kwargs['project'])
return resolve_url("project_detail", self.kwargs["project"])
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.project = self.project
self.object.save()
return redirect('wiki', project=self.object.project_id, pk=self.object.pk)
return redirect("wiki", project=self.object.project_id, pk=self.object.pk)
class WikiEditView(ProjectMixin, UpdateView):
admin_required = True
@@ -389,46 +398,53 @@ class WikiEditView(ProjectMixin, UpdateView):
form_class = forms.WikiForm
def cancel_url(self):
return resolve_url('wiki', self.kwargs['project'], self.kwargs['pk'])
return resolve_url("wiki", self.kwargs["project"], self.kwargs["pk"])
""" RESOURCE VIEWS """
class ResourceCreateView(ProjectMixin, CreateView):
admin_required = True
model = models.Resource
form_class = forms.ResourceForm
template_name = 'interface/default_form.html'
template_name = "interface/default_form.html"
title = "Add a new resource"
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.project = self.project
self.object.save()
return redirect('resource_upload', project=self.object.project_id, pk=self.object.pk)
return redirect(
"resource_upload", project=self.object.project_id, pk=self.object.pk
)
class ResourceUploadView(ProjectMixin, UpdateView):
admin_required = True
model = models.Resource
fields = ['file']
template_name = 'interface/default_form.html'
fields = ["file"]
template_name = "interface/default_form.html"
def get_success_url(self):
return resolve_url('resource_list', project=self.kwargs['project'])
return resolve_url("resource_list", project=self.kwargs["project"])
class ResourceListView(ProjectMixin, ListView):
model = models.Resource
def get_queryset(self):
qs = super().get_queryset()
if not self.request.is_admin:
if not self.request.is_admin: # type: ignore
qs = qs.filter(visible=True)
return qs
class ResourceEditView(ProjectMixin, UpdateView):
admin_required = True
model = models.Resource
fields = ['name', 'description', 'visible']
template_name = 'interface/default_form.html'
fields = ["name", "description", "visible"]
template_name = "interface/default_form.html"
def get_success_url(self):
return resolve_url('resource_list', project=self.kwargs['project'])
return resolve_url("resource_list", project=self.kwargs["project"])
@@ -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.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
import re
@@ -44,13 +43,13 @@ class Orchestration(models.Model):
def as_list(self):
tags = [ t.strip() for t in self.instruments.split(' ') ]
return [ (t, MusicTag.from_tag(t)) for t in tags if t ]
def tag_order(self):
tags = [ t.strip() for t in self.instruments.split(' ') if t ]
order = {'score': 0}
for i, t in enumerate(tags):
order.setdefault(t.strip('-0123456789'), i*2+1)
return order
def sorter(self):
@@ -79,7 +78,6 @@ class ProjectItem(models.Model):
approved_by = models.ForeignKey('auth.User', on_delete=models.CASCADE)
order = models.SmallIntegerField(default=0)
section = models.CharField(max_length=100, blank=True)
class Meta:
ordering = ['order', 'work']
@@ -93,16 +91,18 @@ class Collection(models.Model):
"""
name = models.CharField(max_length=255,
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")
administrators = models.ManyToManyField('auth.User', related_name="collections",
administrators = models.ManyToManyField('auth.User', related_name="collections",
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)
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")
notes = models.TextField(blank=True,
notes = models.TextField(blank=True,
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,
help_text="Increment this to reset the authentication links")
@@ -116,8 +116,8 @@ class Collection(models.Model):
@property
def genres(self):
return self.meta('genre')
return self.meta('genre')
def has_administrator(self, user):
if not user.is_authenticated:
return False
@@ -130,7 +130,7 @@ class Collection(models.Model):
def auth(self):
return sign_data(f'{self.pk}-{self.nonce}', 12)
def __str__(self):
return self.name
@@ -174,7 +174,7 @@ class Work(models.Model):
(LICENCE_PERUSAL, 'Perusal Licence'),
(LICENCE_NONE, 'Internal use only'),
)
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")
@@ -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")
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 mm:ss format")
notes = models.TextField(blank=True)
@@ -219,7 +219,7 @@ class Work(models.Model):
#return [ s[1] for s in sections ]
sections = list(dict(sections).items()) # primitive unique()
return sections
def pdfs(self):
return self.docs.filter(doctype=Document.DOCTYPE_PDF)
@@ -304,7 +304,7 @@ class WorkMeta(models.Model):
('style', 'Style'),
('orchestration', 'Orchestration'),
)
work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name='meta_info')
name = models.SlugField(max_length=20, choices=META_CHOICES)
value = models.CharField(max_length=255)
@@ -348,7 +348,7 @@ class Document(models.Model):
def delete(self, *args, **kwargs):
self.upload.delete(save=False)
return super().delete(*args, **kwargs)
def filename(self):
return os.path.basename(self.upload.name)
@@ -359,7 +359,7 @@ class Section(models.Model):
"""
Section is a tagged portion of a Document
"""
PAGE_AUTO = 0
PAGE_LEFT = 1
PAGE_RIGHT = 2
@@ -396,7 +396,7 @@ class Section(models.Model):
def filename(self):
return slugify(f'{self.doc.work.name} - {self.name}').title() + '.pdf'
@property
@property
def pagerange(self):
if self.start:
if self.end:
@@ -405,4 +405,5 @@ class Section(models.Model):
return "all"
def __str__(self):
return self.name
return self.name
@@ -139,7 +139,7 @@
crossorigin="anonymous"></script>
{{ json_data|json_script:"data" }}
<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.
let pdfjsLib = window['pdfjs-dist/build/pdf'];
@@ -447,4 +447,4 @@
window.addEventListener('beforeunload', checkSaved);
</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 %}
</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">
<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">
<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="box">
<h4 class="subtitle is-size-4">
@@ -79,8 +79,6 @@
{% 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>
@@ -206,4 +204,4 @@
</script>
{% endblock %}
{% endblock %}
{% endblock %}
@@ -1,10 +1,11 @@
<h3 class="subtitle">{{ work.name }}</h3>
<div class="block">
<div class="block tags">
{% 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 %}
</div>
<!--
<h3 class="subtitle">Files</h3>
<table class="table is-narrow is-fullwidth">
<tbody>
@@ -19,6 +20,7 @@
{% endfor %}
</tbody>
</table>
-->
<p>
<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>/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/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>/import', api.WorkImportView.as_view(), name="work_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.views.generic import TemplateView
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, DeleteView
@@ -18,6 +19,7 @@ import re
from interface.views import EnsembleMixin, ProjectMixin, AuthorizedResourceMixin
from interface.models import Project
from interface.utils import signed_url
from library.models import Collection, Work, Document, Section
from library.music_tags import MUSIC_TAGS, MusicTag, auto_tag
from library import forms, models
@@ -416,9 +418,11 @@ class DocumentDownloadView(DocumentMixin, SingleObjectMixin, View):
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)
if request.GET.get('method') == 'direct':
return redirect(self.object.upload.url)
response = FileResponse(self.object.upload.open('rb'), content_type="application/pdf")
return response
class DocumentAnnotateView(DocumentMixin, DetailView):
template_name = 'library/document_annotate.html'
@@ -446,6 +450,8 @@ class DocumentAnnotateView(DocumentMixin, DetailView):
for part in data['document'].sections.all():
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)}
return data
@@ -479,4 +485,15 @@ class PartDownloadView(CollectionMixin, SingleObjectMixin, View):
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:
from .local_settings import *
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
botocore==1.21.34
certifi==2022.12.7
charset-normalizer==2.1.1
crispy-bulma==0.8.0
Django==3.2.7
#-e git+ssh://git@gitea.tfconsulting.com.au/tris/django-byostorage.git@227ce5fccb5864657f080c25528263b655c2b6b7#egg=django_byostorage
git+https://gitea.tfconsulting.com.au/tris/django-byostorage.git
Django==5.1
django-byostorage @ git+https://gitea.tfconsulting.com.au/tris/django-byostorage.git@9903bb00888f20dfd2d39754e5ee22eeb5f36298
django-crispy-forms==1.14.0
django-markdown2==0.3.1
django-rest-framework==0.1.0
@@ -14,11 +13,14 @@ django-storages==1.13.1
django_debug_toolbar==3.8.1
djangorestframework==3.14.0
djantic==0.7.0
greenlet==3.0.3
idna==3.4
jmespath==0.10.0
markdown2==2.4.1
msgpack==1.0.8
pydantic==1.10.2
pydantic-django==0.1.1
pynvim==0.5.0
python-dateutil==2.8.2
pytz==2021.1
requests==2.28.1
+34
View File
@@ -0,0 +1,34 @@
[project]
name = "polyphonic"
version = "0.8.3"
description = "Polyphonic Ensemble Manager"
authors = [
{name = "Tris Forster",email = "tris@tfconsulting.com.au"}
]
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"django (==6.0.4)",
"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"
[tool.poetry.scripts]
manage = "manage:main"
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"