106 Commits
Author SHA1 Message Date
tris 1301d19c08 Switched to material icons 2026-06-16 09:18:21 +10:00
tris ca3effcad1 Fixed my music page 2026-05-29 22:47:33 +10:00
tris 5468f6d3e7 Added handling of gdrive resource_keys - fixes #16 2026-05-27 22:46:08 +10:00
tris e46d8145a7 Install from git 2026-05-27 09:35:15 +10:00
tris 3444cdbc59 Merge pull request 'gdrive' (#15) from gdrive into master
Reviewed-on: #15
2026-05-24 16:45:13 +10:00
tris 6e77474d15 Couple of fixes 2026-05-24 16:42:51 +10:00
tris 72bad08a61 Merge branch 'master' into gdrive 2026-05-24 14:24:34 +10:00
tris b217fbea5e Sorted out linting 2026-05-24 14:23:59 +10:00
tris cec64ecd2f Working with manual sync 2026-05-24 13:51:23 +10:00
tris d30005d5b6 Indexing bug fix 2026-05-24 11:48:42 +10:00
tris ef981e06e8 Shared gdrive access 2026-05-24 11:35:18 +10:00
tris 4cef5800bc Merge pull request 'Repackaged as a single project' (#13) from repackage into master
Reviewed-on: #13
2026-05-24 11:11:27 +10:00
tris 78441dc142 Version bump 2026-05-24 11:07:49 +10:00
tris c96a2ad80b Refactored docker 2026-05-24 11:00:05 +10:00
tris 7e0c77f260 Refactored to polyphonic package 2026-05-24 10:51:43 +10:00
tris ff17114514 Minor test change 2026-05-24 10:07:05 +10:00
tris 533d6f09e8 Fixed compose launch 2026-05-23 22:10:37 +10:00
tris eeb35ce4f6 Modify PYTHONPATH 2026-05-23 21:46:51 +10:00
tris 947626c2af Added indexer tests 2026-05-23 11:38:40 +10:00
tris c1f0e48f80 Another go at sorting the settings mess 2026-05-23 10:00:09 +10:00
tris ab7d32d46e Renamed to poly-tool 2026-05-23 00:05:29 +10:00
tris 5116247ae8 Simplified settings 2026-05-22 23:51:25 +10:00
tris 93c4926dfd Updated docker build 2026-05-22 14:11:03 +10:00
tris 27d1b03c3c Added fuzzy matching 2026-05-14 11:02:55 +10:00
tris 4e102c07ac Skip migrations 2026-05-13 11:08:03 +10:00
tris b1ea75cec0 Merge pull request 'GdriveFolder code' (#12) from gdrive into master
Reviewed-on: #12
2026-05-13 09:35:48 +10:00
tris 7d041e1fd0 GdriveFolder code 2026-05-13 09:32:22 +10:00
tris 504c2ee56b Moving makefile to poetry 2026-05-12 23:24:46 +10:00
tris cfd6d45189 Added linting and got tests passing 2026-05-12 13:26:56 +10:00
tris ca62ed693a Code cleanup 2026-05-12 11:10:21 +10:00
tris 5e0e165037 Interface cleanup 2026-05-12 11:04:22 +10:00
tris 4164d56dea Library cleanup 2026-05-12 11:03:11 +10:00
tris 75dced77b8 Fixed logout + cleanup 2026-05-11 22:53:31 +10:00
tris b3675e28af Cleanup 2026-05-11 22:06:05 +10:00
tris b86c867bd2 Merge pull request 'whoosh_search' (#11) from whoosh_search into master
Reviewed-on: #11
2026-05-11 21:55:52 +10:00
tris 9248692e5d Merge branch 'master' into whoosh_search 2026-05-11 21:51:15 +10:00
tris 406609262d Cleanup 2026-05-11 21:50:57 +10:00
tris ffb31cc004 Implemented proper search engine 2026-05-11 21:50:21 +10:00
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
Tris Forster 04bf1ab1f3 Improved auto tagging 2023-03-27 10:34:20 +11:00
Tris Forster fb700b15d6 Added search bar to collection list 2023-03-04 17:12:58 +11:00
Tris Forster 0bd2596ebc Added search bar to collection list 2023-03-04 17:06:28 +11:00
Tris Forster 1f0a336ed4 Added search bar to collection list 2023-03-04 17:02:18 +11:00
Tris Forster 220163bca2 Added Number 2023-03-03 19:56:22 +11:00
Tris Forster d1328ae1b1 Many interface changes 2023-03-03 16:07:30 +11:00
Tris Forster 69f747d5b5 Fixed part selection dropdown 2023-03-02 16:25:38 +11:00
Tris Forster 2a6554471d List works on project page 2023-03-02 13:28:24 +11:00
Tris Forster 6edc612ed6 Fixed gitignore 2023-03-02 12:30:39 +11:00
Tris Forster f441940e8c Layout tweeks 2023-03-02 12:15:19 +11:00
Tris Forster b1eaf9c7bc Added document extractor 2023-03-02 10:05:26 +11:00
Tris Forster a892b0bc41 Got api work import working 2023-03-02 07:09:38 +11:00
Tris Forster f1757be96e Dont tag files that aren't PDFs 2023-03-01 14:42:05 +11:00
Tris Forster 95ee92cef6 Fixed related work links 2023-03-01 14:13:52 +11:00
Tris Forster b11cfcd6ea Added primitive doc type checking 2023-03-01 13:56:52 +11:00
Tris Forster 6e1b26440f Added coverage testing 2023-03-01 13:39:56 +11:00
Tris Forster 4268b66b27 Added module management 2023-03-01 13:15:09 +11:00
Tris Forster f22961ad44 Couple of little fixes 2023-03-01 12:41:42 +11:00
Tris Forster caeee16657 Extended PROTETED_URLS tests 2023-03-01 12:41:10 +11:00
Tris Forster 4344cb978b Fixed reference to section type 2023-03-01 11:25:38 +11:00
Tris Forster a3d8cf0a21 Fixed reference to section type 2023-03-01 11:21:06 +11:00
Tris Forster 70aa7ae3a3 Fixed reference to section type 2023-03-01 11:18:35 +11:00
Tris Forster f840ee3d8b Couple of interface tweeks 2023-02-28 16:56:22 +11:00
Tris Forster 7d4f959146 Fixing work edit 2023-02-23 20:33:38 +11:00
Tris Forster 1cb00ebc0b Fixing bulk importer 2023-02-23 20:29:04 +11:00
Tris Forster 928173976b Got my music working again 2023-02-23 19:29:20 +11:00
Tris Forster d7892d355d Moved project filtering to the right place - the queryset 2023-02-23 19:18:23 +11:00
Tris Forster dbbfa79f10 No date for perpetual projects 2023-02-23 19:03:01 +11:00
Tris Forster f49ff0fd0e Moved project list to model 2023-02-23 18:37:21 +11:00
Tris Forster 6ec5808275 Refactored tests 2023-02-23 18:36:39 +11:00
Tris Forster 948e9deb54 Got collection links working and tested 2023-02-23 14:27:50 +11:00
Tris Forster 75de40f2bd Cleaned up access tests 2023-02-23 10:22:58 +11:00
Tris Forster 295999eaef Refactored test access (eventually) 2023-02-22 18:46:12 +11:00
Tris Forster f9fa9d5e05 Stupid typo 2023-02-21 09:11:14 +11:00
Tris Forster c720cb773e Getting partset working 2023-02-20 14:21:34 +11:00
Tris Forster da15eebb0a Silly fix 2023-02-20 10:31:56 +11:00
Tris Forster e74043ae67 Fixed work create and some security issues 2023-02-20 10:28:38 +11:00
Tris Forster 5a201cef85 Fixed add work 2023-02-20 08:03:32 +11:00
Tris Forster 5fb5138383 Fix ensemble reference 2023-02-20 07:56:53 +11:00
Tris Forster 039b440a57 Tweak! 2023-02-20 03:38:23 +11:00
Tris Forster 7d394e2f34 Updated requirements 2023-02-16 20:47:16 +11:00
Tris Forster 13f466228b Got library working again 2023-02-16 17:22:18 +11:00
Tris Forster b85440d25c Finally got authentication to somewhere I'm happy 2023-02-10 12:01:32 +11:00
Tris Forster bc9f292a2e Major changes to permission system 2023-02-03 10:10:54 +11:00
Tris Forster 8a249de51c Tweeking section handling 2023-01-04 09:52:22 +11:00
Tris Forster 7e47eec4ae Orchestration cleanup 2022-12-10 10:18:45 +11:00
Tris Forster 59deeffefe Improved upload 2022-12-02 13:24:31 +11:00
Tris Forster 53ec846f98 Refactored into /app 2022-11-28 14:11:39 +11:00
Tris Forster 94bba3769a Pre refactor 2022-11-27 10:20:13 +11:00
Tris Forster 2726a8fe04 Adding font 2022-11-19 12:46:58 +11:00
Tris Forster 4731d18131 Add static folder 2022-11-19 12:32:17 +11:00
Tris Forster c639020ac9 Fixing migrations 2022-11-19 12:24:40 +11:00
Tris Forster 7f6875f3c4 Updated byostorage 2022-11-19 12:10:35 +11:00
Tris Forster 025e1344f0 Recreating migrations 2022-11-19 22:43:15 +11:00
Tris Forster bbc74a77f9 Removed old deps 2022-11-19 22:37:37 +11:00
Tris Forster dfe4a925c7 Added secret key! 2022-11-19 22:35:06 +11:00
Tris Forster 18e5893cc2 Updated requirements 2022-11-19 22:33:21 +11:00
Tris Forster 988161b599 Added default settings 2022-11-19 22:31:06 +11:00
141 changed files with 5868 additions and 2499 deletions
+2
View File
@@ -0,0 +1,2 @@
local_settings.py
db.sqlite3
+17 -9
View File
@@ -1,12 +1,20 @@
__pycache__
*.pyc
db.sqlite3
*.sqlite3
*.swp
credentials.json
credentials
polyphonic/settings.py
env
test.*
static
teststore
cache
local_storage
media
local_settings.py
.coverage
Session.vim
poetry.lock
/env
/data
/old
/static
/teststore
/cache
/local_storage
/media
/index
/dist
+25
View File
@@ -0,0 +1,25 @@
FROM alpine:latest
ENV TARGET=/opt/polyphonic
#ENV RELEASE=polyphonic-0.8.4-py3-none-any.whl
ENV RELEASE=git+https://gitea.tfconsulting.com.au/projects/polyphonic.git
RUN apk add --no-cache python3 py3-pip git ghostscript sqlite
WORKDIR /root
RUN python3 -m venv ${TARGET}
ENV PATH="${TARGET}/bin:$PATH"
#COPY dist/${RELEASE} .
RUN pip3 install ${RELEASE} --no-cache-dir
RUN pip3 install gunicorn whitenoise
WORKDIR ${TARGET}
RUN SECRET_KEY=_ poly-tool collectstatic --noinput
VOLUME ["/var/polyphonic"]
EXPOSE 8000/tcp
CMD ["gunicorn", "-b", "0.0.0.0", "polyphonic.config.wsgi"]
+19 -7
View File
@@ -1,15 +1,28 @@
PYTHON=env/bin/python
DROPZONE=5.7.0
test:
poetry run coverage run --include "polyphonic/*" --omit "*/migrations/*" polyphonic/manage.py test polyphonic
poetry run coverage html
poetry run coverage report
check:
poetry run ruff check polyphonic
poetry run ruff format --check polyphonic
pre-commit: check test
build:
poetry build
dev-setup:
env/bin/pip install -r requirements.txt
env/bin/pip install -r dev-requirements.txt
${PYTHON} manage.py migrate
${PYTHON} manage.py createsuperuser --username admin --email admin@localhost
poetry install --with=dev
poetry run manage migrate
poetry run manage createsuperuser --username admin --email admin@localhost
upgrade:
${PYTHON} manage.py migrate
${PYTHON} manage.py collectstatic
poetry run manage migrate
poetry run manage collectstatic
${MAKE} libraries
libraries: static/dropzone static/fonts/Quicksand_Book.otf
@@ -35,4 +48,3 @@ start_s3_storage:
stop_s3_storage:
kill `cat teststore/pid` | true
rm teststore/pid
+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
+14
View File
@@ -0,0 +1,14 @@
services:
polyphonic:
image: "polyphonic:0.8.4"
build: "."
ports:
- "8001:8000"
volumes:
- "./data:/var/polyphonic"
- "./local_settings.py:/opt/polyphonic/local_settings.py"
env_file: "compose.env"
environment:
DJANGO_SETTINGS_MODULE: local_settings
PYTHONPATH: /opt/polyphonic
WORK_DIR: /var/polyphonic
-46
View File
@@ -1,46 +0,0 @@
from django import forms
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit
from . import models, fields
class BaseForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = self.get_form_helper()
def get_form_helper(self):
helper = FormHelper(self)
helper.add_input(Submit('submit', 'Submit', css_class='button is-link'))
return helper
class ProjectForm(forms.ModelForm, BaseForm):
class Meta:
model = models.Project
fields = ['name', 'description', 'event_date']
widgets = {
'event_date': forms.DateTimeInput(attrs={'type': 'date'})
}
class ResourceForm(forms.ModelForm, BaseForm):
class Meta:
model = models.Resource
fields = ['name', 'media_type', 'description', 'file']
def get_form_helper(self):
helper = super().get_form_helper()
helper[3].wrap(fields.BulmaFileUpload)
return helper
class CodeForm(BaseForm):
code = forms.CharField(max_length=14,
widget=forms.TextInput(attrs={'placeholder': 'xxx-xxx-xxx', 'inputmode': 'numeric'}))
passphrase = forms.CharField(max_length=32)
class ResourceUploadForm(forms.Form):
pass
# file = S3UploadField()
-157
View File
@@ -1,157 +0,0 @@
from django.db import models
from django.utils.text import slugify
from django.utils import timezone
from django.conf import settings
from django.shortcuts import resolve_url
from byostorage.user import BYOStorage
import random
from datetime import datetime
from urllib.parse import urlparse
import os.path
MEDIA_TYPES = [
('audio', "Audio"),
('video', "Video"),
('general', "General"),
]
def rough_date(d):
days = (d - timezone.now()).days
in_past = days < 0
if in_past:
days = abs(days)
if days ==0:
return "today!"
if days >= 7:
return in_past, "{0:d} weeks, {1:d} days".format(int(days / 7), int(days % 7))
return in_past, f"{days} days"
def generate_code(length=9):
return "".join([ random.choice('0123456789') for _ in range(length) ])
class Ensemble(models.Model):
''' A group that plays together
'''
name = models.CharField(max_length=100,
help_text="Display name")
slug = models.SlugField(max_length=100, editable=False, unique=True,
help_text="Short name for the ensemble - used for folders")
code = models.CharField(max_length=9, default=generate_code,
help_text="Ensemble registration code")
passphrase = models.CharField(max_length=100,
help_text="Used to register ensembles")
admins = models.ManyToManyField('auth.User', related_name='ensembles')
details = models.TextField(blank=True,
help_text="Description of the ensemble (markdown)")
storage = models.ForeignKey('byostorage.UserStorage', null=True, on_delete=models.SET_NULL,
help_text="Default storage for this ensemble")
def active_projects(self):
return self.projects.filter(active=True).order_by('event_date')
def ensemble_code(self):
code = str(self.code)
return "{}-{}-{}".format(code[:3], code[3:6], code[6:])
def save(self, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super(Ensemble, self).save(**kwargs)
def __str__(self):
return self.name
class Project(models.Model):
''' A Project linked to an ensemble
'''
name = models.CharField(max_length=100)
ensemble = models.ForeignKey(Ensemble, related_name='projects', on_delete=models.CASCADE, null=True)
description = models.TextField(blank=True,
help_text="Markdown format")
active = models.BooleanField(default=True)
event_date =models.DateTimeField(null=True, blank=True)
owner = models.CharField(max_length=255, blank=True)
class Meta:
ordering = ['active', '-pk']
@property
def submissions(self):
return self.all_submissions.order_by('-pk')
@property
def days(self):
return (self.event_date - timezone.now().date()).days
@property
def has_happened(self):
return self.event_date < timezone.now()
@property
def rough_date(self):
in_past, s = rough_date(self.event_date)
if in_past:
return f"{s} ago"
return f"In {s}"
@property
def folder(self):
print(f"{self.ensemble.storage_id}:{self.ensemble.slug}/{self.slug}")
return f"{self.ensemble.storage_id}:{self.ensemble.slug}/{self.slug}"
def __str__(self):
return self.name
class Module(models.Model):
''' Enable modules on a oriject
'''
name = models.SlugField(max_length=20, choices=[ (x, x.title()) for x in settings.POLYPHONIC_MODULES ])
project = models.ForeignKey(Project, related_name="modules", on_delete=models.CASCADE)
def resource_key(resource, filename):
return f'{resource.project.folder}/resources/{filename}'
class Resource(models.Model):
''' A viewable file resource attached to a project
e.g PDF instructions, MP3 backing track
'''
project = models.ForeignKey(Project, related_name='resources', on_delete=models.CASCADE)
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
file = models.FileField(storage=BYOStorage(), upload_to=resource_key)
media_type = models.CharField(max_length=10, choices=MEDIA_TYPES, default='*')
visible = models.BooleanField(default=True)
class Meta:
ordering = ['-visible', '-pk']
def accept(self):
if self.media_type == 'general':
return ".*"
return f"{self.media_type}/*"
def __str__(self):
return self.name
class WikiPage(models.Model):
''' An editable wiki page for the project in markdown format
'''
project = models.ForeignKey(Project, related_name='wiki_pages', on_delete=models.CASCADE)
title = models.CharField(max_length=255)
markdown = models.TextField()
def get_absolute_url(self):
return resolve_url('wiki', project=self.project_id, pk=self.pk)
def __str__(self):
return self.title
@@ -1,12 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h2>{{ ensemble.name }}</h2>
<p>{{ ensemble.details }}</p>
<h4>Administrators</h4>
<ul>
{% for admin in ensemble.admins.all %}
<li><a href="mailto:{{ admin.email }}">{% firstof admin.get_full_name admin.get_username %}</a></li>
{% endfor %}
</ul>
{% endblock %}
@@ -1,41 +0,0 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% block page %}
<div class="admin-tools is-pulled-right">
<a class="button is-link" href="{% url 'register' %}">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Register another</span>
</a>
</div>
<h3 class="title">My Ensembles</h3>
<div class="columns is-multiline">
{% for ensemble in object_list %}
<div class="column is-half">
<div class="card">
<header class="card-header{% if ensemble.pk == ensemble_id %} has-background-link-light{% endif %}">
<a class="card-header-title" href="{% url 'register' %}?code={{ ensemble.code }}">
{{ ensemble.name }}
</a>
<a class="card-header-icon" href="{% url 'ensemble_forget' pk=ensemble.id %}">
<span class="delete"></span>
</a>
</header>
<div class="card-content">
{% if ensemble.details %}
<div class="content">
{{ ensemble.details | markdown }}
</div>
{% endif %}
<div>
{% with projects=ensemble.active_projects.count %}
<p>{{ projects }} active project{{ projects|pluralize }}</p>
{% endwith %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endblock %}
@@ -1,73 +0,0 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% block admin %}
<a href="{% url 'project_create' %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add new</span>
</a>
{% endblock %}
{% block page %}
<h3 class="title">Projects for {{ ensemble.name }}</h3>
<div class="columns is-multiline">
{% for project in ensemble.active_projects %}
<div class="column is-half">
<div class="card">
<a class="" href="{% url 'project_detail' project=project.id %}">
<header class="card-header">
<p class="card-header-title">{{ project.name }}</p>
<p class="card-header-icon" style="color: black;">{{ project.rough_date }}</p>
</header>
</a>
<div class="card-content">
<div class="content">
{{ project.description | markdown }}
</div>
<p><small>
{% if project.deadline %}In {{ project.deadline|timeuntil }}<br/>{% endif %}
{% if project.works.count %}
<a href="{% url 'item_list' project=project.pk %}">
{{ project.works.count }} works
</a>
<br/>
{% endif %}
{% if project.submissions.count %}{{ project.submissions.count }} submissions<br/>{% endif %}
</small></p>
</div>
</div>
</div>
{% empty %}
<div class="hero">
<div class="hero-body">
<p class="title">No projects currently planned</p>
<p class="subtitle">Go put your feet up!</p>
</div>
</div>
{% endfor %}
</div>
{% if request.is_admin %}
<div class="">
<div class="card">
<header class="card header">
<p class="card-header-title">Admin Details</p>
</header>
<div class="card-content">
<p>
Joining instructions for participants<br/><br/>
URL: <a href="{{ ensemble_url }}">{{ ensemble_url }}</a><br/>
Code: {{ ensemble.ensemble_code }}<br/>
Passphrase: {{ ensemble.passphrase }}
</p>
</div>
</div>
</div>
{% endif %}
{% endblock %}
@@ -1,62 +0,0 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% load polyphonic %}
{% block admin %}
<a href="{% url 'wiki_create' project=project.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-file"></i></span>
<span>Add Page</span>
</a>
<a href="{% url 'project_edit' project=project.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-edit"></i></span>
<span>Edit</span>
</a>
{% endblock %}
{% block page %}
<div class="box">
{% if project.event_date %}
<h3 class="subtitle is-centered">
<b>{{ project.event_date|date:"l jS F Y, g:i A" }}</b>
{% if project.has_happened %}
({{ project.event_date|roughtimesince }} ago)
{% else %}
(in {{ project.event_date|roughtimeuntil }}...)
{% endif %}
</h3>
{% endif %}
<div class="block">
{% if project.description %}
<p class="content">{{ project.description|markdown }}</p>
{% else %}
<p>No description</p>
{% endif %}
</div>
{% if 'library' in modules %}
<div class="block">
{% include 'library/project_detail.html' %}
</div>
{% endif %}
{% if 'submission' in modules %}
<div class="block">
{% include 'submissions/project_detail.html' %}
</div>
{% endif %}
{% if project.owner %}
<div class="block">
{% if project.owner.email %}
The project owner is <a href="mailto:{{ project.owner.email }}">{{ project.owner }}</a>
{% else %}
The project owner is {{ project.owner }}.
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
@@ -1,21 +0,0 @@
{% extends "interface/project_base.html" %}
{% load crispy_forms_tags %}
{% block page %}
<style>
TEXTAREA {
height: 200px;
}
FORM.vertical {
max-width: 90%;
}
</style>
<div>
<h3>{{ title }}</h3>
<p>{{ instructions }}</p>
<form class="vertical" method="POST">
{% csrf_token %}
{{ form | crispy }}
</form>
</div>
{% endblock %}
-14
View File
@@ -1,14 +0,0 @@
from django import template
from django.utils import timesince
register = template.Library()
def roughtimesince(value):
return timesince.timesince(value, depth=1)
register.filter('roughtimesince', roughtimesince)
def roughtimeuntil(value):
return timesince.timeuntil(value, depth=1)
register.filter('roughtimeuntil', roughtimeuntil)
-63
View File
@@ -1,63 +0,0 @@
from django.test import TestCase, Client
from interface import models
class RegisterTestCase(TestCase):
def setUp(self):
self.client = Client()
@staticmethod
def setUpTestData():
e1 = models.Ensemble.objects.create(name='The Be Sharps', code="1234", passphrase='Homer')
e1.projects.create(name='Baby on Board')
e2 = models.Ensemble.objects.create(name='Lisa and the Bleeding Gums', code="2345", passphrase="Maggie")
e2.projects.create(name='Baker St')
def test_redirect(self):
response = self.client.get('/')
self.assertRedirects(response, '/register?')
def test_redirect_project(self):
response = self.client.get('/projects/1')
self.assertRedirects(response, '/register?')
def test_redirect_with_code(self):
response = self.client.get('/?code=123-456-789')
self.assertRedirects(response, '/register?code=123-456-789')
def test_register(self):
response = self.client.post('/register', {'code': '123-456-789', })
self.assertFormError(response, 'form', 'passphrase', 'This field is required.')
response = self.client.post('/register', {'code': '123-456-789', 'passphrase': 'Foo'})
self.assertFormError(response, 'form', None, 'Incorrect code or passphrase')
response = self.client.post('/register', {'code': '12-34', 'passphrase': 'Homer'})
self.assertRedirects(response, '/')
response = self.client.get(response.url)
self.assertEqual(response.context['object'].pk, 1)
# revisting original url get redirected back to homepage
response = self.client.get('/?code=12-34')
response = self.client.get(response.url)
response = self.client.get(response.url)
self.assertEqual(response.context['object'].pk, 1)
# providing a new code
response = self.client.get('/?code=23-45')
self.assertRedirects(response, '/register?code=23-45')
response = self.client.get(response.url)
self.assertQuerysetEqual(response.context['current'], ['<Ensemble: The Be Sharps>'])
#self.assertEqual(response.context['form'].code.initial, 'foo')
response = self.client.post('/register', {'code': '23-45', 'passphrase': 'maggie'})
self.assertRedirects(response, '/')
response = self.client.get('/')
self.assertEqual(response.context['object'].pk, 2)
# can use previous link to switch back without passphrase
response = self.client.get('/?code=12-34')
response = self.client.get(response.url)
response = self.client.get(response.url)
self.assertEqual(response.context['object'].pk, 1)
-30
View File
@@ -1,30 +0,0 @@
from django.urls import path
from django.contrib.auth import views as auth_views
from . import views
urlpatterns = [
path('login', auth_views.LoginView.as_view(), name='login'),
path('logout', views.logout, name='logout'),
path('register', views.register, name="register"),
path('', views.EnsembleProjectListView.as_view(), name='ensemble_detail'),
path('ensembles', views.EnsembleListView.as_view(), name="ensemble_list"),
path('ensembles/<int:pk>', views.EnsembleDetailView.as_view(), name='ensemble_detail'),
path('ensembles/<int:pk>/forget', views.EnsembleForgetView.as_view(), name='ensemble_forget'),
path('projects/create', views.ProjectCreateView.as_view(), name="project_create"),
path('projects/<int:project>', views.ProjectDetailView.as_view(), name="project_detail"),
path('projects/<int:project>/edit', views.ProjectUpdateView.as_view(), name="project_edit"),
#path('projects/<int:project>/submissions.mk', views.ProjectMakefileView.as_view(), name="project_makefile"),
path('projects/<int:project>/page/create', views.WikiCreateView.as_view(), name="wiki_create"),
path('projects/<int:project>/page/<int:pk>', views.WikiView.as_view(), name="wiki"),
path('projects/<int:project>/page/<int:pk>/edit', views.WikiEditView.as_view(), name="wiki_edit"),
path('projects/<int:project>/resources', views.ResourceListView.as_view(), name="resource_list"),
path('projects/<int:project>/resources/add', views.ResourceCreateView.as_view(), name="resource_create"),
path('projects/<int:project>/resources/<int:pk>/upload', views.ResourceUploadView.as_view(), name="resource_upload"),
path('projects/<int:project>/resources/<int:pk>/edit', views.ResourceEditView.as_view(), name="resource_edit"),
]
-336
View File
@@ -1,336 +0,0 @@
from django.shortcuts import render, get_object_or_404, redirect, resolve_url
from django.views.generic import TemplateView, RedirectView
from django.views.generic.detail import DetailView
from django.views.generic.list import ListView
from django.views.generic.edit import CreateView, UpdateView
from django.core.exceptions import SuspiciousOperation
from django.core.signing import Signer
from django.contrib import auth
from markdown2 import markdown
from . import models, forms
import logging
logger = logging.getLogger(__name__)
signer = Signer()
def signed_url(name, **kwargs):
url = resolve_url(name, **kwargs)
sig = signer.sign(url)
return sig.replace(":", "?auth=")
class EnsembleMixin(object):
admin_required = False
def dispatch(self, request, *args, **kwargs):
request.ensemble_id = request.session.get('ensemble')
request.is_admin = request.user.is_superuser
if request.is_admin:
if request.ensemble_id is None:
return redirect('ensemble_list')
return super().dispatch(request, *args, **kwargs)
if 'auth' in request.GET:
sig = signer.sign(request.path)
if sig[len(request.path)+1:] == request.GET['auth']:
logger.info("Allowing auth key")
request.is_admin = True
return super().dispatch(request, *args, **kwargs)
else:
raise SuspiciousOperation("Bad auth code")
if request.user.is_authenticated:
try:
request.user.ensembles.get(pk=request.ensemble_id)
request.is_admin = True
except models.Ensemble.DoesNotExist:
pass
if not request.ensemble_id:
return redirect('register')
if self.admin_required and not request.is_admin:
return redirect('login')
return super().dispatch(request, *args, **kwargs)
@property
def ensemble(self):
return models.Ensemble.objects.get(pk=self.request.ensemble_id)
#def get_context_data(self, **kwargs):
# context = super().get_context_data(**kwargs)
# context['ensemble'] = self.ensemble
# return context
class ProjectMixin(EnsembleMixin):
def get_project(self):
if not hasattr(self, '_project'):
if self.request.is_admin: # can access any ensemble
self._project = get_object_or_404(models.Project, pk=self.kwargs['project'])
else:
self._project = get_object_or_404(models.Project,
pk=self.kwargs['project'], ensemble=self.request.ensemble_id)
return self._project
def get_queryset(self):
return super().get_queryset().filter(project=self.get_project())
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['project'] = self.get_project()
context['modules'] = context['project'].modules.values_list('name', flat=True)
return context
def register(request):
if 'clear' in request.GET:
request.session.clear()
request.ensemble_id = request.session.get('ensemble')
registered = request.session.setdefault('registered', [])
code = request.GET.get('code', '').replace('-', '')
print("Registering with code %s", code)
# check if already joined
if code in registered or request.user.is_superuser:
request.session['ensemble'] = models.Ensemble.objects.get(code=code).pk
return redirect('ensemble_detail')
if request.method == "POST":
form = forms.CodeForm(request.POST)
if form.is_valid():
data = form.cleaned_data
try:
ensemble = models.Ensemble.objects.get(code=data['code'].replace('-', ''))
if ensemble.passphrase.lower() == data['passphrase'].lower():
request.session['ensemble'] = ensemble.pk
registered.append(ensemble.code)
return redirect('ensemble_detail')
except models.Ensemble.DoesNotExist:
form.add_error(None, "Incorrect code or passphrase")
else:
form = forms.CodeForm(initial=request.GET)
if request.user.is_superuser:
current = models.Ensemble.objects.all()
else:
current = models.Ensemble.objects.filter(pk__in=registered)
return render(request, 'interface/register.html', {'form': form, 'current': current})
def on_login(sender, **kwargs):
user = kwargs['user']
request = kwargs['request']
registered = request.session.get('registered', [])
for e in user.ensembles.all():
if not e.code in registered:
registered.append(e.code)
request.session['registered'] = registered
auth.signals.user_logged_in.connect(on_login)
def logout(request):
ensemble = request.session.get('ensemble')
registered = request.session.get('registered', {})
auth.logout(request)
request.session['ensemble'] = ensemble
request.session['registered'] = registered
return redirect('/')
class EnsembleForgetView(EnsembleMixin, RedirectView):
def get_redirect_url(self, *args, **kwargs):
registered = self.request.session.setdefault('registered', [])
ensemble = models.Ensemble.objects.get(pk=self.kwargs['pk'])
try:
registered.remove(ensemble.code)
self.request.session['registered'] = registered
except KeyError:
pass
if self.request.ensemble_id == ensemble.pk:
del(self.request.session['ensemble'])
return resolve_url('ensemble_detail')
class EnsembleListView(ListView):
def get_queryset(self):
registered = self.request.session.get('registered', [])
if self.request.user.is_superuser:
current = models.Ensemble.objects.all()
else:
current = models.Ensemble.objects.filter(code__in=registered)
return current
#def get_context_data(self, **kwargs):
# context = super().get_context_data(**kwargs)
# context['ensemble_id'] = self.request.session.get('ensemble')
# return context
class EnsembleProjectListView(EnsembleMixin, DetailView):
template_name = 'interface/ensemble_project_list.html'
def dispatch(self, request, *args, **kwargs):
# capture provided urls
if 'code' in request.GET:
return redirect('/register?code={0}'.format(request.GET['code']))
return super().dispatch(request, *args, **kwargs)
def get_object(self):
return models.Ensemble.objects.get(pk=self.request.ensemble_id)
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
if self.request.is_admin:
data['ensemble_url'] = self.request.build_absolute_uri('/?code={0}'.format(self.ensemble.ensemble_code()))
return data
class EnsembleDetailView(DetailView):
model = models.Ensemble
class ProjectDetailView(ProjectMixin, DetailView):
def get_object(self):
return self.get_project()
class ProjectCreateView(EnsembleMixin, CreateView):
model = models.Project
template_name = "interface/default_form.html"
title = "Add a new project"
form_class = forms.ProjectForm
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.ensemble_id = self.request.ensemble_id
self.object.owner = self.request.user
self.object.save()
return redirect('project_detail', project=self.object.pk)
class ProjectUpdateView(EnsembleMixin, UpdateView):
model = models.Project
template_name = "interface/default_form.html"
#fields = ['name', 'description', 'event_date', 'enable_library', 'enable_submissions', 'active']
pk_url_kwarg = 'project'
form_class = forms.ProjectForm
def get_success_url(self):
return resolve_url('project_detail', project=self.kwargs['project'])
#class ProjectMakefileView(EnsembleMixin, DetailView):
# template_name = 'interface/project_submissions.mk'
# content_type = 'text/plain'
#
# def get_queryset(self):
# if self.request.is_admin:
# return models.Project.objects.all()
#
# return models.Project.objects.filter(ensemble=self.request.ensemble_id)
#
# def get_context_data(self, **kwargs):
# data = super().get_context_data(**kwargs)
#
# data['submissions'] = []
# data['targets'] = []
# for s in self.object.submissions:
# name = s.short_name
# data['targets'].append(name)
# data['submissions'].append({
# 'url': self.request.build_absolute_uri(signed_url('submission_download', project=self.kwargs['pk'], pk=s.pk)),
# 'name': name,
# })
#
# return data
class WikiView(ProjectMixin, DetailView):
template_name = 'interface/wiki.html'
model = models.WikiPage
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
data['wiki_html'] = markdown(self.object.markdown)
return data
class WikiCreateView(ProjectMixin, CreateView):
admin_required = True
model = models.WikiPage
fields = ['title', 'markdown']
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.project = self.get_project()
self.object.save()
return redirect('wiki', project=self.object.project_id, pk=self.object.pk)
class WikiEditView(ProjectMixin, UpdateView):
admin_required = True
model = models.WikiPage
fields = ['title', 'markdown']
class ResourceCreateView(ProjectMixin, CreateView):
model = models.Resource
form_class = forms.ResourceForm
template_name = 'interface/project_form.html'
title = "Add a new resource"
admin_required = True
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.project = self.get_project()
self.object.save()
return redirect('resource_upload', project=self.object.project_id, pk=self.object.pk)
class ResourceUploadView(ProjectMixin, UpdateView):
admin_required = True
model = models.Resource
fields = ['file']
template_name = 'interface/default_form.html'
def get_success_url(self):
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:
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'
def get_success_url(self):
return resolve_url('resource_list', project=self.kwargs['project'])
class ManageView(EnsembleMixin, TemplateView):
template_name = 'interface/manage.html'
admin_required = True
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['ensemble'] = models.Ensemble.objects.get(pk=self.request.ensemble_id)
context['ensemble_url'] = self.request.build_absolute_uri('/?code={0}'.format(context['ensemble'].ensemble_code()))
return context
-32
View File
@@ -1,32 +0,0 @@
from django import forms
from .models import Work
from interface.models import Project
from interface.widgets import DatePickerInput
from django.db.models import Q
from interface.forms import BaseForm
class WorkCreateForm(forms.ModelForm, BaseForm):
#uploads = forms.FileField(label="PDFs to upload", widget=forms.ClearableFileInput(attrs={'multiple': True}), required=False)
class Meta:
model = Work
fields = ['name', 'code', 'running_time', 'notes']
class PlaylistAddForm(forms.Form):
work = forms.ModelChoiceField(queryset=Work.objects.all())
def __init__(self, instance, *args, **kwargs):
super(PlaylistAddForm, self).__init__(*args, **kwargs)
existing = [ x[0] for x in instance.works.values_list('pk') ]
qs = Work.objects.filter(ensemble_id=instance.ensemble_id).exclude(id__in=existing)
self.fields['work'].queryset = qs
self.instance = instance
def save(self):
self.instance.works.add(self.cleaned_data['work'])
class ProjectSelectForm(forms.Form):
project = forms.ModelChoiceField(queryset=Project.objects.all())
-197
View File
@@ -1,197 +0,0 @@
from collections import namedtuple
# taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_Instruments
ABBREVIATIONS = """
score Score
acc Accordion
afl Alto flute
alt Alto (voice) (contralto)
arp Arpeggione
bag Bagpipe
bar Baritone (voice)
bass Bass (voice)
bbar Bass baritone (voice)
bc Continuo (Basso continuo)
bcl Bass clarinet
bell Bell (Chimes)
bfl Bass flute
bgtr Bass guitar
bjo Banjo
bn Bassoon
bob Bass oboe (Baritone oboe)
br Brass instruments
bryt Baryton
bstcl Basset clarinet
bsthn Basset horn
bug Bugle
cbcl Contrabass clarinet
cbn Contrabassoon
cch Children's chorus
cel Celesta
ch Mixed chorus
cimb Cimbalom
cit Cittern
cl Clarinet
clvd Clavichord
cm Chalumeau
conc Concertina
crh Crumhorn
crt Cornet
crtt Cornett (Zink)
cv Child's voice
db Double Bass
dlcn Dulcian
dom Domra
dulc Dulcimer
egtr Electric guitar
eh English horn (Cor anglais)
elec Electronic Instruments
epf Electric piano
eq Equal voices
erhu Erhu
euph Euphonium
fch Female chorus
fda Flute d'amore (Tenor flute)
fgh Flugelhorn
fife Fife
fl Flute
flag Flageolet
ghca Glass harmonica (Bowl organ)
gl Glockenspiel
gtr Guitar
harm Harmonium
hca Harmonica (Mouth Organ)
heck Heckelphone
hn Horn
hp Harp
hpd Harpsichord
kbd Keyboard instrument
lute Lute
lyre Lyre
mand Mandolin
mar Marimba
mch Male chorus
mez Mezzo-soprano
mus Musette
nar Narrator (Reciter)
ob Oboe
oca Ocarina
oda Oboe d'amore
om Ondes Martenot
oph Ophicleide
orch Orchestra
org Organ
oud Oud
pan Pan flute (Pan-pipes)
perc Percussion
pf Piano
pf3h Piano 3 hands
pf4h Piano 4 hands
pf5h Piano 5 hands
pf6h Piano 6 hands
pflh Piano left hand
pfped Pedal piano
pfrh Piano right hand
picc Piccolo
pipa Pipa
pk Timpani
ptpt Piccolo trumpet
reb Rebec
rec Recorder
sar Sarrusophone
sax Saxophone
sheng Sheng
shw Shawm
sit Sitar
skbt Sackbut
sop Soprano (voice)
srp Serpent
stpt Slide trumpet
str String instruments
sxh Saxhorn
syn Synthesizer
tba Tuba
tbn Trombone
ten Tenor
thrm Theremin
timp Timpani
tpt Trumpet
uch Unison chorus
uke Ukelele (Ukulele)
v Voice (solo)
va Viola
vap Viola pomposa
vc Cello
vda Viola d'amore
vib Vibraphone
vie Vielle (Hurdy-Gurdy)
viol Viol (Viola da gamba)
vlne Violone
vn Violin
vuv Vuvuzela
vv Voices (multiple soloists)
wag Wagner tuba
ww Woodwind instruments
xiao Xiao
xyl Xylophone
zith Zither
"""
INSTRUMENTS = []
for line in ABBREVIATIONS.split('\n'):
parts = line.strip().split(maxsplit=1)
if len(parts) < 2: continue
name, _, _ = parts[1].partition('(')
INSTRUMENTS.append((parts[0], name))
INSTRUMENT_LOOKUP = dict(INSTRUMENTS)
TAG_LOOKUP = dict( ( (x[1].lower(), x[0]) for x in INSTRUMENTS ) )
class Instrument(namedtuple('Instrument', ('name', 'variant'), defaults=[None])):
@classmethod
def from_tag(cls, tag):
"""
>>> Instrument.from_tag('vn-1')
Instrument(name='Violin', variant='1')
>>> Instrument.from_tag('db')
Instrument(name='Double Bass', variant=None)
>>> Instrument.from_tag('Jaws Harp')
Instrument(name='Jaws Harp', variant=None)
"""
abbr, _, variant = tag.partition('-')
name = INSTRUMENT_LOOKUP.get(abbr.lower(), abbr)
if variant:
return cls(name, variant)
return cls(name, None)
def abbreviate(self):
"""
>>> Instrument('Violin', 1).abbreviate()
'vn-1'
>>> Instrument('Double Bass').abbreviate()
'db'
"""
tag = TAG_LOOKUP.get(self.name.lower())
if self.variant:
tag = f"{tag}-{self.variant}"
return tag
def __str__(self):
"""
>>> str(Instrument('Violin', 1))
'Violin 1'
>>> str(Instrument('Double Bass'))
'Double Bass'
"""
if self.variant:
return f"{self.name} {self.variant}"
return self.name
if __name__ == "__main__":
import doctest
print(doctest.testmod())
@@ -1,20 +0,0 @@
from django.core.management.base import BaseCommand, CommandError
import argparse
import csv
from library import models
class Command(BaseCommand):
help = 'Imports works from a csv file'
def add_arguments(self, parser):
parser.add_argument('collection', type=int, help="Collection ID")
parser.add_argument('source', type=argparse.FileType('r'), help="Source CSV")
def handle(self, *args, **options):
collection = models.Collection.objects.get(pk=options['collection'])
reader = csv.DictReader(options['source'])
for row in reader:
collection.works.create(name=row['Piece'], composer=row['Composer'], notes=row['Notes'])
-303
View File
@@ -1,303 +0,0 @@
from os import SCHED_OTHER
from django.conf import settings
from django.db import models
from django.utils.text import slugify
from django.utils.timezone import now
from django.utils.functional import cached_property
from django.core.files.storage import get_storage_class
from django.db.models import Q, Count, Min, Max
from byostorage.user import BYOStorage
from .imslp import Instrument
import logging
#from polyphonic.settings import LIBRARY_STORAGE
logger = logging.getLogger(__name__)
#try:
# library_storage = get_storage_class(settings.LIBRARY_STORAGE)()
#except (ImportError, AttributeError):
# logger.exception("Failed to load library storage")
# library_storage = get_storage_class()()
#logger.info("Library storage: %s", library_storage.__class__.__name__)
'''
ORCHESTRATIONS = {
'SATB': ('S', 'A', 'T', 'B'),
'String Quartet': ('Vln1', 'Vln2', 'Vla', 'Vc'),
'String Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb'),
'Chamber Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb',
'Fl1', 'Fl2', 'Cl1', 'Cl2', 'Hn1', 'Hn2',
'Tpt1', 'Tpt2', 'Tbn1', 'Tbn2', 'Tuba',
'Timp', 'Drum', 'Perc'),
'Custom': (),
}
'''
DOCTYPES = [
(1, 'PDF'),
(2, 'Audio'),
(3, 'Video'),
(4, 'Source'),
]
LICENCE_TYPES = [
(2, 'Public Domain'),
(4, 'Copyright Expired'),
(6, 'Copyrighted'),
(10, 'Internal use only'),
]
ACCESS_TYPES = [
(1, 'Unlimited'),
(2, 'Approval required'),
]
'''
class Orchestration(models.Model):
"""
Stores a list of instrument codes as a single entry (space delimited).
Can be global or ensemble specific
"""
ensemble = models.ForeignKey('interface.Ensemble', on_delete=models.CASCADE, related_name="orchestrations", null=True, blank=True)
name = models.CharField(max_length=100)
instruments = models.TextField()
def as_list(self):
tags = [ t.strip() for t in self.instruments.split(' ') ]
return [ (t, tag_to_instrument(t)) for t in tags if t ]
def save(self):
self.as_list()
super(Orchestration, self).save()
def __str__(self):
return self.name
'''
class ProjectItem(models.Model):
"""
ProjectItem represents a Work attached to a Project e.g. item in set list or programme
It also allows works to be shared from one ensemble to another on a per-project basis.
"""
project = models.ForeignKey('interface.Project', on_delete=models.CASCADE, related_name='items')
work = models.ForeignKey('Work', on_delete=models.CASCADE, related_name='project_items')
checkout = models.DateTimeField()
due = models.DateTimeField(null=True, blank=True)
returned = models.DateTimeField(null=True, blank=True)
approved_by = models.ForeignKey('auth.User', on_delete=models.CASCADE)
order = models.SmallIntegerField(default=0)
section = models.CharField(max_length=100, blank=True)
#version = models.CharField(max_length=30, blank=True, help_text="Limited to specific version tag")
class Meta:
ordering = ['order', 'work']
def __str__(self):
return f"<{self.project_id}:{self.work.slug}>"
class Collection(models.Model):
"""
A logical collection of works, typically owned by an organisation or person (physical or virtual)
"""
name = models.CharField(max_length=255,
help_text="Name of the collection")
prefix = models.SlugField(max_length=30, default="default",
help_text="Folder to store works in")
administrators = models.ManyToManyField('auth.User', related_name="collections",
help_text="Administrators for this collection")
location = models.CharField(max_length=100,
help_text="Physical location (institution, town...)", blank=True)
storage = models.ForeignKey('byostorage.UserStorage', on_delete=models.CASCADE, null=True, blank=True,
help_text="User storage for documents")
notes = models.TextField(blank=True,
help_text="Publicly visible notes about collection and loans policy (markdown format)")
def __str__(self):
return self.name
class EnsembleAccess(models.Model):
"""
Can have different access levels to a collection
"""
ensemble = models.ForeignKey('interface.Ensemble', on_delete=models.CASCADE, related_name="allowed_collections")
collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name="allowed_ensembles")
access_type = models.PositiveSmallIntegerField(choices=ACCESS_TYPES, default=2)
class Meta:
verbose_name_plural = "Ensemble access"
META_TAGS = (
('tag', 'Tag'),
('arr', 'Arranger'),
('lyrics', 'Lyracist'),
('genre', 'Genre'),
('style', 'Style'),
('orchestration', 'Orchestration'),
)
class Work(models.Model):
"""
A musical work 'owned' by a collection from a licencing perspective.
"""
slug = models.SlugField(max_length=100, editable=False,
help_text="Used as folder name")
name = models.CharField(max_length=255, help_text="Original name of the work")
edition = models.CharField(max_length=255, blank=True,
help_text="Edition details to distinguish multiple versions")
parent = models.ForeignKey('Work', null=True, blank=True, on_delete=models.SET_NULL, related_name="related_works",
help_text="Arrangement of another work or part of an anthology")
composer = models.CharField(max_length=255, blank=True,
help_text="Surname, First Name/Initials")
original_parts = models.JSONField(default=dict, blank=True, help_text="Original printed parts (IMSLP format)")
# Collection details
collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name="works")
code = models.CharField(max_length=100, blank=True, help_text="Collection specific code or number")
licence = models.PositiveSmallIntegerField(choices=LICENCE_TYPES, default=6, help_text="Copyright status")
max_projects = models.IntegerField(default=1, help_text="How many active projects can this work be attached to")
# Extra info
running_time = models.DurationField(null=True, blank=True, help_text="Running time in seconds")
notes = models.TextField(blank=True)
# Allocation to projects
projects = models.ManyToManyField('interface.Project', through='ProjectItem', related_name="works", help_text="Current usage")
def extract(self, *tags):
qs = self.docs.filter(sections__tag__in=tags)
qs = qs.annotate(Count('sections'), end=Min('sections__end'), start=Max('sections__start')) \
.filter(sections__count=len(tags))
return list(qs.values_list('upload', 'start', 'end'))
@property
def digital_parts(self):
return Section.objects.filter(doc__work=self.pk)
@property
def physical_parts(self):
if not self.original_parts:
return []
return [ (Instrument.from_tag(k), v) for (k, v) in self.original_parts.items() ]
@property
def tags(self):
return self.meta_info.filter(name='tag').values_list('value', flat=True)
@property
def meta(self):
return self.meta_info.exclude(name='tag')
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super(Work, self).save(*args, **kwargs)
@property
def active_projects(self):
return self.projects.filter(active=True)
@property
def current_loans(self):
return self.project_items.filter(checkout__lte=now(), returned=None).select_related('project')
@cached_property
def loans(self):
try:
return self.loan_count
except AttributeError:
return self.project_items.filter(checkout__lte=now(), returned=None).count()
@property
def is_available(self):
if self.max_projects < 0:
return True
return self.max_projects > self.loans
@property
def available(self):
if self.max_projects < 0:
return 'Unlimited'
a = self.max_projects - self.loans
return '{0} of {1}'.format(max(a, 0), self.max_projects)
@property
def identifier(self):
if self.code:
return self.code;
composer = self.composer or "Anon"
words = self.name.split()
if len(words) > 2:
work = ''.join([ x[0] for x in self.name.split() ])
else:
work = words[0][:3]
return f"{composer[:4]}-{work}-{self.pk:03d}".upper()
def __str__(self):
return f"{self.name} ({self.composer})"
class WorkMeta(models.Model):
work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name='meta_info')
name = models.SlugField(max_length=20, choices=META_TAGS)
value = models.CharField(max_length=255)
def doc_upload_filename(doc, filename):
collection = doc.work.collection
storage = collection.storage
if not storage:
raise RuntimeError("Collection has no storage attached")
return f'{storage}:library/{collection.prefix}/{doc.work.slug}-{doc.work.pk}/{filename}'
class Document(models.Model):
"""
Document represents a single file stored in the storage backend.
"""
work = models.ForeignKey('Work', on_delete=models.CASCADE, related_name="docs")
doctype = models.PositiveSmallIntegerField(choices=DOCTYPES, default=1)
upload = models.FileField(upload_to=doc_upload_filename, storage=BYOStorage())
created = models.DateTimeField(auto_now_add=True)
version = models.CharField(max_length=30, blank=True)
def __str__(self):
return self.upload.name
class Section(models.Model):
"""
Section is a tagged portion of a Document
"""
doc = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="sections")
tag = models.CharField(max_length=50)
start = models.SmallIntegerField(null=True, blank=True)
end = models.SmallIntegerField(null=True, blank=True)
class Meta:
ordering = ['doc', 'start', 'pk']
@property
def instrument(self):
return Instrument.from_tag(self.tag)
@property
def filename(self):
return slugify(f'{self.doc.work.name}_{self.instrument}') + '.pdf'
@property
def pagerange(self):
if self.start:
if self.end:
return f"{self.start}-{self.end}"
return str(self.start)
return "all"
def __str__(self):
return f'{self.doc.upload} [{self.pagerange}]'
@@ -1,26 +0,0 @@
{% extends "interface/project_base.html" %}
{% block page %}
<h3 class="title">Library collections for {{ request.user }}</h3>
<div class="columns is-multiline">
{% for collection in object_list %}
<div class="column is-half">
<div class="card">
<header class="card-header">
<a class="" href="{% url 'collection_work_list' pk=collection.id %}">
<p class="card-header-title">{{ collection.name }}</p>
</a>
</header>
<div class="card-content">
<p>{{ collection.location }}, {{ collection.works.count }} items.</p>
</div>
</div>
</div>
{% endfor %}
</div>
<div>
<small>{{ ensemble.ensemble_code }}</small>
</div>
{% endblock %}
@@ -1,12 +0,0 @@
{% with project.items.count as items %}
{% if items %}
<p>
There {{ items|pluralize:"is,are" }} currently {{ items}} item{{ items|pluralize }} for this project.<br/>
Click the '<a href="{% url 'item_list' project=project.pk %}">My Music</a>' button below to access your parts...
</p>
{% else %}
<p>
There are no items listed yet for this project - please check back later...
</p>
{% endif %}
{% endwith %}
-71
View File
@@ -1,71 +0,0 @@
{% extends "interface/project_base.html" %}
{% load url_tools %}
{% block admin %}
{% if collection_id %}
<a href="{% url 'work_add' pk=collection_id %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add a work</span>
</a>
{% endif %}
{% endblock %}
{% block page %}
<h3 class="title">{{ title }}</h3>
<form method="GET">
<div class="field has-addons">
<div class="control is-expanded">
<input class="input" name="filter" type="text" placeholder="Filter" value="{{ request.GET.filter }}"/>
</div>
<div class="control">
<a class="button" href="?"><i class="fas fa-times"></i></a>
</div>
</div>
</form>
<table class="table is-striped is-fullwidth">
<thead>
<tr>
<th>Work</th>
<th>Composer</th>
<th class="is-hidden-mobile">Edition</th>
{% if request.is_admin %}
<th class="is-hidden-touch">Collection</th>
<th class="is-hidden-mobile">Copies</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for work in object_list %}
<tr>
<td><a href="{% url 'work_detail' pk=work.pk %}">{{ work.name }}</a></td>
<td title="{{ work.composer }}">{{ work.composer|truncatewords:3 }}</td>
<td class="is-hidden-mobile" title="{{ work.edition }}">{{ work.edition|truncatewords:2 }}</td>
{% if request.is_admin %}
<td class="is-hidden-touch">{{ work.collection.name }}</td>
<td class="is-hidden-mobile {{ work.is_available|yesno:'has-text-success,has-text-danger' }}">{{ work.available }}</td>
{% endif %}
</tr>
{% empty %}
<tr><td colspan="4">No works found</td></tr>
{% endfor %}
</tbody>
</table>
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
{% if page_obj.has_previous %}
<a class="pagination-previous" href="{% url_update page=page_obj.prev_page_number %}">Previous</a>
{% endif %}
{% if page_obj.has_next %}
<a class="pagination-next" href="{% url_update page=page_obj.next_page_number %}">Next page</a>
{% endif %}
<ul class="pagination-list">
{% for page in page_obj.paginator.page_range %}
<li>
<a class="pagination-link {% if forloop.counter == page_obj.number %}is-current{% endif %}" href="{% url_update page=forloop.counter %}" aria-label="Goto page {{ forloop.counter }}">{{ forloop.counter }}</a>
</li>
{% endfor %}
</ul>
</nav>
{% endblock %}
@@ -1,37 +0,0 @@
{% extends "interface/project_base.html" %}
{% block page %}
<form action="" method="post" target="_blank">
{% csrf_token %}
<div class="admin-tools is-pulled-right">
<button type="submit" class="button is-link">
<span class="icon"><i class="fas fa-print"></i></span>
<span>Print Set</span>
</button>
<a class="button is-link is-light" href="{% url 'work_detail' pk=object.pk %}">
<span>Cancel</span>
</a>
</div>
<h3 class="subtitle"><a href="{% url 'work_detail' pk=work.pk %}">{{ work.name }}</a></h3>
<p class="block">
You can generate a custom partset for printing - select the number of copies of each you want...
</p>
<div class="columns is-multiline is-mobile">
{% for part in work.digital_parts %}
<div class="column is-3 has-text-right">
<span style="white-space: nowrap">{{ part.instrument }}</span>
<input name="parts" type="hidden" value="{{ part.tag }}">
<input name="copies" type="number" value="{% if part.tag == 'Score' %}0{% else %}1{% endif %}" size="1">
</div>
{% endfor %}
</div>
</form>
{% endblock %}
-50
View File
@@ -1,50 +0,0 @@
from django.test import TestCase
from django.contrib.auth.models import User
from interface.models import Ensemble, Project
from . import models
class IntegrationTestCase(TestCase):
def setUp(self):
self.homer = User.objects.create(username='homer')
self.ned = User.objects.create(username="ned")
self.lisa = User.objects.create(username="lisa")
self.dewey = User.objects.create(username="dewey")
self.be_sharps = self.homer.ensembles.create(name='Be Sharps', code="barbershop")
self.sesd = self.dewey.ensembles.create(name="Springfield Elementary School Band", code="sax")
self.sel = self.lisa.collections.create(name="Springfield Elementary Library")
self.flanders = self.ned.collections.create(name="Neds Shed")
def test_integration(self):
pass
def test_movement_from_large_work(self):
'''
Will be common to store a work which has several movements, but the project is only going to play one.
This also should give us the ability to store an anthology as one Work have Project reference 'no:23'
'''
work = self.sel.works.create(name="Some Quartet", composer="Beethoven")
for g in ('vl1', 'vl2', 'vla', 'vc'):
doc = work.docs.create(upload=f'sel/beethoven/some_quartet/some_quartet_{g}.pdf')
doc.sections.create(tag='mvmt:1', start=1, end=3)
doc.sections.create(tag='mvmt:2', start=4, end=8)
doc.sections.create(tag='mvmt:3', start=9, end=12)
doc.sections.create(tag=f'inst:{g}')
# no tags - get nothing (should it be everything?)
self.assertEqual(work.extract(), [])
# single tag - should get just that range
self.assertEqual(work.extract('inst:vl1'), [('sel/beethoven/some_quartet/some_quartet_vl1.pdf', None, None)])
# single tag - returns all documents with that range
result = work.extract('mvmt:2')
self.assertEqual(len(result), 4)
# multiple tags - returns the overlapping portion of all documents that have all tags
self.assertEqual(work.extract('inst:vl1', 'mvmt:2'), [('sel/beethoven/some_quartet/some_quartet_vl1.pdf', 4, 8)])
self.assertEqual(work.extract('inst:vl1', 'inst:vl2'), [])
-29
View File
@@ -1,29 +0,0 @@
from django.urls import path
from django.contrib.auth import views as auth_views
from . import views
urlpatterns = [
path('projects/<int:project>/items', views.ProjectItemListView.as_view(), name="item_list"),
path('projects/<int:project>/items/manage', views.ProjectItemManageView.as_view(), name="item_list_manage"),
path('projects/<int:project>/items/append', views.ProjectItemAddView.as_view(), name="item_list_append"),
path('library/collections', views.CollectionListView.as_view(), name="collection_list"),
path('library/collections/<int:pk>', views.CollectionWorkListView.as_view(), name="collection_work_list"),
path('library/collection/<int:pk>/create', views.WorkAddView.as_view(), name="work_add"),
path('library/works', views.WorkListView.as_view(), name="work_list"),
path('library/works/<int:pk>', views.WorkDetailView.as_view(), name="work_detail"),
path('library/works/<int:pk>/edit', views.WorkUpdateView.as_view(), name="work_edit"),
path('library/works/<int:pk>/partset', views.WorkPartSetView.as_view(), name="work_partset"),
path('library/works/<int:pk>/add_to_project', views.WorkAddToProject.as_view(), name="work_add_to_project"),
path('library/works/<int:pk>/upload', views.WorkAddDocumentView.as_view(), name="document_add"),
path('library/documents/<int:pk>/download', views.DocumentDownloadView.as_view(), name="document_download"),
path('library/documents/<int:pk>/annotate', views.DocumentAnnotateView.as_view(), name="document_annotate"),
path('library/parts/<int:pk>/<str:filename>', views.PartDownloadView.as_view(), name="part_download"),
]
from django.views.static import serve
urlpatterns.append(path('docs/<path:path>', serve, {'document_root': 'local_storage'}))
-361
View File
@@ -1,361 +0,0 @@
from django.shortcuts import render, redirect, resolve_url
from django.views.generic.detail import DetailView, SingleObjectMixin, View
from django.views.generic.list import ListView, MultipleObjectMixin
from django.views.generic.edit import CreateView, FormView, UpdateView
from django.http import FileResponse, HttpResponse, JsonResponse
from django.db import IntegrityError
from django.db.models import Q, Count, Sum
from django.utils.timezone import now
from django.urls import reverse
import json
import os.path
from interface.views import EnsembleMixin, ProjectMixin
from interface.models import Project
from .models import Collection, Work, Document, Section
from .imslp import INSTRUMENTS
from . import forms, models
from .pdf_utils import extract_pages, extract_and_concat
class ProjectItemListView(ProjectMixin, ListView):
template_name = "library/item_list.html"
model = models.ProjectItem
def post(self, request, **kwargs):
project = self.get_project()
project_works = project.works.all()
instruments = request.POST.getlist('instruments')
works = request.POST.getlist('works')
self.request.session['part'] = request.POST.get('part', '')
self.request.session['instrument'] = request.POST.get('instrument')
valid_pks = [ x.pk for x in project_works ]
sections = []
for i, pk in enumerate(works):
if int(pk) not in valid_pks:
raise Exception(f"Not a valid work pk: {pk}")
tag = instruments[i]
if tag == '-':
continue
part = Section.objects.filter(tag=tag, doc__work=pk).select_related('doc').get()
sections.append((part.doc.upload.path, part.doc.work.name, part.start, part.end, 1))
result = extract_and_concat(sections)
download_name = f'{project.name}.pdf'
response = FileResponse(result, content_type="application/pdf")
response['Content-Disposition'] = f'inline; filename="{download_name}"'
return response
def get_queryset(self):
return super(ProjectItemListView, self).get_queryset().select_related('project', 'work')
def get_context_data(self, **kwargs):
data = super(ProjectItemListView, self).get_context_data(**kwargs)
data['instruments'] = INSTRUMENTS
data['instrument'] = self.request.session.get('instrument', 'Score')
data['part'] = self.request.session.get('part', '0')
data['running_time'] = self.get_queryset().aggregate(Sum('work__running_time'))['work__running_time__sum']
#if running_time:
# data['running_time'] = "{0:d}:{1:02d}".format(int(running_time / 60), running_time % 60)
#else:
# data['running_time'] = "-:--"
return data
class ProjectItemManageView(ProjectMixin, ListView):
template_name = "library/item_list_manage.html"
model = models.ProjectItem
def post(self, request, **kwargs):
self.request = request
self.kwargs = kwargs
data = json.loads(request.body)
q = self.get_queryset()
for pk, order in data.items():
order = int(order)
if order == -1:
q.filter(pk=pk).delete()
else:
i = q.filter(pk=pk).update(order=order)
return HttpResponse(status=204)
def get_queryset(self):
return super(ProjectItemManageView, self).get_queryset().select_related('project', 'work')
class ProjectItemAddView(ProjectMixin, UpdateView):
form_class = forms.PlaylistAddForm
template_name = "interface/default_form.html"
def get_success_url(self):
return resolve_url('item_list_manage', project=self.kwargs['project'])
def get_object(self):
return self.get_project()
class CollectionListView(EnsembleMixin, ListView):
def get_queryset(self):
return Collection.objects.filter(administrators=self.request.user)
class WorkListView(EnsembleMixin, ListView):
paginate_by = 20
def get_works(self):
return Work.objects.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id).order_by('name').distinct().select_related('collection')
def get_context_data(self, *args, **kwargs):
data = super(WorkListView, self).get_context_data(*args, **kwargs)
data['title'] = f'Music available to {self.ensemble.name}'
return data
def get_queryset(self):
works = self.get_works().order_by('name')
q = self.request.GET.get('filter')
if q:
if ":" in q:
name, _, value = q.partition(":")
works = works.filter(meta_info__name=name, meta_info__value__contains=value)
else:
works = works.filter(Q(name__contains=q) | Q(composer__contains=q) | Q(meta_info__value__contains=q))
return works
class CollectionWorkListView(WorkListView):
def get_works(self):
works = Work.objects.filter(collection=self.kwargs['pk']).distinct()
loan_count = Count('project_items', Q(project_items__checkout__lte=now(), project_items__returned=None))
works = works.annotate(loan_count=loan_count)
return works
def get_context_data(self, *args, **kwargs):
data = super(CollectionWorkListView, self).get_context_data(*args, **kwargs)
data['title'] = Collection.objects.get(pk=self.kwargs['pk']).name
data['collection_id'] = self.kwargs['pk']
return data
class WorkAddView(EnsembleMixin, FormView):
template_name = "interface/default_form.html"
form_class = forms.WorkCreateForm
title = "Add a new work"
def form_valid(self, form):
work = form.save(commit=False)
work.ensemble_id = self.request.ensemble_id
work.collection_id = self.kwargs['pk']
work.save()
# handle the files
uploads = self.request.FILES.getlist('uploads')
docs = []
for f in uploads:
docs.append(work.docs.create(upload=f).pk)
if len(docs) == 1:
return redirect('document_annotate', docs[0])
else:
return redirect('work_detail', pk=work.pk)
class WorkMixin(object):
def get_queryset(self):
if self.request.is_admin:
return Work.objects.all()
return Work.objects.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id)
class WorkDetailView(EnsembleMixin, WorkMixin, DetailView):
pass
class WorkUpdateView(EnsembleMixin, WorkMixin, UpdateView):
fields = ['name', 'composer', 'edition', 'code', 'licence', 'max_projects', 'running_time', 'notes']
template_name = 'interface/default_form.html'
def get_success_url(self):
return resolve_url('work_detail', self.kwargs['pk'])
class WorkAddToProject(EnsembleMixin, FormView):
admin_required = True
form_class = forms.ProjectSelectForm
template_name = "interface/default_form.html"
title = "Select project to add work to"
def get_object(self):
return Work.objects.get(pk=self.kwargs['pk'])
def get_form(self):
f = super(WorkAddToProject, self).get_form()
qs = f.fields['project'].queryset
work = self.get_object()
qs = qs.filter(ensemble_id=self.request.ensemble_id).exclude(pk__in=work.projects.all())
f.fields['project'].queryset = qs
return f
def form_valid(self, form):
work = self.get_object()
project = form.cleaned_data['project']
work.project_items.create(project=project, approved_by=self.request.user, checkout=now())
return redirect('item_list', project=project.pk)
class WorkPartSetView(EnsembleMixin, DetailView):
template_name = "library/work_partset.html"
def post(self, request, *args, **kwargs):
work = self.get_object()
parts = request.POST.getlist('parts')
copies = request.POST.getlist('copies')
sections = []
for i, tag in enumerate(parts):
part = work.digital_parts.select_related('doc').get(tag=tag)
sections.append((part.doc.upload.path, part.instrument, part.start, part.end, int(copies[i])))
result = extract_and_concat(sections)
download_name = f'{work.name}.pdf'
response = FileResponse(result, content_type="application/pdf")
response['Content-Disposition'] = f'inline; filename="{download_name}"'
return response
def get_queryset(self):
works = Work.objects.all()
if not self.request.is_admin:
works = works.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id)
return works
class WorkAddDocumentView(EnsembleMixin, CreateView):
template_name = "interface/default_form.html"
model = Document
fields = ['upload']
def title(self):
work = Work.objects.get(pk=self.kwargs['pk'])
return f"Add a document to {work.name}"
def form_invalid(self, form):
if self.request.headers['Accept'] == 'application/json':
return HttpResponse(status=400)
return super().form_invalid(form)
def form_valid(self, form):
doc = form.save(commit=False)
doc.work_id = self.kwargs['pk']
doc.save()
if self.request.headers['Accept'] == 'application/json':
filename = os.path.basename(doc.upload.name)
return JsonResponse({
"message": "created",
"id": doc.pk,
"entry": f"""
<td><a href="{reverse('document_download', args=[doc.pk])}">{filename}</a></td>
<td/>
<td class="has-text-right">
<a href="{reverse('document_annotate', args=[doc.pk])}"><i class="fas fa-tags"
title="Manage Tags"></i></a>
<a href=""><i class="fas fa-trash-alt" title="Delete Document"></i></a>
</td>
"""
}, status=201)
return redirect('document_annotate', doc.pk)
class DocumentMixin(object):
def get_queryset(self):
if self.request.is_admin:
return Document.objects.select_related('work')
return Document.objects.filter(work__ensemble=self.request.ensemble_id).select_related('work')
class DocumentDetailView(EnsembleMixin, DocumentMixin, DetailView):
pass
class DocumentDownloadView(EnsembleMixin, DocumentMixin, SingleObjectMixin, View):
def get(self, request, **args):
self.request = request
self.args = args
self.object = self.get_object()
#response = FileResponse(self.object.upload, content_type="application/pdf")
#return response
return redirect(self.object.upload.url)
class DocumentAnnotateView(EnsembleMixin, DocumentMixin, DetailView):
template_name = 'library/document_annotate.html'
def post(self, request, **args):
self.request = request
self.args = args
self.object = self.get_object()
data = json.loads(request.body)
self.object.sections.all().delete()
for tag, start, end in data:
#pages.sort()
#end = pages[-1] if len(pages) > 1 else None
o = self.object.sections.create(tag=tag, start=start, end=end)
return HttpResponse(status=204)
def get_context_data(self, **kwargs):
data = super(DocumentAnnotateView, self).get_context_data(**kwargs)
pages = []
for part in data['document'].sections.all():
pages.append((part.tag, part.start, part.end))
data['json_data'] = {'pageTags': pages, 'instruments': dict(INSTRUMENTS)}
return data
class PartDownloadView(EnsembleMixin, SingleObjectMixin, View):
def get(self, request, **args):
self.request = request
self.args = args
self.object = self.get_object()
result = extract_pages(self.object.doc.upload.path, self.object.doc.work.name, self.object.start, self.object.end)
download_name = f'{self.object.doc.work.name}_{self.object.instrument}.pdf'
response = FileResponse(result, content_type="application/pdf")
response['Content-Disposition'] = f'inline; filename="foo.pdf"'
return response
def get_queryset(self):
if self.request.is_admin:
parts = Section.objects.all()
else:
parts = Section.objects.filter(doc__work__collection__allowed_ensembles__ensemble=self.request.ensemble_id)
return parts.select_related('doc', 'doc__work')
@@ -11,6 +11,6 @@ import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'polyphonic.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polyphonic.settings")
application = get_asgi_application()
+142
View File
@@ -0,0 +1,142 @@
"""
Django settings for polyphonic project.
Generated by 'django-admin startproject' using Django 3.1.1.
For more information on this file, see
https://docs.djangoproject.com/en/3.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
from pathlib import Path
import os
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent
# A place to put things
WORK_DIR = os.environ.get("WORK_DIR") or os.path.join(BASE_DIR, "data")
# Will fail to start if not defined
SECRET_KEY = os.environ.get("SECRET_KEY")
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
# Application definition
POLYPHONIC_MODULES = ["polyphonic.library"]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django_markdown2",
"rest_framework",
"crispy_forms",
"crispy_bulma",
"byostorage",
"polyphonic.interface",
]
INSTALLED_APPS += POLYPHONIC_MODULES
CRISPY_ALLOWED_TEMPLATE_PACKS = ("bulma",)
CRISPY_TEMPLATE_PACK = "bulma"
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "polyphonic.config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "polyphonic.config.wsgi.application"
# Database
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(WORK_DIR, "db.sqlite3"),
}
}
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Password validation
# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
LOGIN_REDIRECT_URL = "/"
# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
# Localisation (localization?)
LANGUAGE_CODE = "en-us"
TIME_ZONE = "Australia/Melbourne"
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.1/howto/static-files/
STATIC_URL = "/static/"
STATIC_ROOT = "static"
# Library settings
CACHED_STORAGE_REMOTE = "byostorage.user.BYOStorage"
CACHED_STORAGE_DIR = os.path.join(WORK_DIR, "cache")
WHOOSH_INDEX = os.path.join(WORK_DIR, "index")
STORAGE_CLASSES = ["polyphonic.library.gdrive.storage.GDriveLinkStorage"]
+28
View File
@@ -0,0 +1,28 @@
from .base import * # noqa
from os import environ
DEBUG = True
SECRET_KEY = "DO NOT USE IN PRODUCTION"
# Enable debug toolbar
INSTALLED_APPS.append("debug_toolbar") # noqa
MIDDLEWARE.insert(1, "debug_toolbar.middleware.DebugToolbarMiddleware") # noqa
INTERNAL_IPS = ["127.0.0.1"]
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": environ.get("DEBUG_LEVEL", "WARNING"),
},
},
"loggers": {
"polyphonic": {
"handlers": ["console"],
"level": environ.get("DEBUG_LEVEL", "WARNING"),
}
},
}
+4
View File
@@ -0,0 +1,4 @@
from .base import * # noqa
# Enable WhiteNoise for static files
MIDDLEWARE.insert(1, "whitenoise.middleware.WhiteNoiseMiddleware") # noqa
@@ -13,18 +13,20 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, re_path, include
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('interface.urls')),
#path('', include('submissions.urls')),
path('', include('library.urls')),
path("admin/", admin.site.urls),
path("", include("polyphonic.interface.urls")),
# path('', include('submissions.urls')),
path("", include("polyphonic.library.urls")),
]
try:
import debug_toolbar
urlpatterns.append(path('__debug__', include(debug_toolbar.urls)))
urlpatterns.append(path("__debug__", include(debug_toolbar.urls)))
except ImportError:
pass
@@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'polyphonic.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polyphonic.config.settings.base")
application = get_wsgi_application()
@@ -2,28 +2,33 @@ from django.contrib import admin
from . import models
class EnsembleAdmin(admin.ModelAdmin):
list_display = ['name', 'ensemble_code', 'slug']
list_display = ["name", "slug"]
class ModuleInline(admin.StackedInline):
model = models.Module
extra = 0
class ProjectAdmin(admin.ModelAdmin):
list_display = ['name', 'ensemble', 'event_date', 'active']
list_filter = ['ensemble', 'active']
class ProjectAdmin(admin.ModelAdmin):
list_display = ["name", "ensemble", "event_date", "active"]
list_filter = ["ensemble", "active"]
inlines = [ModuleInline]
class ResourceAdmin(admin.ModelAdmin):
list_display = ['name', 'media_type', 'project']
list_filter = ['project']
list_display = ["name", "media_type", "project"]
list_filter = ["project"]
class WikiPageAdmin(admin.ModelAdmin):
list_display = ['title', 'project']
list_filter = ['project']
list_display = ["title", "project"]
list_filter = ["project"]
admin.site.register(models.Ensemble, EnsembleAdmin)
admin.site.register(models.Project, ProjectAdmin)
admin.site.register(models.Resource, ResourceAdmin)
admin.site.register(models.WikiPage, WikiPageAdmin)
admin.site.register(models.WikiPage, WikiPageAdmin)
@@ -2,4 +2,4 @@ from django.apps import AppConfig
class InterfaceConfig(AppConfig):
name = 'interface'
name = "polyphonic.interface"
@@ -1,4 +1,5 @@
from crispy_forms.layout import Field
class BulmaFileUpload(Field):
template = 'bulma/file_upload.html'
template = "bulma/file_upload.html"
+76
View File
@@ -0,0 +1,76 @@
from django import forms
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit, HTML
from crispy_bulma.layout import FormGroup
from . import models, fields
class BaseForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = self.get_form_helper()
def get_form_helper(self):
helper = FormHelper(self)
# helper.add_input(Submit('submit', 'Submit', css_class='button is-link'))
# helper.layout.subm append(HTML('<a class="button is-light">Cancel</a>'))
# print(helper.layout)
helper.layout.append(
FormGroup(
Submit("submit", "Save", css_class="button is-primary"),
HTML(
'{% if view.cancel_url %}<div class="control"><a href="{{ view.cancel_url }}" class="button is-light">Cancel</a></div>{% endif %}'
),
)
)
return helper
class ProjectForm(forms.ModelForm, BaseForm):
class Meta:
model = models.Project
fields = ["name", "description", "modules", "event_date"]
# widgets = {
# 'event_date': forms.DateTimeInput(attrs={'type': 'date'})
# }
modules = forms.MultipleChoiceField(
choices=[(x, x.title()) for x in models.settings.POLYPHONIC_MODULES],
widget=forms.CheckboxSelectMultiple,
required=False,
)
class ResourceForm(forms.ModelForm, BaseForm):
class Meta:
model = models.Resource
fields = ["name", "media_type", "description", "file"]
def get_form_helper(self):
helper = super().get_form_helper()
helper[3].wrap(fields.BulmaFileUpload)
return helper
class WikiForm(forms.ModelForm, BaseForm):
class Meta:
model = models.WikiPage
fields = ["title", "markdown"]
class CodeForm(BaseForm):
code = forms.CharField(
max_length=14,
widget=forms.TextInput(
attrs={"placeholder": "xxx-xxx-xxx", "inputmode": "numeric"}
),
)
passphrase = forms.CharField(max_length=32)
class ResourceUploadForm(forms.Form):
pass
# file = S3UploadField()
@@ -1,21 +1,19 @@
# Generated by Django 3.2.7 on 2022-11-19 10:25
# Generated by Django 3.2.7 on 2022-11-19 01:24
import byostorage.user
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import interface.models
import polyphonic.interface.models
class Migration(migrations.Migration):
replaces = [('interface', '0001_initial'), ('interface', '0002_alter_module_name'), ('interface', '0003_alter_ensemble_slug'), ('interface', '0004_alter_project_event_date')]
initial = True
dependencies = [
('byostorage', '0004_alter_userstorage_storage'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('byostorage', '0006_alter_userstorage_settings_data'),
]
operations = [
@@ -25,7 +23,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Display name', max_length=100)),
('slug', models.SlugField(editable=False, help_text='Short name for the ensemble - used for folders', max_length=100, unique=True)),
('code', models.CharField(default=interface.models.generate_code, help_text='Ensemble registration code', max_length=9)),
('code', models.CharField(default=polyphonic.interface.models.generate_code, help_text='Ensemble registration code', max_length=9)),
('passphrase', models.CharField(help_text='Used to register ensembles', max_length=100)),
('details', models.TextField(blank=True, help_text='Description of the ensemble (markdown)')),
('admins', models.ManyToManyField(related_name='ensembles', to=settings.AUTH_USER_MODEL)),
@@ -62,7 +60,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('description', models.TextField(blank=True)),
('file', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=interface.models.resource_key)),
('file', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=polyphonic.interface.models.resource_key)),
('media_type', models.CharField(choices=[('audio', 'Audio'), ('video', 'Video'), ('general', 'General')], default='*', max_length=10)),
('visible', models.BooleanField(default=True)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='resources', to='interface.project')),
@@ -75,7 +73,7 @@ class Migration(migrations.Migration):
name='Module',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.SlugField(choices=[('library', 'Library'), ('submissions', 'Submissions')], max_length=20)),
('name', models.SlugField(choices=[('library', 'Library')], max_length=20)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modules', to='interface.project')),
],
),
@@ -0,0 +1,25 @@
# Generated by Django 3.2.7 on 2023-02-01 21:04
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('interface', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='project',
options={'ordering': ['active', 'event_date']},
),
migrations.RemoveField(
model_name='ensemble',
name='code',
),
migrations.RemoveField(
model_name='ensemble',
name='passphrase',
),
]
@@ -0,0 +1,27 @@
# Generated by Django 3.2.7 on 2023-02-08 22:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('interface', '0002_auto_20230202_0804'),
]
operations = [
migrations.AlterModelOptions(
name='ensemble',
options={'ordering': ('slug',)},
),
migrations.AddField(
model_name='ensemble',
name='auth',
field=models.SmallIntegerField(default=1, help_text='Increment this to reset the authentication links'),
),
migrations.AddField(
model_name='project',
name='auth',
field=models.SmallIntegerField(default=1, help_text='Increment this to reset the authentication links'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 3.2.7 on 2023-02-09 22:38
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('interface', '0003_auto_20230209_0910'),
]
operations = [
migrations.RenameField(
model_name='ensemble',
old_name='auth',
new_name='nonce',
),
migrations.RenameField(
model_name='project',
old_name='auth',
new_name='nonce',
),
]
+250
View File
@@ -0,0 +1,250 @@
from django.db import models
from django.utils.text import slugify
from django.utils import timezone
from django.conf import settings
from django.shortcuts import resolve_url
from byostorage.user import BYOStorage
import random
from .utils import sign_data
MEDIA_TYPES = [
("audio", "Audio"),
("video", "Video"),
("general", "General"),
]
def rough_date(d):
if not d:
return False, "sometime..."
days = (d - timezone.now()).days
in_past = days < 0
if in_past:
days = abs(days)
if days == 0:
m = int((d - timezone.now()).seconds / 60)
if m > 60:
return in_past, "{0:d} hours".format(int(m / 60))
return in_past, "{0:d} minutes!".format(int(m % 60))
if days >= 14:
return in_past, "{0:d} weeks".format(int(days / 7))
if days >= 7:
return in_past, "{0:d} weeks, {1:d} days".format(int(days / 7), int(days % 7))
return in_past, f"{days} days"
def generate_code(length=9):
return "".join([random.choice("0123456789") for _ in range(length)])
class EnsembleQuerySet(models.QuerySet):
def for_user(self, user, ensemble_keys=[], project_keys=[]):
if user.is_superuser:
return self
f = models.Q(slug__in=ensemble_keys) | models.Q(projects__in=project_keys)
if user.is_authenticated:
f |= models.Q(admins=user.pk)
return self.filter(f).distinct()
class Ensemble(models.Model):
"""A group that plays together"""
name = models.CharField(max_length=100, help_text="Display name")
slug = models.SlugField(
max_length=100,
editable=False,
unique=True,
help_text="Short name for the ensemble - used for folders",
)
admins = models.ManyToManyField("auth.User", related_name="ensembles")
details = models.TextField(
blank=True, help_text="Description of the ensemble (markdown)"
)
storage = models.ForeignKey(
"byostorage.UserStorage",
null=True,
on_delete=models.SET_NULL,
help_text="Default storage for this ensemble",
)
nonce = models.SmallIntegerField(
default=1, help_text="Increment this to reset the authentication links"
)
objects = EnsembleQuerySet.as_manager()
class Meta:
ordering = ("slug",)
def active_projects(self):
return self.projects.active().current()
def has_admin(self, user):
if not user.is_authenticated:
return False
if user.is_superuser:
return True
return user.pk in self.admins.values_list("pk", flat=True)
def save(self, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super(Ensemble, self).save(**kwargs)
def get_absolute_url(self):
return resolve_url("ensemble_detail", ensemble=self.slug)
def auth(self):
return sign_data(f"{self.pk}-{self.nonce}", 12)
def __str__(self):
return self.name
class ProjectQuerySet(models.QuerySet):
def current(self):
return self.filter(
models.Q(event_date__gte=(timezone.now() - timezone.timedelta(7)))
| models.Q(event_date=None)
)
def active(self):
return self.filter(active=True)
def for_user(self, user, project_keys=[], ensemble_keys=[]):
if user.is_superuser:
return self
f = models.Q(pk__in=project_keys) | models.Q(ensemble__slug__in=ensemble_keys)
if user.is_authenticated:
f |= models.Q(ensemble__admins=user.pk)
return self.filter(f)
class Project(models.Model):
"""A Project linked to an ensemble"""
name = models.CharField(max_length=100)
ensemble = models.ForeignKey(
Ensemble, related_name="projects", on_delete=models.CASCADE, null=True
)
description = models.TextField(blank=True, help_text="Markdown format")
active = models.BooleanField(default=True)
event_date = models.DateTimeField(null=True, blank=True)
owner = models.CharField(max_length=255, blank=True)
nonce = models.SmallIntegerField(
default=1, help_text="Increment this to reset the authentication links"
)
objects = ProjectQuerySet.as_manager()
class Meta:
ordering = ["active", "event_date"]
@property
def days(self):
return (self.event_date - timezone.now().date()).days
@property
def has_happened(self):
if not self.event_date:
return False
return self.event_date < timezone.now()
@property
def rough_date(self):
if not self.event_date:
return "No timescale"
in_past, s = rough_date(self.event_date)
if in_past:
return f"{s} ago"
return f"In {s}"
@property
def folder(self):
project = slugify(self.name)
print(f"{self.ensemble.storage_id}:{self.ensemble.slug}/{project}")
return f"{self.ensemble.storage_id}:{self.ensemble.slug}/{project}"
@property
def active_modules(self):
return self.modules.values_list("name", flat=True)
def get_absolute_url(self):
return resolve_url("project_detail", project=self.pk)
def auth(self):
return sign_data(f"{self.pk}-{self.nonce}", 12)
def __str__(self):
return self.name
class Module(models.Model):
"""Enable modules on a oriject"""
name = models.SlugField(
max_length=20, choices=[(x, x.title()) for x in settings.POLYPHONIC_MODULES]
)
project = models.ForeignKey(
Project, related_name="modules", on_delete=models.CASCADE
)
def __str__(self):
return self.name
def resource_key(resource, filename):
return f"{resource.project.folder}/resources/{filename}"
class Resource(models.Model):
"""A viewable file resource attached to a project
e.g PDF instructions, MP3 backing track
"""
project = models.ForeignKey(
Project, related_name="resources", on_delete=models.CASCADE
)
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
file = models.FileField(storage=BYOStorage(), upload_to=resource_key)
media_type = models.CharField(max_length=10, choices=MEDIA_TYPES, default="*")
visible = models.BooleanField(default=True)
class Meta:
ordering = ["-visible", "-pk"]
def accept(self):
if self.media_type == "general":
return ".*"
return f"{self.media_type}/*"
def __str__(self):
return self.name
class WikiPage(models.Model):
"""An editable wiki page for the project in markdown format"""
project = models.ForeignKey(
Project, related_name="wiki_pages", on_delete=models.CASCADE
)
title = models.CharField(max_length=255)
markdown = models.TextField()
def get_absolute_url(self):
return resolve_url("wiki", project=self.project_id, pk=self.pk)
def __str__(self):
return self.title
Binary file not shown.

Before

Width:  |  Height:  |  Size: 426 KiB

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

@@ -0,0 +1,29 @@
document.addEventListener('DOMContentLoaded', () => {
// Get all "navbar-burger" elements
const navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);
// Add a click event on each of them
navbarBurgers.forEach( el => {
el.addEventListener('click', () => {
// Get the target from the "data-target" attribute
const target = document.getElementById(el.dataset.target);
// Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu"
el.classList.toggle('is-active');
target.classList.toggle('is-hidden-touch');
});
});
// set all active links
const here = location.toString();
for (const el of document.getElementsByTagName('a')) {
if (el.href == here) {
el.classList.add('is-active');
}
}
});
+8
View File
@@ -0,0 +1,8 @@
{% extends "interface/project_base.html" %}
{% block page %}
<div class="hero">
<h3 class="is-size-3">Sorry, you do not have permission to do that!</h3>
<p>{{ exception }}</p>
</div>
{% endblock %}
+8
View File
@@ -0,0 +1,8 @@
{% extends "interface/project_base.html" %}
{% block page %}
<div class="hero">
<h3 class="is-size-3">Sorry, that resource is not found.</h3>
<p>{{ exception }}</p>
</div>
{% endblock %}
@@ -9,19 +9,25 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css">
<link rel="stylesheet" href="{% static 'interface/css/polyphonic.css' %}"></link>
<script src="{% static 'interface/js/interface.js' %}"></script>
<script src="//unpkg.com/alpinejs" defer></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js" defer></script>
<!-- script src="//kit.fontawesome.com/c837098e5b.js" crossorigin="anonymous" defer></script -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" rel="stylesheet" />
<title>{% block title %}Polyphonic{% endblock %}</title>
{% block media %}{% endblock %}
<style>{% block style %}{% endblock %}</style>
</head>
<body>
{% block navigation %}
<nav class="navbar" role="navigation">
<div class="navbar-brand has-text-primary">
<a class="navbar-item" href="/">
<span class="icon fancy is-size-2 mx-4"><i class="fas fa-random"></i></span>
<span class="fancy is-size-2">Polyphonic</span>
<span class="icon fancy mx-4"><span class="material-symbols-outlined is-size-1 is-size-3-mobile">groups</span></span>
<span class="fancy is-size-2 is-size-4-mobile">Polyphonic</span>
</a>
<span class="navbar-item is-hidden-mobile fancy is-size-5">Music Ensemble Manager</span>
<span class="navbar-item is-hidden-mobile fancy is-size-5">Musical Ensemble Manager</span>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="projectMenu">
@@ -34,7 +40,7 @@
<div id="navbarMain" class="navbar-menu">
<div class="navbar-end">
<span class="navbar-item is-size-4">{% firstof ensemble project.ensemble %}</span>
<span class="navbar-item is-size-5-touch is-size-4-tablet">{% firstof ensemble project.ensemble %}</span>
</div>
</div>
@@ -47,9 +53,7 @@
{% endblock %}
<!-- late load scripts -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="https://kit.fontawesome.com/c837098e5b.js" crossorigin="anonymous"></script>
{% block scripts %}
{% endblock %}
</body>
</html>
</html>
@@ -6,7 +6,7 @@
{% crispy_field field 'class' 'file-input'%}
<span class="file-cta">
<span class="file-icon">
<i class="fas fa-upload"></i>
<span class="material-symbols-outlined">file_upload</span>
</span>
<span class="file-label">
Choose a file…
@@ -25,4 +25,4 @@
fileName.textContent = fileInput.files[0].name;
}
}
</script>
</script>
@@ -8,11 +8,11 @@
{% block page %}
<h3 class="subtitle">{% firstof title view.title %}</h3>
<div class="columns is-centered">
<div class="column is-form-group">
<div class="column is-two-thirds">
{% if instructions %}
<p>{{ instructions }}</p>
{% endif %}
{% crispy form %}
{% crispy form %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,58 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% load polyphonic %}
{% block admin %}
<a href="{% url 'project_create' object.slug %}" class="button is-link">
{{ "add_notes"|icon }}
<span>Add project</span>
</a>
{% if inactive %}
<a href="?" class="button is-link">
{{ "preview_off"|icon }}
<span>Hide old</span>
</a>
{% else %}
<a href="?inactive" class="button is-link">
{{ "preview"|icon }}
<span>Show all</span>
</a>
{% endif %}
{% endblock %}
{% block page %}
<h3 class="title">Projects for {{ensemble.name }}</h3>
<div class="content">
{{ ensemble.details|markdown }}
</div>
<div class="block">
Contacts:
{% for admin in ensemble.admins.all %}
<a href="mailto:{{ admin.email }}" class="tag">{% firstof admin.get_full_name admin.get_username %}</a>
{% endfor %}
</div>
{% include 'interface/project_items.html' %}
{% if request.is_admin %}
<div class="">
<div class="card">
<header class="card header">
<p class="card-header-title">Admin Details</p>
</header>
<div class="card-content">
<ul>
<li><a href="{{ ensemble_link }}">Ensemble Sharing Link</a></li>
<li><a href="{% url 'project_create' ensemble.pk %}">Add a new project</a></li>
</ul>
</div>
</div>
</div>
{% endif %}
<div>
<a href="{% url 'forget_resource' 'ensemble' ensemble.slug %}">Forget this ensemble</a>
</div>
{% endblock %}
@@ -0,0 +1,51 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% block page %}
{% comment %}
<div class="admin-tools is-pulled-right">
<a class="button is-link" href="{% url 'register' %}">
{% icon "add_file" %}
<span>Register another</span>
</a>
</div>
{% endcomment %}
<h3 class="title">My Ensembles</h3>
<div class="columns is-multiline">
{% for ensemble in object_list %}
<div class="column is-half-tablet is-one-third-widescreen">
<div class="card">
<div class="card-content">
<div class="media">
<div class="media-left">
<figure class="image is-48x48">
<img src="https://www.gravatar.com/avatar/{{ ensemble.email }}?d=mp" alt="Placeholder image">
</figure>
</div>
<div class="media-content" style="min-height: 100px">
<a href="{% url 'ensemble_detail' ensemble.slug %}">
<p class="title is-4">{{ ensemble.name }}</p>
</a>
<div class="mt-3">
{{ ensemble.details|markdown }}
</div>
</div>
</div>
</div>
<div class="card-footer">
{% with projects=ensemble.active_projects.count %}
<a class="card-footer-item" href="{% url 'ensemble_detail' ensemble.slug %}">{{ projects }} active project{{ projects|pluralize }}</a>
{% endwith %}
</div>
</div>
</div>
{% empty %}
<div class="hero">
You don't currently have access to any ensembles - ask your administrator for a link.
</div>
{% endfor %}
</div>
{% endblock %}
@@ -2,9 +2,7 @@
{% block content %}
<div class="columns">
<div class="columns mx-2">
<div class="column is-narrow is-hidden-touch" id="projectMenu">
<div style="margin: auto 2em;">
<aside class="menu">
@@ -12,44 +10,41 @@
<p class="menu-label">My Things</p>
<ul class="menu-list">
<li><a href="{% url 'ensemble_list' %}">Ensembles</a></li>
<li><a href="{% url 'ensemble_detail' %}">Projects</a></li>
<li><a href="{% url 'work_list' %}">Library</a></li>
<li><a href="{% url 'project_list' %}">Projects</a></li>
{% if request.user.is_authenticated %}
<!--li><a href="{% url 'work_list' %}">Library</a></li-->
<li><a href="{% url 'collection_list' %}">Collections</a></li>
{% endif %}
</ul>
{% if project %}
<p class="menu-label">This Project</p>
<ul class="menu-list">
<li><a role="tab" href="{% url 'project_detail' project=project.id %}">Project Info</a></li>
{% for page in project.wiki_pages.all %}
<li><a class="nav-link {% if page.id == wiki_id %}active{% endif %}"
href="{% url 'wiki' project=project.id pk=page.id %}">{{ page.title }}</a></li>
{% endfor %}
<li><a role="tab" href="{% url 'resource_list' project=project.pk %}">Resources</a></li>
{% if 'library' in modules %}
<li><a href="{% url 'item_list' project=project.id %}">My Music</a></li>
{% endif %}
{% if 'submission' in modules %}
<!--a role="tab" href="">Record a submission</a-->
{% if request.is_admin %}
<li><a role="tab" class="admin-link" href="{% url 'submission_list' project=project.id %}">Submissions</a></li>
<ul class="menu-list">
<li><a role="tab" href="{% url 'project_detail' project=project.id %}">Project Info</a></li>
{% if 'library' in modules %}
<li><a class="nav-link" href="{% url 'item_list' project=project.pk %}">My Music</a></li>
{% endif %}
{% for page in project.wiki_pages.all %}
<li><a class="nav-link"
href="{% url 'wiki' project=project.id pk=page.id %}">{{ page.title }}</a></li>
{% endfor %}
<li><a role="tab" href="{% url 'resource_list' project=project.pk %}">Resources</a></li>
</ul>
{% endif %}
<li><a role="tab" href="{% url 'submission_create' project=project.id %}">Send a File</a></li>
{% endif %}
{% if collection %}
<p class="menu-label">Collection</p>
<ul class="menu-list">
<li><a role="tab" href="{% url 'collection_work_list' collection.pk %}">Work List</a></li>
</ul>
{% endif %}
<p class="menu-label">Admin</p>
{% if request.is_admin %}
<ul class="menu-list">
<li><a class="admin-link" href="{% url 'collection_list' %}">Collections</a></li>
{% if request.user.is_superuser %}
<li><a class="admin-link" href="/admin" target="polyphonic_admin" rel="noopener noreferrer">Django Admin</a></li>
{% if request.user.is_staff %}
<li><a href="/admin" target="polyphonic_admin" rel="noopener noreferrer">Django Admin</a></li>
{% endif %}
</ul>
{% endif %}
<ul class="menu-list">
{% if request.user.is_authenticated %}
@@ -76,19 +71,19 @@
{% if project.resources.count %}
<li><a href="{% url 'resource_list' project=project.pk %}">Resources</a></li>
{% endif %}
{% if project.enable_library %}
{% if 'library' in modules %}
<li><a href="{% url 'item_list' project=project.pk %}">My Music</a></li>
{% endif %}
{% if project.enable_submissions %}
{% if 'submission' in modules %}
<li><a href="{% url 'submission_create' project=project.pk %}">Send File</a></li>
{% endif %}
</ul>
</div>
{% endif %}
<section class="section">
<div class="block">
{% if request.is_admin %}
<div class="admin-tools is-pulled-right">
<div class="admin-tools is-pulled-right is-hidden-mobile">
{% block admin %}
{% endblock %}
</div>
@@ -97,14 +92,8 @@
{% block page %}
No content
{% endblock %}
</section>
</div>
</div>
</div>
{% if ensemble %}
<div class="project-footer">
{{ ensemble.name }}
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,84 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% load polyphonic %}
{% block admin %}
<a href="{% url 'wiki_create' project=project.pk %}" class="button is-link">
{{ "add_notes"|icon }}
<span>Add Page</span>
</a>
<a href="{% url 'project_edit' project=project.pk %}" class="button is-link">
{{ "edit"|icon }}
<span>Edit</span>
</a>
{% endblock %}
{% block page %}
<div class="columns is-multiline">
<div class="column ">
<div class="box">
{% if project.event_date %}
<h3 class="subtitle is-centered">
<b>{{ project.event_date|date:"l jS F Y, g:i A" }}</b>
{% if project.has_happened %}
({{ project.event_date|roughtimesince }} ago)
{% else %}
(in {{ project.event_date|roughtimeuntil }})
{% endif %}
</h3>
{% endif %}
<div class="block">
{% if project.description %}
<p class="content">{{ project.description|markdown }}</p>
{% else %}
<p>No description</p>
{% endif %}
</div>
{% if project.owner %}
<div class="block">
{% if project.owner.email %}
The project owner is <a href="mailto:{{ project.owner.email }}">{{ project.owner }}</a>
{% else %}
The project owner is {{ project.owner }}.
{% endif %}
</div>
{% endif %}
</div>
</div>
{% if 'library' in modules %}
<div class="column is-one-third">
{% include 'library/project_detail.html' %}
</div>
{% endif %}
{% if 'submission' in modules %}
<div class="column">
{% include 'submissions/project_detail.html' %}
</div>
{% endif %}
</div>
{% if request.is_admin %}
<div class="box">
<h3 class="subtitle">Admin Actions</h3>
<ul>
<li><a href="{{ project_link }}">Project Link</a></li>
{% if 'library' in modules %}
<li><a href="{% url 'item_list_manage' project=project.pk %}">Manage items</a></li>
{% endif %}
</ul>
</div>
{% endif %}
<!--
<div>
<a href="{% url 'forget_resource' 'project' project.pk %}">Forget this project</a>
</div>
-->
{% endblock %}
@@ -0,0 +1,36 @@
{% load md2 %}
<div class="columns is-multiline">
{% for project in object_list %}
<div class="column is-half-tablet is-one-third-widescreen">
<div class="card">
<a class="" href="{% url 'project_detail' project=project.id %}">
<header class="card-header{% if not project.active %} has-background-light{% endif %}">
<p class="card-header-title">
{{ project.name }}
</p>
<p class="card-header-icon" style="color: black;">{{ project.rough_date }}</p>
</header>
</a>
<div class="card-content" style="height: 100px; overflow: hidden">
<div class="content">
{{ project.description | markdown }}
</div>
</div>
{% if not ensemble %}
<div class="card-footer">
<a class="card-footer-item" href="{% url 'ensemble_detail' project.ensemble.slug %}">{{ project.ensemble }}</a>
</div>
{% endif %}
</div>
</div>
{% empty %}
<div class="hero">
<div class="hero-body">
<p class="title">No projects currently planned</p>
<p class="subtitle">Go put your feet up!</p>
</div>
</div>
{% endfor %}
</div>
@@ -0,0 +1,8 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% block page %}
<h3 class="title">My Projects</h3>
{% include 'interface/project_items.html' %}
{% endblock %}
@@ -1,14 +1,16 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% load polyphonic %}
{% block admin %}
<a class="button is-link" href="{% url 'resource_create' project=project.pk %}">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
{% icon "add_notes" %}
<span>Add new</span>
</a>
{% endblock %}
{% block page %}
<h3 class="subtitle">Resources</h3>
<div class="columns is-multiline">
{% for resource in object_list %}
{% with download=resource.file.url %}
@@ -26,11 +28,11 @@
<div class="card-header-icon">
{% if request.is_admin %}
<a href="{% url 'resource_upload' project=project.pk pk=resource.pk %}" class="icon" title="Upload">
<i class="fas fa-upload"></i>
<a href="{% url 'resource_upload' project=project.pk pk=resource.pk %}" title="Upload">
{% icon "upload_file" %}
</a>
<a href="{% url 'resource_edit' project=project.pk pk=resource.pk %}" class="icon" title="Edit">
<i class="fas fa-edit"></i>
<a href="{% url 'resource_edit' project=project.pk pk=resource.pk %}" title="Edit">
{% icon "edit" %}
</a>
{% endif %}
</div>
@@ -51,7 +53,7 @@
{% endwith %}
{% empty %}
<div class="column">
<p>There are resources for this project</p>
<p>There are no resources for this project</p>
</div>
{% endfor %}
</div>
@@ -1,9 +1,10 @@
{% extends "interface/project_base.html" %}
{% load polyphonic %}
{% block admin %}
<a href="{% url 'wiki_edit' project=project.pk pk=wikipage.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-edit"></i></span>
<span>Edit</span>
{{ "edit"|icon }}
<span>Edit</span>
</a>
{% endblock %}
@@ -12,4 +13,4 @@
<div class="box content wiki-page">
{{ wiki_html|safe }}
</div>
{% endblock %}
{% endblock %}
@@ -8,7 +8,10 @@
{% block page %}
<h3 class="subtitle">{% firstof title view.title %}</h3>
<div class="columns is-centered">
<div class="column is-form-group">
<div class="column">
{% if instructions %}
<p>{{ instructions }}</p>
{% endif %}
{% crispy form %}
</div>
</div>
@@ -7,7 +7,6 @@
<div class="box is-half">
<p class="block">
Login is only required to administer a project.<br/>
If you have an ensemble code <a href="{% url 'register' %}">enter it here</a> instead.
</p>
<form method="POST" class="vertical">
{% csrf_token %}
@@ -15,7 +14,7 @@
<div class="field is-grouped">
<div class="control">
<button class="button is-link">Login</button>
<a href="{% url 'ensemble_detail' %}" class="button is-light">Cancel</a>
<a href="{% url 'home' %}" class="button is-light">Cancel</a>
</div>
</div>
</form>
@@ -3,7 +3,9 @@ import os.path
register = template.Library()
def basename(value):
return os.path.basename(value)
register.filter('basename', basename)
register.filter("basename", basename)
@@ -0,0 +1,36 @@
from django import template
from django.utils import timesince
from django.utils.html import format_html
register = template.Library()
@register.filter("icon", is_safe=True)
def material_icon(value):
return f'<span class="icon"><span class="material-symbols-outlined">{value}</span></span>'
@register.simple_tag
def icon(name, element="span", classes=[]):
classes = ["icon"] + classes
return format_html(
'<{} class="{}"><span class="material-symbols-outlined">{}</span></{}>',
element,
" ".join(classes),
name,
element,
)
def roughtimesince(value):
return timesince.timesince(value, depth=1)
register.filter("roughtimesince", roughtimesince)
def roughtimeuntil(value):
return timesince.timeuntil(value, depth=1)
register.filter("roughtimeuntil", roughtimeuntil)
@@ -2,9 +2,10 @@ from django import template
register = template.Library()
@register.simple_tag(takes_context=True)
def url_update(context, **kwargs):
params = context.request.GET.copy()
for k in kwargs:
params[k] = kwargs[k]
return "?" + params.urlencode()
return "?" + params.urlencode()
+71
View File
@@ -0,0 +1,71 @@
from django.test import TestCase
from polyphonic.interface import models
from django.contrib.auth.models import User
from django.utils import timezone
from datetime import timedelta
class AccessTestCase(TestCase):
USERS = ()
ENSEMBLES = ()
PROJECTS = ()
PROTECTED_URLS = ()
@classmethod
def setUpTestData(cls):
cls.users = {}
for details in cls.USERS:
cls.users[details["username"]] = User.objects.create_user(**details)
now = timezone.now()
cls.ensembles = {}
for details in cls.ENSEMBLES:
admins = details.pop("admins", [])
obj = models.Ensemble.objects.create(**details)
for admin in admins:
obj.admins.add(cls.users[admin])
cls.ensembles[obj.slug] = obj
cls.projects = {}
for details in cls.PROJECTS:
when = details.pop("when", 0)
ensemble = details.pop("ensemble")
details["event_date"] = now + timedelta(days=when) if when else None
obj = cls.ensembles[ensemble].projects.create(**details)
cls.projects[details["name"]] = obj
return
def test_protected_views(self):
self.assertAccess({x: False for x in self.PROTECTED_URLS})
if "admin" in self.users:
self.client.force_login(self.users["admin"])
self.assertAccess({x: True for x in self.PROTECTED_URLS})
def login(self, user, passwd):
response = self.client.post("/login", {"username": user, "password": passwd})
self.assertEqual(response.status_code, 302, f"Failed to login as {user}")
def authorize(self, model, **kwargs):
object = model.objects.get(**kwargs)
response = self.client.get(f"{object.get_absolute_url()}?auth={object.auth()}")
self.assertEqual(response.status_code, 302)
def assertAccess(self, urls):
for url, expected in urls.items():
response = self.client.get(url)
self.assertEqual(
response.status_code == 200,
expected,
f"Expected {expected} for {url} (status: {response.status_code})",
)
def assertObjectList(self, response, expected, element="name"):
self.assertEqual(response.status_code, 200, "No result returned")
objects = response.context["object_list"].values_list(element, flat=True)
self.assertEqual(list(objects), expected)
+214
View File
@@ -0,0 +1,214 @@
from polyphonic.interface import models
from . import AccessTestCase
class InterfaceAccessTestCase(AccessTestCase):
USERS = (
{
"username": "admin",
"password": "secret",
"is_superuser": True,
"is_staff": True,
},
{"username": "homer", "password": "maggie"},
)
ENSEMBLES = (
{"name": "The Be Sharps", "slug": "be-sharps", "admins": ["homer"]},
{"name": "Lisa & the Bleeding Gums", "slug": "bleeding-gums"},
{"name": "Party Posse"},
)
PROJECTS = (
{"name": "Baker St", "ensemble": "bleeding-gums", "when": -12},
{"name": "Navy Recruitment Day", "ensemble": "party-posse", "when": 6},
{"name": "Barbershop Contest", "ensemble": "be-sharps", "when": 28},
{"name": "Open Mic Night", "ensemble": "bleeding-gums", "when": 1},
{"name": "Current Repertoire", "ensemble": "be-sharps"},
)
PROTECTED_URLS = (
"/ensembles/be-sharps",
"/ensembles/be-sharps/new-project",
"/projects/3",
"/projects/3/resources",
"/projects/3/resources/add",
"/admin/interface/ensemble/",
"/admin/interface/project/",
"/admin/interface/resource/",
"/admin/interface/wikipage/",
)
def test_bad_login(self):
with self.assertRaisesMessage(
self.failureException, "Failed to login as admin"
):
self.login("admin", "admin")
def test_superuser_ensembles(self):
self.login("admin", "secret")
response = self.client.get("/ensembles")
self.assertObjectList(
response, ["The Be Sharps", "Lisa & the Bleeding Gums", "Party Posse"]
)
self.assertContains(response, "Django Admin")
def test_superuser_ensemble_permissions(self):
self.login("admin", "secret")
response = self.client.get("/ensembles/party-posse")
self.assertTrue(response.context["request"].is_admin)
self.assertContains(response, "Add project")
self.assertAccess(
{
"/ensembles/be-sharps": True,
"/ensembles/bleeding-gums": True,
"/ensembles/party-posse": True,
"/ensembles/unknown": False,
"/ensembles/be-sharps/new-project": True,
}
)
def test_superuser_projects(self):
self.login("admin", "secret")
response = self.client.get("/projects")
self.assertObjectList(
response,
[
"Current Repertoire",
"Open Mic Night",
"Navy Recruitment Day",
"Barbershop Contest",
],
)
self.assertObjectList(
self.client.get("/ensembles/bleeding-gums"), ["Open Mic Night"]
)
self.assertObjectList(
self.client.get("/ensembles/bleeding-gums?inactive"),
["Open Mic Night", "Baker St"],
)
def test_user_ensembles(self):
self.login("homer", "maggie")
response = self.client.get("/ensembles")
self.assertObjectList(response, ["The Be Sharps"])
self.assertNotContains(response, "Django Admin")
def test_user_ensemble_permissions(self):
self.login("homer", "maggie")
response = self.client.get("/ensembles/be-sharps")
self.assertTrue(response.context["request"].is_admin)
self.assertContains(response, "Add project")
self.assertContains(response, "Show all")
self.assertAccess(
{
"/ensembles/be-sharps": True,
"/ensembles/bleeding-gums": False,
"/ensembles/party-posse": False,
"/ensembles/be-sharps/new-project": True,
"/ensembles/party-posse/new-project": False,
}
)
self.authorize(models.Ensemble, slug="bleeding-gums")
self.assertAccess(
{
"/ensembles/be-sharps": True,
"/ensembles/bleeding-gums": True,
"/ensembles/party-posse": False,
"/ensembles/be-sharps/new-project": True,
"/ensembles/party-posse/new-project": False,
}
)
response = self.client.get("/ensembles/bleeding-gums")
self.assertFalse(response.context["request"].is_admin)
self.assertNotContains(response, "Add project")
self.assertNotContains(response, "Show all")
def test_user_projects(self):
self.login("homer", "maggie")
response = self.client.get("/projects")
self.assertObjectList(response, ["Current Repertoire", "Barbershop Contest"])
response = self.client.get("/projects/3")
self.assertTrue(response.context["request"].is_admin)
self.assertAccess(
{
"/projects/3": True,
"/projects/3/resources": True,
"/projects/3/resources/add": True,
"/projects/4": False,
"/projects/4/resources": False,
"/projects/4/resources/add": False,
}
)
self.authorize(models.Project, pk=4)
response = self.client.get("/projects")
self.assertObjectList(
response, ["Current Repertoire", "Open Mic Night", "Barbershop Contest"]
)
response = self.client.get("/projects/4")
self.assertFalse(response.context["request"].is_admin)
def test_anon_ensembles(self):
response = self.client.get("/ensembles")
self.assertObjectList(response, [])
self.assertContains(
response, "You don't currently have access to any ensembles"
)
def test_anon_authorized_ensemble(self):
self.authorize(models.Ensemble, slug="party-posse")
response = self.client.get("/ensembles/party-posse")
self.assertContains(response, "Party Posse")
response = self.client.get("/ensembles")
self.assertObjectList(response, ["Party Posse"])
self.assertAccess(
{
"/ensembles/be-sharps": False,
"/ensembles/party-posse": True,
"/ensembles/bleeding-gums": False,
"/ensembles/unknown": False,
}
)
response = self.client.get("/projects")
self.assertObjectList(response, ["Navy Recruitment Day"])
def test_anon_authorized_project(self):
self.authorize(models.Project, pk=4)
self.assertObjectList(self.client.get("/projects"), ["Open Mic Night"])
self.assertObjectList(
self.client.get("/ensembles"), ["Lisa & the Bleeding Gums"]
)
self.assertAccess(
{
"/projects/4": True,
"/projects/4/resources": True,
"/projects/1": False,
"/projects/1/resources": False,
}
)
def test_anon_permission_denied(self):
self.assertAccess(
{
"/ensembles": True,
"/ensembles/be-sharps": False,
"/ensembles/party-posse": False,
"/ensembles/bleeding-gums": False,
"/ensembles/unknown": False,
}
)
def test_anon_deauthorize_project(self):
self.authorize(models.Project, pk=4)
self.assertAccess({"/projects/4": True})
models.Project.objects.filter(pk=4).update(nonce=2)
self.assertAccess({"/projects/4": False})
@@ -1,6 +1,6 @@
from django.test import TestCase
class IntegrationTestCase(TestCase):
class IntegrationTestCase(TestCase):
def test_runs(self):
self.assertTrue(True)
self.assertTrue(True)
+79
View File
@@ -0,0 +1,79 @@
from django.urls import path
from django.contrib.auth import views as auth_views
from django.views.generic.base import RedirectView
from django.conf import settings
from . import views
urlpatterns = [
path("", RedirectView.as_view(url="projects", permanent=False), name="home"),
path("login", auth_views.LoginView.as_view(), name="login"),
path("logout", views.logout_view, name="logout"),
path(
"forget/<resource>/<key>",
views.ForgetResourceView.as_view(),
name="forget_resource",
),
path("ensembles", views.EnsembleListView.as_view(), name="ensemble_list"),
path(
"ensembles/<slug:ensemble>",
views.EnsembleDetailView.as_view(),
name="ensemble_detail",
),
path(
"ensembles/<slug:ensemble>/new-project",
views.ProjectCreateView.as_view(),
name="project_create",
),
path("projects", views.ProjectListView.as_view(), name="project_list"),
path(
"projects/<int:project>",
views.ProjectDetailView.as_view(),
name="project_detail",
),
path(
"projects/<int:project>/edit",
views.ProjectUpdateView.as_view(),
name="project_edit",
),
# path('projects/<int:project>/submissions.mk', views.ProjectMakefileView.as_view(), name="project_makefile"),
path(
"projects/<int:project>/page/create",
views.WikiCreateView.as_view(),
name="wiki_create",
),
path("projects/<int:project>/page/<int:pk>", views.WikiView.as_view(), name="wiki"),
path(
"projects/<int:project>/page/<int:pk>/edit",
views.WikiEditView.as_view(),
name="wiki_edit",
),
path(
"projects/<int:project>/resources",
views.ResourceListView.as_view(),
name="resource_list",
),
path(
"projects/<int:project>/resources/add",
views.ResourceCreateView.as_view(),
name="resource_create",
),
path(
"projects/<int:project>/resources/<int:pk>/upload",
views.ResourceUploadView.as_view(),
name="resource_upload",
),
path(
"projects/<int:project>/resources/<int:pk>/edit",
views.ResourceEditView.as_view(),
name="resource_edit",
),
]
if settings.DEBUG:
from django.views.static import serve
urlpatterns.append(
path("local_storage/<path:path>", serve, {"document_root": "local_storage"})
)
+43
View File
@@ -0,0 +1,43 @@
from django.shortcuts import resolve_url
from django.core.signing import Signer
from django.core.exceptions import SuspiciousOperation
import logging
signer = Signer()
logger = logging.getLogger(__name__)
def sign_data(data, length=None):
sig = signer.sign(data)
pos = len(data) + 1
if length:
length += pos
return sig[pos:length]
def signed_url(name, **kwargs):
"""
>>> signed_url('foo/bar')
"""
url = resolve_url(name, **kwargs)
sig = signer.sign(url)
sep = "&" if "?" in url else "?"
return sig.replace(":", f"{sep}auth=")
def check_signed_url(full_path):
p = full_path.rfind("auth")
url = full_path[: p - 1]
logger.debug("check_signed_url: %s", url)
signed = signed_url(url)
if signed != full_path:
logger.debug("Mismatch: %s != %s", full_path, signed)
signed = "_HIDDEN_"
raise SuspiciousOperation("Bad auth code")
if __name__ == "__main__":
import doctest
print(doctest.testmod())
+456
View File
@@ -0,0 +1,456 @@
""" """
# 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
from django.core.exceptions import SuspiciousOperation
from django.http import Http404, HttpResponseRedirect
from django.http.request import HttpRequest
from django.contrib.auth import logout
from markdown2 import markdown
from . import models, forms
from .utils import check_signed_url
import logging
logger = logging.getLogger(__name__)
class AuthorizedResourceMixin(object):
"""
Handles these parts of the permission system:
* signed urls
* persistent authenticated resources
* Admin enforcing
"""
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: # type: ignore
# logger.debug("is_authorized: superuser")
return True
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)
return False
if auth == current:
return True
# 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"
return self._authorized.get(resource, {})
def add_authorized_key(self, resource, key, auth):
"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 # type: ignore
def del_authorized_key(self, resource, key):
logger.info("Revoking authorization for %s %s", resource, key)
current = self.get_authorized_keys(resource)
if current.pop(key, None) is None:
return False
if current:
self._authorized[resource] = current
else:
self._authorized.pop(resource)
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."
)
def dispatch(self, request, *args, **kwargs):
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) # type: ignore
# TODO: RevokeResourceView - increment nonce
def logout_view(request):
logout(request)
return redirect("/")
class ForgetResourceView(AuthorizedResourceMixin, RedirectView):
def is_authorized(self):
return True
def get_redirect_url(self, resource, key):
self.del_authorized_key(resource, key)
return "/"
class EnsembleMixin(AuthorizedResourceMixin):
ensemble_slug_kwarg = "ensemble"
limited_project_access = False
def is_authorized(self):
ensemble_slug = self.kwargs[self.ensemble_slug_kwarg]
self.ensemble = get_object_or_404(models.Ensemble, slug=ensemble_slug)
if super().is_authorized():
return 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):
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))
logger.debug("is_authorized: %r & %r", authorized, projects)
if authorized & projects:
self.limited_project_access = True
logger.debug("is_authorized: allowing due to project link")
return True
return False
def get_object(self, queryset=None):
return self.ensemble
class ProjectMixin(AuthorizedResourceMixin):
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
)
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): # type: ignore
logger.debug("is_authorized: ensemble admin for project")
self.request.is_admin = True # type: ignore
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")
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) # type: ignore
def get_context_data(self, **kwargs):
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):
cancel_url = None
def get_cancel_url(self):
return self.cancel_url
""" ENSEMBLE VIEWS """
class EnsembleListView(AuthorizedResourceMixin, ListView):
def is_authorized(self):
return True
def get_queryset(self):
return models.Ensemble.objects.for_user(
self.request.user, # type: ignore
self.get_authorized_keys("ensemble").keys(),
self.get_authorized_keys("project").keys(),
)
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)
)
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 # type: ignore
projects = self.ensemble.projects.all()
if self.limited_project_access:
projects = projects.filter(
pk__in=self.get_authorized_keys("project").keys()
)
if inactive:
projects = projects.order_by("-pk")
else:
projects = projects.active().current()
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):
def get_redirect_url(self):
return
""" PROJECT VIEWS """
class ProjectListView(ProjectMixin, ListView):
def is_authorized(self):
return True
def get_project_queryset(self):
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
template_name = "interface/default_form.html"
title = "Add a new project"
form_class = forms.ProjectForm
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.ensemble = self.ensemble
self.object.owner = self.request.user # type: ignore
self.object.save()
for module in form.cleaned_data["modules"]:
self.object.modules.create(name=module)
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():
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))
return super().request_denied()
def get_object(self, queryset=None):
return self.project
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
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"
form_class = forms.ProjectForm
def get_object(self):
return self.project
def get_initial(self):
data = super().get_initial()
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"])
self.object.modules.exclude(name__in=desired).delete()
for module in desired - current:
self.object.modules.create(name=module)
return redirect("project_detail", self.kwargs["project"])
@property
def cancel_url(self):
return resolve_url("project_detail", self.kwargs["project"])
# Old Makefile from submission module
# class ProjectMakefileView(EnsembleMixin, DetailView):
# template_name = 'interface/project_submissions.mk'
# content_type = 'text/plain'
#
# def get_queryset(self):
# if self.request.is_admin:
# return models.Project.objects.all()
#
# return models.Project.objects.filter(ensemble=self.request.ensemble_id)
#
# def get_context_data(self, **kwargs):
# data = super().get_context_data(**kwargs)
#
# data['submissions'] = []
# data['targets'] = []
# for s in self.object.submissions:
# name = s.short_name
# data['targets'].append(name)
# data['submissions'].append({
# 'url': self.request.build_absolute_uri(signed_url('submission_download', project=self.kwargs['pk'], pk=s.pk)),
# 'name': name,
# })
#
# return data
""" WIKI VIEWS """
class WikiView(ProjectMixin, DetailView):
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)
return data
class WikiCreateView(ProjectMixin, CreateView):
admin_required = True
model = models.WikiPage
template_name = "interface/default_form.html"
form_class = forms.WikiForm
def cancel_url(self):
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)
class WikiEditView(ProjectMixin, UpdateView):
admin_required = True
model = models.WikiPage
form_class = forms.WikiForm
def cancel_url(self):
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"
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
)
class ResourceUploadView(ProjectMixin, UpdateView):
admin_required = True
model = models.Resource
fields = ["file"]
template_name = "interface/default_form.html"
def get_success_url(self):
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: # 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"
def get_success_url(self):
return resolve_url("resource_list", project=self.kwargs["project"])
@@ -2,54 +2,77 @@ from django.contrib import admin
from . import models
class EnsembleAccessInline(admin.StackedInline):
model = models.EnsembleAccess
extra = 0
class CollectionAdmin(admin.ModelAdmin):
list_display = ['name', 'location', 'storage', 'prefix']
list_display = ["name", "location", "storage", "prefix"]
inlines = [EnsembleAccessInline]
admin.site.register(models.Collection, CollectionAdmin)
class ItemInline(admin.TabularInline):
model = models.ProjectItem
extra = 0
class DocInline(admin.TabularInline):
model = models.Document
extra = 0
class MetaInline(admin.TabularInline):
model = models.WorkMeta
extra = 0
class WorkAdmin(admin.ModelAdmin):
list_display = ['name', 'composer', 'edition', 'identifier', 'running_time']
list_filter = ['collection']
list_display = ["name", "composer", "edition", "identifier", "running_time"]
list_filter = ["collection"]
search_fields = ["name", "composer"]
inlines = [MetaInline, DocInline, ItemInline]
admin.site.register(models.Work, WorkAdmin)
class SectionInline(admin.TabularInline):
model = models.Section
fields = ['tag', 'start', 'end']
fields = ["tag", "start", "end", "page"]
class DocumentAdmin(admin.ModelAdmin):
list_display = ['work', '__str__']
list_filter = ['work__collection']
list_display = ["work", "__str__"]
list_filter = ["work__collection"]
inlines = [SectionInline]
admin.site.register(models.Document, DocumentAdmin)
class ItemAdmin(admin.ModelAdmin):
list_display = ['project', 'work', 'order']
list_filter = ['project']
list_display = ["project", "work", "order"]
list_filter = ["project"]
admin.site.register(models.ProjectItem, ItemAdmin)
class EnsembleAccessAdmin(admin.ModelAdmin):
list_display = ['ensemble', 'collection', 'access_type']
list_filter = ['ensemble']
admin.site.register(models.EnsembleAccess, EnsembleAccessAdmin)
class EnsembleAccessAdmin(admin.ModelAdmin):
list_display = ["ensemble", "collection", "access_type"]
list_filter = ["ensemble"]
admin.site.register(models.EnsembleAccess, EnsembleAccessAdmin)
class OrchestrationAdmin(admin.ModelAdmin):
list_display = ["name", "instruments"]
admin.site.register(models.Orchestration, OrchestrationAdmin)
@@ -2,4 +2,4 @@ from django.apps import AppConfig
class LibraryConfig(AppConfig):
name = 'library'
name = "polyphonic.library"
@@ -0,0 +1,38 @@
[
{
"model": "library.orchestration",
"pk": 2,
"fields": {
"ensemble": null,
"name": "SATB",
"instruments": "score sop alt ten bass"
}
},
{
"model": "library.orchestration",
"pk": 3,
"fields": {
"ensemble": null,
"name": "String Quartet",
"instruments": "score vn-1 vn-2 va vc"
}
},
{
"model": "library.orchestration",
"pk": 4,
"fields": {
"ensemble": null,
"name": "Chamber Orchestra",
"instruments": "score fl cl ob bn vn-1 vn-2 va vc db"
}
},
{
"model": "library.orchestration",
"pk": 5,
"fields": {
"ensemble": null,
"name": "Full Orchestra",
"instruments": "score picc fl-1 fl-2 ob cl-1 cl-2 bcl bn tpt-1 tpt-2 tpt-3 hn-1 hn-2 hn-3 hn-4 tbn-1 tbn-2 tbn-3 tba timp perc-1 perc-2 mall pf vn-1 vn-2 va vc db"
}
}
]
+54
View File
@@ -0,0 +1,54 @@
from django import forms
from .models import Work
from polyphonic.interface.models import Project
from polyphonic.interface.forms import BaseForm
class WorkCreateForm(forms.ModelForm, BaseForm):
class Meta:
model = Work
fields = [
"name",
"composer",
"edition",
"code",
"orchestration",
"licence",
"running_time",
"notes",
]
class PlaylistAddForm(forms.Form):
work = forms.ModelChoiceField(queryset=Work.objects.all())
def __init__(self, instance, *args, **kwargs):
super(PlaylistAddForm, self).__init__(*args, **kwargs)
existing = [x[0] for x in instance.works.values_list("pk")]
qs = Work.objects.filter(ensemble_id=instance.ensemble_id).exclude(
id__in=existing
)
self.fields["work"].queryset = qs
self.instance = instance
def save(self):
self.instance.works.add(self.cleaned_data["work"])
class ProjectEnsembleChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
return f"{obj.ensemble.name} - {obj.name}"
class ProjectSelectForm(BaseForm):
project = ProjectEnsembleChoiceField(queryset=Project.objects.all())
class DocumentLinkForm(BaseForm):
link = forms.URLField(help_text="Paste the direct link relevant to this storage")
class DocumentBulkForm(BaseForm):
folder_link = forms.URLField(help_text="Paste the folder link for this storage")
+90
View File
@@ -0,0 +1,90 @@
from polyphonic.library.models import Collection, Work, WorkMeta, Document
from byostorage.models import UserStorage
import logging
logger = logging.getLogger(__name__)
def sync_work(work: Work):
try:
folder_id = work.meta_info.get(name="folderid").value
except WorkMeta.DoesNotExist as err:
raise IndexError("Work not currently linked to a gdrive folder") from err
logger.info("Syncing '%s' from %r", work.name, folder_id)
storage = UserStorage.objects.get(name="gdrive").instance()
existing = set(
[
storage.parse_resource(x.partition(":")[2]).id
for x in work.docs.values_list("upload", flat=True)
]
)
logger.debug("%d existing documents", len(existing))
_, files = storage.listdir(folder_id)
logger.debug("Remote files: %r", files)
for file in files:
if file.id in existing:
logger.debug("%30s: Skipping existing (%s)", file.name, file.id)
existing.discard(file.id)
continue
if not file.name.lower().endswith(".pdf"):
logger.debug("%40s: Not a PDF", file.name)
continue
logger.info("%40s: Adding", file.name)
doc = work.docs.create(upload=f"gdrive:{file}", doctype=Document.DOCTYPE_PDF)
doc.auto_tag()
for uri in existing:
logger.warning("Local entry not in folder: %s", uri)
def sync_partial_collection(collection: Collection, sync_existing: bool = True):
works = Work.objects.filter(collection=collection, meta_info__name="folderid")
for work in works:
sync_work(work)
def sync_collection(collection: Collection, sync_existing: bool = False):
logger.info("Syncing '%s'", collection)
if not collection.storage.storage.endswith("GDriveLinkStorage"):
return sync_partial_collection(collection, sync_existing)
existing = dict(
WorkMeta.objects.filter(
work__collection=collection, name="folderid"
).values_list("value", "work_id")
)
folder = collection.prefix
storage = collection.storage.instance()
folders, _ = storage.listdir(folder)
for folder in folders:
if folder[0] == "_":
continue
if folder.id in existing:
if sync_existing:
logger.info("%40s: Syncing (%s)", folder.name, folder.id[:12])
sync_work(Work.objects.get(pk=existing[folder.id]))
del existing[folder.id]
continue
logger.info("%40s: Adding", folder.name)
work = Work(name=folder.name, collection=collection)
work.save()
work.meta_info.create(name="folderid", value=folder.id)
sync_work(work)
for folderid, work in existing:
logger.warning("Folder for work %d no longer in drive (%s)", work, folderid)
+141
View File
@@ -0,0 +1,141 @@
from django.core.files.storage import Storage
from collections import namedtuple
import requests
import re
from gzip import GzipFile
import logging
logger = logging.getLogger(__name__)
SHARED_FOLDER = re.compile(
r"https://drive.google.com/drive[u0-9\/]+folders/([\w\-]+)(\?resourcekey=([\w\-]+))?"
)
SHARED_FILE = re.compile(
r"https://drive.google.com/file/d/([\w\-]+)(\?resourcekey=([\w\-]+))?"
)
FILES_API = "https://www.googleapis.com/drive/v3/files"
class DriveObject(namedtuple("DriveObject", ("id", "key", "name"))):
@classmethod
def from_string(cls, s: str):
resource, _, name = s.partition("/")
id, _, key = resource.partition("#")
return cls(id, key, name)
def __str__(self):
resource = f"{self.id}#{self.key}" if self.key else self.id
return f"{resource}/{self.name}"
class GDriveLinkStorage(Storage):
is_writable = False
def __init__(self, api_key):
self.api_key = api_key
super().__init__()
def parse_resource(self, name) -> DriveObject:
return DriveObject.from_string(name)
def extract_resource(self, url, *patterns) -> DriveObject:
logger.debug("EXTRACT_RESOURCE: %r", url)
for pattern in patterns:
match = pattern.match(url)
if match:
groups = match.groups()
logger.debug(groups)
if len(groups) == 3:
return DriveObject(groups[0], groups[2], "")
return DriveObject(groups[0], None, "")
raise FileNotFoundError(f"Not a valid url: {url}")
def get_json(self, url, resource: DriveObject):
headers = {}
if resource.key:
headers["X-Goog-Drive-Resource-Keys"] = f"{resource.id}/{resource.key}"
logger.debug("GET_JSON: %s %r", url, headers)
response = requests.get(url, headers=headers)
data = response.json()
logger.debug("Data: %r", data)
return data
def listdir(self, path) -> tuple[list[str], list[str]]:
# used to test for valid connection parameters - should do something to validate API key here
logger.debug("listdir: %s", path)
if path == "":
return [], []
folder = self.parse_resource(path)
url = f"{FILES_API}?q='{folder.id}'+in+parents&key={self.api_key}"
data = self.get_json(url, folder)
files = []
folders = []
for x in data["files"]:
if x["mimeType"] == "application/vnd.google-apps.folder":
# folders.append(f"{x['id']}/{x['name']}")
folders.append(DriveObject(x["id"], x.get("resourceKey"), x["name"]))
else:
# files.append(f"{x['id']}/{x['name']}")
files.append(DriveObject(x["id"], x.get("resourceKey"), x["name"]))
return folders, files
def get_meta(self, name):
file_resource = self.parse_resource(name)
url = f"{FILES_API}/{file_resource.id}?key={self.api_key}"
return self.get_json(url, file_resource)
def open(self, name, mode="rb"):
resource = self.parse_resource(name)
url = f"{FILES_API}/{resource.id}?alt=media&key={self.api_key}"
headers = {}
if resource.key:
headers["X-Goog-Drive-Resource-Keys"] = f"{resource.id}/{resource.key}"
logger.info("URL: %s [%r]", url, headers)
response = requests.get(url, headers=headers, stream=True)
return GzipFile(name, "rb", 9, response.raw)
def size(self, name):
raise NotImplementedError()
def delete(self, name):
pass
def url(self, name):
logger.debug("URL: %r", name)
resource = self.parse_resource(name)
uri = f"https://drive.usercontent.google.com/download?export=download&id={resource.id}&confirm=yes"
if resource.key:
uri += f"&resourcekey=${resource.key}"
return uri
def get_folder_id(self, url):
try:
return self.extract_resource(url, SHARED_FOLDER)
except FileNotFoundError:
return None
def get_file_id(self, url):
try:
return self.extract_resource(url, SHARED_FILE)
except FileNotFoundError:
return None
def import_link(self, url) -> str:
file_resource = self.extract_resource(url, SHARED_FILE)
meta = self.get_meta(file_resource)
return f"{file_resource}/{meta['name']}"
"""
def folder_import(self, url) -> list[str]:
folder_id = self.extract_id(url, SHARED_FOLDER)
_, files = self.listdir(folder_id)
return files
"""
+62
View File
@@ -0,0 +1,62 @@
from django.shortcuts import resolve_url, redirect
from django.views.generic import FormView
from django.views.generic.detail import SingleObjectMixin
from polyphonic.library.views import CollectionMixin
from polyphonic.library.models import Work, Document
from polyphonic.library import forms
from byostorage.models import UserStorage
from . import sync_work
class WorkGDriveView(CollectionMixin, SingleObjectMixin, FormView):
model = Work
template_name = "library/gdrive.html"
form_class = forms.DocumentLinkForm
@property
def cancel_url(self):
return resolve_url("work_detail", self.collection.pk, self.kwargs["pk"])
def get_context_data(self, *args, **kwargs):
self.object = self.get_object()
data = super().get_context_data(*args, **kwargs)
data["meta"] = dict(self.object.meta_info.values_list("name", "value"))
print(data["meta"])
return data
def form_valid(self, form):
link = form.cleaned_data["link"]
# storage = self.collection.storage.instance()
storage = UserStorage.objects.get(name="gdrive").instance()
self.object = self.get_object()
folderid = storage.get_folder_id(link)
if folderid:
self.object.meta_info.update_or_create(
name="folderid", defaults={"value": folderid}
)
sync_work(self.object)
return redirect("work_detail", self.collection.pk, self.kwargs["pk"])
link = storage.import_link(link)
if link is None:
form.add_error("link", "Not a valid link")
return self.form_invalid(form)
work = self.collection.works.get(pk=self.kwargs["pk"])
doc = Document(
work=work,
upload=f"gdrive:{link}",
# upload=f"{self.collection.storage.name}:{link}",
doctype=Document.DOCTYPE_PDF,
)
doc.save()
doc.auto_tag()
return redirect("work_detail", self.collection.pk, self.kwargs["pk"])
+103
View File
@@ -0,0 +1,103 @@
from typing import Protocol, Any, Iterable, Generator
from django.conf import settings
from django.utils.module_loading import import_module
from django.db.models import QuerySet
from polyphonic.library.models import Work, Collection
instance = getattr(settings, "INDEXER", "polyphonic.library.indexer.whoosh")
class Indexer(Protocol):
def create_index(self) -> None: ...
def get_index(self) -> None: ...
def reset_index(self) -> None: ...
def search(
self,
query: str,
collections: list[int],
page: int = 1,
pagesize: int = 20,
) -> tuple[list[dict], dict[str, Any]]: ...
def index_docs(self, docs: Iterable[dict]): ...
class PartialResultSet(object):
"""
Implements enough of QuerySet to fool the Paginator!
"""
def __init__(self, works, meta):
self.works = works
self.meta = meta
self.start = (meta["page"] - 1) * meta["pagesize"]
def __getitem__(self, key):
if isinstance(key, slice):
if key.start != self.start:
raise KeyError(f"Expected {self.start}, got {key.start}")
return self.works
return self.works[key]
def count(self):
return self.meta["total"]
# make the given module available as indexer
indexer: Indexer = import_module(instance) # type: ignore
def work_to_doc(work: Work) -> dict[str, str]:
tags = ",".join(work.meta_info.filter(name="tag").values_list("value", flat=True)) # type: ignore
meta = ", ".join(work.meta_info.values_list("value", flat=True)) # type: ignore
description = f"""{work.name}
{work.composer}
{work.edition}
{work.notes}
{meta}
"""
return dict(
work=str(work.pk),
collection=str(work.collection_id), # type: ignore
name=work.name,
composer=work.composer,
edition=work.edition,
tag=tags.lower(),
text=description,
)
def doc_set(works: QuerySet) -> Generator:
for work in works:
yield work_to_doc(work)
def index_works(works: QuerySet):
indexer.index_docs(doc_set(works))
def model_search(
query: str, collections: list[int], page: int = 1, pagesize: int = 20
) -> PartialResultSet:
hits, meta = indexer.search(query.lower(), collections, page, pagesize)
meta["pagesize"] = pagesize
meta["page"] = page
works = [Work(**hit) for hit in hits]
collection_names = dict(Collection.objects.values_list("pk", "name"))
for work in works:
work.collection = Collection( # type: ignore
pk=work.collection_id, name=collection_names[work.collection_id]
)
return PartialResultSet(works, meta)
+97
View File
@@ -0,0 +1,97 @@
from whoosh.index import create_in, open_dir, EmptyIndexError, Index
from whoosh.analysis import StemmingAnalyzer, CharsetFilter
from whoosh.support.charset import accent_map
from whoosh.fields import Schema, TEXT, KEYWORD, NUMERIC
from whoosh.qparser import QueryParser
from whoosh.query import Term, NullQuery, FuzzyTerm
from typing import Any
from django.conf import settings
import os.path
import shutil
stemming_analyzer = StemmingAnalyzer() | CharsetFilter(accent_map)
schema = Schema(
work=NUMERIC(stored=True, unique=True),
collection=NUMERIC(stored=True),
name=TEXT(stored=True),
composer=TEXT(stored=True),
edition=TEXT(stored=True),
tag=KEYWORD(commas=True),
text=TEXT(analyzer=stemming_analyzer),
)
index_path = settings.WHOOSH_INDEX
def create_index() -> Index:
if not os.path.exists(index_path):
os.mkdir(index_path)
ix = create_in(index_path, schema)
return ix
def get_index() -> Index:
try:
return open_dir(index_path)
except EmptyIndexError:
return create_index()
def reset_index() -> Index:
shutil.rmtree(index_path)
return create_index()
def index_docs(works: list[dict]):
ix = get_index()
with ix.writer() as writer:
for work in works:
writer.update_document(**work)
def search(
query: str,
collections: list[int] = [],
page: int = 1,
pagesize: int = 20,
) -> tuple[list[dict], dict[str, Any]]:
meta = {}
query = query.lower()
qp = QueryParser("text", schema=schema)
q = qp.parse(query)
limit = NullQuery
for c in collections:
limit |= Term("collection", c)
hits = []
ix = get_index()
with ix.searcher() as searcher:
results = searcher.search_page(q & limit, page, pagesize)
# if no results, do a prefix search
if results.results.is_empty():
qp.termclass = FuzzyTerm
q = qp.parse(query)
results = searcher.search_page(q & limit, page, pagesize)
for result in results:
hits.append(
dict(
pk=result["work"],
name=result["name"],
composer=result["composer"],
edition=result["edition"],
collection_id=int(result["collection"]),
)
)
meta["query"] = str(q & limit)
meta["total"] = len(results)
return hits, meta
@@ -0,0 +1,23 @@
from django.core.management.base import BaseCommand
import argparse
import csv
from library import models
class Command(BaseCommand):
help = "Imports works from a csv file"
def add_arguments(self, parser):
parser.add_argument("collection", type=int, help="Collection ID")
parser.add_argument("source", type=argparse.FileType("r"), help="Source CSV")
def handle(self, *args, **options):
collection = models.Collection.objects.get(pk=options["collection"])
reader = csv.DictReader(options["source"])
for row in reader:
collection.works.create(
name=row["Piece"], composer=row["Composer"], notes=row["Notes"]
)
@@ -0,0 +1,36 @@
from django.core.management.base import BaseCommand
from polyphonic.library import models
from polyphonic.library.indexer import model_search, index_works, indexer
FORMATTER = "{w.name:50s} {w.edition:15s} {w.collection.name:15s}"
class Command(BaseCommand):
help = "Imports works from a csv file"
def add_arguments(self, parser):
parser.add_argument("action", choices=("run", "search", "terms"))
parser.add_argument("query", nargs="*")
parser.add_argument("--collection", "-c", nargs="*", type=int)
# parser.add_argument("collection", type=int, help="Collection ID")
# parser.add_argument("source", type=argparse.FileType("r"), help="Source CSV")
def handle(self, action, query, *args, **options):
try:
method = getattr(self, f"handle_{action}")
except AttributeError:
raise RuntimeError(f"Unknown handler: {action}")
return method("".join(query), options["collection"] or [])
def handle_run(self, query, collections=[]):
index_works(models.Work.objects.all())
def handle_search(self, query, collections=[]):
for result in model_search(query, collections):
print(FORMATTER.format(w=result))
def handle_terms(self, query, collections=[]):
ix = indexer.get_index()
with ix.searcher() as searcher:
print(b", ".join(searcher.lexicon(query or "text")))
@@ -0,0 +1,25 @@
from django.core.management.base import BaseCommand, CommandError
from polyphonic.library.models import Work, Collection
from polyphonic.library.gdrive import sync_work, sync_collection
class Command(BaseCommand):
help = "Synchronizes folders and works"
def add_arguments(self, parser):
parser.add_argument("type", type=str, choices=["work", "collection"])
parser.add_argument("pk", nargs="?", type=int)
def handle(self, *args, **options):
if options["type"] == "work":
work = Work.objects.get(pk=options["pk"])
sync_work(work)
return
if options["type"] == "collection":
collection = Collection.objects.get(pk=options["pk"])
sync_collection(collection)
return
raise CommandError("Unknown object type")
@@ -1,22 +1,20 @@
# Generated by Django 3.2.7 on 2022-11-19 10:24
# Generated by Django 3.2.7 on 2022-11-19 01:24
import byostorage.user
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import library.models
import polyphonic.library.models
class Migration(migrations.Migration):
replaces = [('library', '0001_initial'), ('library', '0002_auto_20221118_2208'), ('library', '0003_work_composer'), ('library', '0004_auto_20221118_2223'), ('library', '0005_auto_20221118_2253'), ('library', '0006_auto_20221119_2121')]
initial = True
dependencies = [
('byostorage', '0004_alter_userstorage_storage'),
('interface', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('byostorage', '0006_alter_userstorage_settings_data'),
('interface', '0001_initial'),
]
operations = [
@@ -32,6 +30,16 @@ class Migration(migrations.Migration):
('storage', models.ForeignKey(blank=True, help_text='User storage for documents', null=True, on_delete=django.db.models.deletion.CASCADE, to='byostorage.userstorage')),
],
),
migrations.CreateModel(
name='Document',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('doctype', models.PositiveSmallIntegerField(choices=[(1, 'PDF'), (2, 'Audio'), (3, 'Video'), (4, 'Source')], default=1)),
('upload', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=polyphonic.library.models.doc_upload_filename)),
('created', models.DateTimeField(auto_now_add=True)),
('version', models.CharField(blank=True, max_length=30)),
],
),
migrations.CreateModel(
name='ProjectItem',
fields=[
@@ -40,6 +48,7 @@ class Migration(migrations.Migration):
('due', models.DateTimeField(blank=True, null=True)),
('returned', models.DateTimeField(blank=True, null=True)),
('order', models.SmallIntegerField(default=0)),
('section', models.CharField(blank=True, max_length=100)),
('approved_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='interface.project')),
],
@@ -53,6 +62,8 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(editable=False, help_text='Used as folder name', max_length=100)),
('name', models.CharField(help_text='Original name of the work', max_length=255)),
('edition', models.CharField(blank=True, help_text='Edition details to distinguish multiple versions', max_length=255)),
('composer', models.CharField(blank=True, help_text='Surname, First Name/Initials', max_length=255)),
('original_parts', models.JSONField(blank=True, default=dict, help_text='Original printed parts (IMSLP format)')),
('code', models.CharField(blank=True, help_text='Collection specific code or number', max_length=100)),
('licence', models.PositiveSmallIntegerField(choices=[(2, 'Public Domain'), (4, 'Copyright Expired'), (6, 'Copyrighted'), (10, 'Internal use only')], default=6, help_text='Copyright status')),
@@ -62,36 +73,6 @@ class Migration(migrations.Migration):
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='works', to='library.collection')),
('parent', models.ForeignKey(blank=True, help_text='Arrangement of another work or part of an anthology', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_works', to='library.work')),
('projects', models.ManyToManyField(help_text='Current usage', related_name='works', through='library.ProjectItem', to='interface.Project')),
('composer', models.CharField(blank=True, help_text='Surname, First Name/Initials', max_length=255)),
('edition', models.CharField(blank=True, help_text='Edition details to distinguish multiple versions', max_length=255)),
],
),
migrations.AddField(
model_name='projectitem',
name='work',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_items', to='library.work'),
),
migrations.CreateModel(
name='EnsembleAccess',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('access_type', models.PositiveSmallIntegerField(choices=[(1, 'Unlimited'), (2, 'Approval required')], default=2)),
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_ensembles', to='library.collection')),
('ensemble', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_collections', to='interface.ensemble')),
],
options={
'verbose_name_plural': 'Ensemble access',
},
),
migrations.CreateModel(
name='Document',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('doctype', models.PositiveSmallIntegerField(choices=[(1, 'PDF'), (2, 'Audio'), (3, 'Video'), (4, 'Source')], default=1)),
('upload', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=library.models.doc_upload_filename)),
('created', models.DateTimeField(auto_now_add=True)),
('version', models.CharField(blank=True, max_length=30)),
('work', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='docs', to='library.work')),
],
),
migrations.CreateModel(
@@ -116,4 +97,26 @@ class Migration(migrations.Migration):
'ordering': ['doc', 'start', 'pk'],
},
),
migrations.AddField(
model_name='projectitem',
name='work',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_items', to='library.work'),
),
migrations.CreateModel(
name='EnsembleAccess',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('access_type', models.PositiveSmallIntegerField(choices=[(1, 'Unlimited'), (2, 'Approval required')], default=2)),
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_ensembles', to='library.collection')),
('ensemble', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_collections', to='interface.ensemble')),
],
options={
'verbose_name_plural': 'Ensemble access',
},
),
migrations.AddField(
model_name='document',
name='work',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='docs', to='library.work'),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 3.2.7 on 2022-11-30 22:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='work',
name='slug',
),
migrations.AddField(
model_name='work',
name='path',
field=models.CharField(default='', editable=False, help_text='Used as folder name', max_length=255),
preserve_default=False,
),
migrations.AlterField(
model_name='work',
name='composer',
field=models.CharField(default='Anon', help_text='Surname, Initials', max_length=255),
),
]
@@ -0,0 +1,21 @@
# Generated by Django 3.2.7 on 2022-12-01 04:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('library', '0002_auto_20221201_0934'),
]
operations = [
migrations.AlterUniqueTogether(
name='work',
unique_together={('collection', 'composer', 'name', 'edition')},
),
migrations.RemoveField(
model_name='work',
name='path',
),
]

Some files were not shown because too many files have changed in this diff Show More