Compare commits
102
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff17114514 | ||
|
|
533d6f09e8 | ||
|
|
eeb35ce4f6 | ||
|
|
947626c2af | ||
|
|
c1f0e48f80 | ||
|
|
ab7d32d46e | ||
|
|
5116247ae8 | ||
|
|
93c4926dfd | ||
|
|
27d1b03c3c | ||
|
|
4e102c07ac | ||
|
|
b1ea75cec0 | ||
|
|
7d041e1fd0 | ||
|
|
504c2ee56b | ||
|
|
cfd6d45189 | ||
|
|
ca62ed693a | ||
|
|
5e0e165037 | ||
|
|
4164d56dea | ||
|
|
75dced77b8 | ||
|
|
b3675e28af | ||
|
|
b86c867bd2 | ||
|
|
9248692e5d | ||
|
|
406609262d | ||
|
|
ffb31cc004 | ||
|
|
b1ef2b9dac | ||
|
|
5c56d40bf8 | ||
|
|
a1341d1edc | ||
|
|
4d964291b2 | ||
|
|
ee5305ba6c | ||
|
|
147c84550c | ||
|
|
596445061f | ||
|
|
02858a76c0 | ||
|
|
1bcec919cf | ||
|
|
78789c02ed | ||
|
|
04bf1ab1f3 | ||
|
|
fb700b15d6 | ||
|
|
0bd2596ebc | ||
|
|
1f0a336ed4 | ||
|
|
220163bca2 | ||
|
|
d1328ae1b1 | ||
|
|
69f747d5b5 | ||
|
|
2a6554471d | ||
|
|
6edc612ed6 | ||
|
|
f441940e8c | ||
|
|
b1eaf9c7bc | ||
|
|
a892b0bc41 | ||
|
|
f1757be96e | ||
|
|
95ee92cef6 | ||
|
|
b11cfcd6ea | ||
|
|
6e1b26440f | ||
|
|
4268b66b27 | ||
|
|
f22961ad44 | ||
|
|
caeee16657 | ||
|
|
4344cb978b | ||
|
|
a3d8cf0a21 | ||
|
|
70aa7ae3a3 | ||
|
|
f840ee3d8b | ||
|
|
7d4f959146 | ||
|
|
1cb00ebc0b | ||
|
|
928173976b | ||
|
|
d7892d355d | ||
|
|
dbbfa79f10 | ||
|
|
f49ff0fd0e | ||
|
|
6ec5808275 | ||
|
|
948e9deb54 | ||
|
|
75de40f2bd | ||
|
|
295999eaef | ||
|
|
f9fa9d5e05 | ||
|
|
c720cb773e | ||
|
|
da15eebb0a | ||
|
|
e74043ae67 | ||
|
|
5a201cef85 | ||
|
|
5fb5138383 | ||
|
|
039b440a57 | ||
|
|
7d394e2f34 | ||
|
|
13f466228b | ||
|
|
b85440d25c | ||
|
|
bc9f292a2e | ||
|
|
8a249de51c | ||
|
|
7e47eec4ae | ||
|
|
59deeffefe | ||
|
|
53ec846f98 | ||
|
|
94bba3769a | ||
|
|
2726a8fe04 | ||
|
|
4731d18131 | ||
|
|
c639020ac9 | ||
|
|
7f6875f3c4 | ||
|
|
025e1344f0 | ||
|
|
bbc74a77f9 | ||
|
|
dfe4a925c7 | ||
|
|
18e5893cc2 | ||
|
|
988161b599 | ||
|
|
f7aaa98000 | ||
|
|
8f18b9ab9d | ||
|
|
598ee5ad7e | ||
|
|
7aedc6bd07 | ||
|
|
f50fe11ced | ||
|
|
12beebd486 | ||
|
|
9094cae63f | ||
|
|
1581f56b74 | ||
|
|
35555c3321 | ||
|
|
526ae4f59b | ||
|
|
98fe7b77ce |
@@ -0,0 +1,2 @@
|
|||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
+17
-6
@@ -1,9 +1,20 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
*.pyc
|
*.pyc
|
||||||
db.sqlite3
|
*.sqlite3
|
||||||
|
*.swp
|
||||||
|
credentials.json
|
||||||
credentials
|
credentials
|
||||||
polyphonic/settings.py
|
local_settings.py
|
||||||
env
|
.coverage
|
||||||
test.*
|
Session.vim
|
||||||
static
|
poetry.lock
|
||||||
teststore
|
/env
|
||||||
|
/data
|
||||||
|
/old
|
||||||
|
/static
|
||||||
|
/teststore
|
||||||
|
/cache
|
||||||
|
/local_storage
|
||||||
|
/media
|
||||||
|
/index
|
||||||
|
/dist
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
ENV TARGET=/opt/polyphonic
|
||||||
|
ENV RELEASE=polyphonic-0.8.3-py3-none-any.whl
|
||||||
|
|
||||||
|
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.wsgi"]
|
||||||
@@ -1,15 +1,28 @@
|
|||||||
PYTHON=env/bin/python
|
PYTHON=env/bin/python
|
||||||
DROPZONE=5.7.0
|
DROPZONE=5.7.0
|
||||||
|
|
||||||
|
test:
|
||||||
|
poetry run coverage run --include "app/*" --omit "*/migrations/*" app/manage.py test app
|
||||||
|
poetry run coverage html
|
||||||
|
poetry run coverage report
|
||||||
|
|
||||||
|
check:
|
||||||
|
poetry run ruff check app
|
||||||
|
poetry run ruff format --check app || true
|
||||||
|
|
||||||
|
pre-commit: check test
|
||||||
|
|
||||||
|
build:
|
||||||
|
poetry build
|
||||||
|
|
||||||
dev-setup:
|
dev-setup:
|
||||||
env/bin/pip install -r requirements.txt
|
poetry install --with=dev
|
||||||
env/bin/pip install -r dev-requirements.txt
|
poetry run manage migrate
|
||||||
${PYTHON} manage.py migrate
|
poetry run manage createsuperuser --username admin --email admin@localhost
|
||||||
${PYTHON} manage.py createsuperuser --username admin --email admin@localhost
|
|
||||||
|
|
||||||
upgrade:
|
upgrade:
|
||||||
${PYTHON} manage.py migrate
|
poetry run manage migrate
|
||||||
${PYTHON} manage.py collectstatic
|
poetry run manage collectstatic
|
||||||
${MAKE} libraries
|
${MAKE} libraries
|
||||||
|
|
||||||
libraries: static/dropzone static/fonts/Quicksand_Book.otf
|
libraries: static/dropzone static/fonts/Quicksand_Book.otf
|
||||||
@@ -35,4 +48,3 @@ start_s3_storage:
|
|||||||
stop_s3_storage:
|
stop_s3_storage:
|
||||||
kill `cat teststore/pid` | true
|
kill `cat teststore/pid` | true
|
||||||
rm teststore/pid
|
rm teststore/pid
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
## Polyphonic
|
## Polyphonic
|
||||||
|
|
||||||
A simple web app for managing video uploads to an S3 bucket for virtual ensembles.
|
A simple web app managing scores, large files and submissions in a manor tailored to musical ensembles.
|
||||||
|
|
||||||
|
No registration required for ensemble participants - just a one time code and passphrase.
|
||||||
|
|
||||||
|
### Library App
|
||||||
|
|
||||||
|
Store all your scores on your own cloud account (Amazon S3, Google Files etc).
|
||||||
|
Tag up the scores so you can generate custom part sets and assign them to
|
||||||
|
projects so people can easily print just their parts.
|
||||||
|
|
||||||
|
### Submissions App
|
||||||
|
|
||||||
|
Accept video/audio submissions direct to your cloud storage. Was developed and
|
||||||
|
used during 2020 lockdown period for virtual choirs/orchestras but could have more uses.
|
||||||
|
|
||||||
### S3 Setup
|
### S3 Setup
|
||||||
|
|
||||||
#### Bucket setup [virtual-orchestra]
|
#### Bucket setup [virtual-orchestra]
|
||||||
|
|
||||||
Default block public access
|
Default block public access
|
||||||
|
|
||||||
Permissions -> CORS
|
Permissions -> CORS
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||||
@@ -28,6 +43,7 @@ Permissions -> CORS
|
|||||||
|
|
||||||
User
|
User
|
||||||
Create with programatic access (copy keys) and an inline policy for the bucket.
|
Create with programatic access (copy keys) and an inline policy for the bucket.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"Version": "2012-10-17",
|
"Version": "2012-10-17",
|
||||||
@@ -52,3 +68,4 @@ Create with programatic access (copy keys) and an inline policy for the bucket.
|
|||||||
```
|
```
|
||||||
|
|
||||||
3.
|
3.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pylint==2.6.0
|
||||||
|
django-debug-toolbar
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
|
class EnsembleAdmin(admin.ModelAdmin):
|
||||||
|
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"]
|
||||||
|
inlines = [ModuleInline]
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["name", "media_type", "project"]
|
||||||
|
list_filter = ["project"]
|
||||||
|
|
||||||
|
|
||||||
|
class WikiPageAdmin(admin.ModelAdmin):
|
||||||
|
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)
|
||||||
@@ -2,4 +2,4 @@ from django.apps import AppConfig
|
|||||||
|
|
||||||
|
|
||||||
class InterfaceConfig(AppConfig):
|
class InterfaceConfig(AppConfig):
|
||||||
name = 'interface'
|
name = "interface"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from crispy_forms.layout import Field
|
||||||
|
|
||||||
|
|
||||||
|
class BulmaFileUpload(Field):
|
||||||
|
template = "bulma/file_upload.html"
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('byostorage', '0006_alter_userstorage_settings_data'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Ensemble',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(help_text='Display name', max_length=100)),
|
||||||
|
('slug', models.SlugField(editable=False, help_text='Short name for the ensemble - used for folders', max_length=100, unique=True)),
|
||||||
|
('code', models.CharField(default=interface.models.generate_code, help_text='Ensemble registration code', max_length=9)),
|
||||||
|
('passphrase', models.CharField(help_text='Used to register ensembles', max_length=100)),
|
||||||
|
('details', models.TextField(blank=True, help_text='Description of the ensemble (markdown)')),
|
||||||
|
('admins', models.ManyToManyField(related_name='ensembles', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('storage', models.ForeignKey(help_text='Default storage for this ensemble', null=True, on_delete=django.db.models.deletion.SET_NULL, to='byostorage.userstorage')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Project',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
('description', models.TextField(blank=True, help_text='Markdown format')),
|
||||||
|
('active', models.BooleanField(default=True)),
|
||||||
|
('event_date', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('owner', models.CharField(blank=True, max_length=255)),
|
||||||
|
('ensemble', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='interface.ensemble')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['active', '-pk'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='WikiPage',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=255)),
|
||||||
|
('markdown', models.TextField()),
|
||||||
|
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='wiki_pages', to='interface.project')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Resource',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('file', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=interface.models.resource_key)),
|
||||||
|
('media_type', models.CharField(choices=[('audio', 'Audio'), ('video', 'Video'), ('general', 'General')], default='*', max_length=10)),
|
||||||
|
('visible', models.BooleanField(default=True)),
|
||||||
|
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='resources', to='interface.project')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-visible', '-pk'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Module',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.SlugField(choices=[('library', 'Library')], 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',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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 |
@@ -0,0 +1,63 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: MartinHand;
|
||||||
|
src: url('/static/fonts/Martinhand3.ttf');
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary: #485fc7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fancy {
|
||||||
|
font-family: MartinHand;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-text-shadow {
|
||||||
|
text-shadow: 1px 1px 2px #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-form-group {
|
||||||
|
max-width: 600px;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-centered {
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-action {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-label {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.is-primary, .button.is-primary:hover {
|
||||||
|
background-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
A.admin-link:after {
|
||||||
|
content: "*";
|
||||||
|
}
|
||||||
|
|
||||||
|
TEXTAREA.input {
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control INPUT[type='file'] {
|
||||||
|
border: none;
|
||||||
|
width: 80%;
|
||||||
|
text-align: center;
|
||||||
|
margin: auto 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0px;
|
||||||
|
right: 0px;
|
||||||
|
background-color: #EEE;
|
||||||
|
padding: 10px 10px;
|
||||||
|
margin-top: 30px;
|
||||||
|
border-top-left-radius: 6px;
|
||||||
|
}
|
||||||
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{% load static %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<!-- Required meta tags -->
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" type="image/png" href="{% static 'interface/icon.png' %}" />
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css">
|
||||||
|
<link rel="stylesheet" href="{% static 'interface/css/polyphonic.css' %}"></link>
|
||||||
|
<script src="{% static 'interface/js/interface.js' %}"></script>
|
||||||
|
<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>
|
||||||
|
<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 is-size-4-mobile mx-4"><i class="fas fa-random"></i></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">Musical Ensemble Manager</span>
|
||||||
|
|
||||||
|
|
||||||
|
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="projectMenu">
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="navbarMain" class="navbar-menu">
|
||||||
|
|
||||||
|
<div class="navbar-end">
|
||||||
|
<span class="navbar-item is-size-5-touch is-size-4-tablet">{% firstof ensemble project.ensemble %}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</nav>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>No content!</h1>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<!-- late load scripts -->
|
||||||
|
{% block scripts %}
|
||||||
|
{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{% load crispy_forms_field %}
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<div id="div_id_{{ field.name }}" class="file has-name is-fullwidth">
|
||||||
|
<label class="file-label">
|
||||||
|
{% crispy_field field 'class' 'file-input'%}
|
||||||
|
<span class="file-cta">
|
||||||
|
<span class="file-icon">
|
||||||
|
<i class="fas fa-upload"></i>
|
||||||
|
</span>
|
||||||
|
<span class="file-label">
|
||||||
|
Choose a file…
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="file-name"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const fileInput = document.querySelector('#div_id_{{ field.name }} input[type=file]');
|
||||||
|
fileInput.onchange = () => {
|
||||||
|
if (fileInput.files.length > 0) {
|
||||||
|
const fileName = document.querySelector('#div_id_{{ field.name }} .file-name');
|
||||||
|
fileName.textContent = fileInput.files[0].name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "interface/project_base.html" %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block media %}
|
||||||
|
{{ form.media }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page %}
|
||||||
|
<h3 class="subtitle">{% firstof title view.title %}</h3>
|
||||||
|
<div class="columns is-centered">
|
||||||
|
<div class="column is-two-thirds">
|
||||||
|
{% if instructions %}
|
||||||
|
<p>{{ instructions }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% crispy form %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{% extends "interface/project_base.html" %}
|
||||||
|
{% load md2 %}
|
||||||
|
|
||||||
|
{% block admin %}
|
||||||
|
<a href="{% url 'project_create' object.slug %}" class="button is-link">
|
||||||
|
<span class="icon"><i class="fas fa-plus-circle"></i></span>
|
||||||
|
<span>Add project</span>
|
||||||
|
</a>
|
||||||
|
{% if inactive %}
|
||||||
|
<a href="?" class="button is-link">
|
||||||
|
<span class="icon"><i class="fas fa-archive"></i></span>
|
||||||
|
<span>Hide old</span>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="?inactive" class="button is-link">
|
||||||
|
<span class="icon"><i class="fas fa-archive"></i></span>
|
||||||
|
<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,48 @@
|
|||||||
|
{% 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' %}">
|
||||||
|
<span class="icon"><i class="fas fa-plus-circle"></i></span>
|
||||||
|
<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: 60px">
|
||||||
|
<a href="{% url 'ensemble_detail' ensemble.slug %}">
|
||||||
|
<p class="title is-4">{{ ensemble.name }}</p>
|
||||||
|
</a>
|
||||||
|
</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 %}
|
||||||
+3
@@ -8,6 +8,9 @@
|
|||||||
URL: <a href="{{ ensemble_url }}">{{ ensemble_url }}</a><br/>
|
URL: <a href="{{ ensemble_url }}">{{ ensemble_url }}</a><br/>
|
||||||
Passphrase: {{ ensemble.passphrase }}
|
Passphrase: {{ ensemble.passphrase }}
|
||||||
</p>
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><a href="{% url 'work_list' %}">Library</a></li>
|
||||||
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
Sorry, not much you can do here yet.
|
Sorry, not much you can do here yet.
|
||||||
<ul>
|
<ul>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="columns mx-2">
|
||||||
|
<div class="column is-narrow is-hidden-touch" id="projectMenu">
|
||||||
|
<div style="margin: auto 2em;">
|
||||||
|
<aside class="menu">
|
||||||
|
|
||||||
|
<p class="menu-label">My Things</p>
|
||||||
|
<ul class="menu-list">
|
||||||
|
<li><a href="{% url 'ensemble_list' %}">Ensembles</a></li>
|
||||||
|
<li><a href="{% url '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>
|
||||||
|
{% 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 %}
|
||||||
|
|
||||||
|
{% 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>
|
||||||
|
<ul class="menu-list">
|
||||||
|
{% if request.user.is_staff %}
|
||||||
|
<li><a href="/admin" target="polyphonic_admin" rel="noopener noreferrer">Django Admin</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<ul class="menu-list">
|
||||||
|
{% if request.user.is_authenticated %}
|
||||||
|
<li><a href="{% url 'logout' %}">Logout</a></li>
|
||||||
|
{% else %}
|
||||||
|
<li><a href="{% url 'login' %}">Login</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column">
|
||||||
|
{% if project %}
|
||||||
|
<div class="tabs is-centered is-hidden-desktop">
|
||||||
|
<ul>
|
||||||
|
<li><a href="">Info</a></li>
|
||||||
|
{% if project.wiki_pages.count %}
|
||||||
|
<li><a href="">Pages</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% if project.resources.count %}
|
||||||
|
<li><a href="{% url 'resource_list' project=project.pk %}">Resources</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% if 'library' in modules %}
|
||||||
|
<li><a href="{% url 'item_list' project=project.pk %}">My Music</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% if 'submission' in modules %}
|
||||||
|
<li><a href="{% url 'submission_create' project=project.pk %}">Send File</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
<div class="block">
|
||||||
|
{% if request.is_admin %}
|
||||||
|
<div class="admin-tools is-pulled-right is-hidden-mobile">
|
||||||
|
{% block admin %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if project %}<h3 class="title">{{ project.name }}</h3>{% endif %}
|
||||||
|
{% block page %}
|
||||||
|
No content
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% 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">
|
||||||
|
<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="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 %}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="section">
|
||||||
|
{% if not request.user.is_authenticated %}
|
||||||
|
<a href="{% url 'login' %}" style="float: right"><i class="fa fa-key"></i></a>
|
||||||
|
{% endif %}
|
||||||
|
<div class="columns is-centered">
|
||||||
|
<div class="box is-half">
|
||||||
|
<h3 class="title">Join an ensemble</h3>
|
||||||
|
<form action="" method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form | crispy }}
|
||||||
|
<div class="field is-grouped">
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-link">Register</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{% extends "interface/project_base.html" %}
|
||||||
|
{% load md2 %}
|
||||||
|
|
||||||
|
{% 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>
|
||||||
|
<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 %}
|
||||||
|
<div class="column is-half">
|
||||||
|
<div class="card {% if not object.visible %}disabled{% endif %}">
|
||||||
|
<div class="card-header">
|
||||||
|
|
||||||
|
<div class="card-header-title">
|
||||||
|
{% if download %}
|
||||||
|
<a href="{{ download }}">{{ resource.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ resource.name }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-header-icon">
|
||||||
|
{% if request.is_admin %}
|
||||||
|
<a href="{% url 'resource_upload' project=project.pk pk=resource.pk %}" class="icon" title="Upload">
|
||||||
|
<i class="fas fa-upload"></i>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'resource_edit' project=project.pk pk=resource.pk %}" class="icon" title="Edit">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<p>
|
||||||
|
<small>{{ resource.description|markdown }}</small>
|
||||||
|
{% if not resource.visible %}
|
||||||
|
<br/>(This resource is hidden from participants)
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% if download and resource.media_type == 'audio' %}
|
||||||
|
<audio class="resource-player" controls src="{{ download }}" style="width: 100%;"></audio>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
{% empty %}
|
||||||
|
<div class="column">
|
||||||
|
<p>There are no resources for this project</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{% extends "interface/project_base.html" %}
|
||||||
|
|
||||||
|
{% 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>
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page %}
|
||||||
|
<h3 class="subtitle">{{ wikipage.title }}</h3>
|
||||||
|
<div class="box content wiki-page">
|
||||||
|
{{ wiki_html|safe }}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "interface/project_base.html" %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block media %}
|
||||||
|
{{ form.media }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page %}
|
||||||
|
<h3 class="subtitle">{% firstof title view.title %}</h3>
|
||||||
|
<div class="columns is-centered">
|
||||||
|
<div class="column">
|
||||||
|
{% if instructions %}
|
||||||
|
<p>{{ instructions }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% crispy form %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="section">
|
||||||
|
<div class="columns is-centered">
|
||||||
|
<div class="box is-half">
|
||||||
|
<p class="block">
|
||||||
|
Login is only required to administer a project.<br/>
|
||||||
|
</p>
|
||||||
|
<form method="POST" class="vertical">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form | crispy }}
|
||||||
|
<div class="field is-grouped">
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-link">Login</button>
|
||||||
|
<a href="{% url 'home' %}" class="button is-light">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from django import template
|
||||||
|
import os.path
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
def basename(value):
|
||||||
|
return os.path.basename(value)
|
||||||
|
|
||||||
|
|
||||||
|
register.filter("basename", basename)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from 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)
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
|
||||||
|
from 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})
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationTestCase(TestCase):
|
||||||
|
def test_runs(self):
|
||||||
|
self.assertTrue(True)
|
||||||
@@ -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"})
|
||||||
|
)
|
||||||
@@ -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())
|
||||||
@@ -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 interface.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"])
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
# Model overview
|
||||||
|
|
||||||
|
Collections contain Works
|
||||||
|
Only the collection's administrators can add/remove/edit items
|
||||||
|
Collections can be enabled for Ensembles which makes everything availble in the Library
|
||||||
|
|
||||||
|
|
||||||
|
## How do loans work?
|
||||||
|
|
||||||
|
1. Project admins need to be able to search for available music - list with access type
|
||||||
|
2. Item can be attached to Project
|
||||||
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
|
class EnsembleAccessInline(admin.StackedInline):
|
||||||
|
model = models.EnsembleAccess
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["name", "location", "storage", "prefix"]
|
||||||
|
inlines = [EnsembleAccessInline]
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(models.Collection, CollectionAdmin)
|
||||||
|
|
||||||
|
|
||||||
|
class ItemInline(admin.TabularInline):
|
||||||
|
model = models.ProjectItem
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
class DocInline(admin.TabularInline):
|
||||||
|
model = models.Document
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
class MetaInline(admin.TabularInline):
|
||||||
|
model = models.WorkMeta
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
class WorkAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["name", "composer", "edition", "identifier", "running_time"]
|
||||||
|
list_filter = ["collection"]
|
||||||
|
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", "page"]
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["work", "__str__"]
|
||||||
|
list_filter = ["work__collection"]
|
||||||
|
inlines = [SectionInline]
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(models.Document, DocumentAdmin)
|
||||||
|
|
||||||
|
|
||||||
|
class ItemAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["project", "work", "order"]
|
||||||
|
list_filter = ["project"]
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(models.ProjectItem, ItemAdmin)
|
||||||
|
|
||||||
|
|
||||||
|
class EnsembleAccessAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["ensemble", "collection", "access_type"]
|
||||||
|
list_filter = ["ensemble"]
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(models.EnsembleAccess, EnsembleAccessAdmin)
|
||||||
|
|
||||||
|
|
||||||
|
class OrchestrationAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["name", "instruments"]
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(models.Orchestration, OrchestrationAdmin)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryConfig(AppConfig):
|
||||||
|
name = "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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from django import forms
|
||||||
|
from .models import Work
|
||||||
|
from interface.models import Project
|
||||||
|
from 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")
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
from library.models import Collection, Work, WorkMeta, Document
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_work(work: Work):
|
||||||
|
logger.info("Syncing '%s'", work.name)
|
||||||
|
folder_id = work.meta_info.get(name="folderid").value
|
||||||
|
|
||||||
|
storage = work.collection.storage.instance()
|
||||||
|
prefix = work.collection.storage.name
|
||||||
|
_, files = storage.listdir(folder_id)
|
||||||
|
|
||||||
|
existing = set(
|
||||||
|
[
|
||||||
|
storage.parse_id(x.partition(":")[2])
|
||||||
|
for x in work.docs.values_list("upload", flat=True)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger.debug("%d existing documents", len(existing))
|
||||||
|
|
||||||
|
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"{prefix}:{file}", doctype=Document.DOCTYPE_PDF)
|
||||||
|
doc.auto_tag()
|
||||||
|
|
||||||
|
for uri in existing:
|
||||||
|
logger.warning("Local entry not in folder: %s", uri)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_collection(collection: Collection, sync_existing: bool = False):
|
||||||
|
logger.info("Syncing '%s'", collection)
|
||||||
|
|
||||||
|
if not collection.storage.storage.endswith("GDriveLinkStorage"):
|
||||||
|
raise RuntimeError("Not a gdrive storage")
|
||||||
|
|
||||||
|
if not collection.prefix:
|
||||||
|
raise KeyError("Prefix must store folder id")
|
||||||
|
|
||||||
|
existing = dict(
|
||||||
|
WorkMeta.objects.filter(
|
||||||
|
work__collection=collection, name="folderid"
|
||||||
|
).values_list("value", "work_id")
|
||||||
|
)
|
||||||
|
|
||||||
|
storage = collection.storage.instance()
|
||||||
|
folders, _ = storage.listdir(collection.prefix)
|
||||||
|
|
||||||
|
for folder in folders:
|
||||||
|
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)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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/folders/(\w+)")
|
||||||
|
SHARED_FILE = re.compile(r"https://drive.google.com/file/d/([\w\-]+)")
|
||||||
|
|
||||||
|
FILES_API = "https://www.googleapis.com/drive/v3/files"
|
||||||
|
|
||||||
|
|
||||||
|
class DriveObject(namedtuple("DriveObject", ("id", "name"))):
|
||||||
|
@classmethod
|
||||||
|
def from_string(cls, s):
|
||||||
|
return cls(*s.split("/", 1))
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.id}/{self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class GDriveLinkStorage(Storage):
|
||||||
|
is_writable = False
|
||||||
|
|
||||||
|
def __init__(self, api_key):
|
||||||
|
self.api_key = api_key
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def parse_id(self, name):
|
||||||
|
parts = name.split("/")
|
||||||
|
return parts[0]
|
||||||
|
|
||||||
|
def extract_id(self, url, *patterns):
|
||||||
|
logger.debug("EXTRACT_ID: %r", url)
|
||||||
|
for pattern in patterns:
|
||||||
|
match = pattern.match(url)
|
||||||
|
if match:
|
||||||
|
return match.groups()[0]
|
||||||
|
raise FileNotFoundError(f"Not a valid url: {url}")
|
||||||
|
|
||||||
|
def get_json(self, url):
|
||||||
|
logger.debug("GET_JSON: %s", url)
|
||||||
|
response = requests.get(url)
|
||||||
|
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
|
||||||
|
if path == "":
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
logger.debug("LISTDIR: %s", path)
|
||||||
|
folder_id = self.parse_id(path)
|
||||||
|
url = f"{FILES_API}?q='{folder_id}'+in+parents&key={self.api_key}"
|
||||||
|
data = self.get_json(url)
|
||||||
|
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["name"]))
|
||||||
|
else:
|
||||||
|
# files.append(f"{x['id']}/{x['name']}")
|
||||||
|
files.append(DriveObject(x["id"], x["name"]))
|
||||||
|
|
||||||
|
return folders, files
|
||||||
|
|
||||||
|
def get_meta(self, name):
|
||||||
|
file_id = self.parse_id(name)
|
||||||
|
url = f"{FILES_API}/{file_id}?key={self.api_key}"
|
||||||
|
return self.get_json(url)
|
||||||
|
|
||||||
|
def open(self, name, mode="rb"):
|
||||||
|
file_id = self.parse_id(name)
|
||||||
|
url = f"{FILES_API}/{file_id}?alt=media&key={self.api_key}"
|
||||||
|
logger.info("URL: %s", url)
|
||||||
|
response = requests.get(url, 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)
|
||||||
|
file_id = self.parse_id(name)
|
||||||
|
return f"https://drive.usercontent.google.com/download?export=download&id={file_id}&confirm=yes"
|
||||||
|
|
||||||
|
def get_folder_id(self, url):
|
||||||
|
try:
|
||||||
|
return self.extract_id(url, SHARED_FOLDER)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_file_id(self, url):
|
||||||
|
try:
|
||||||
|
return self.extract_id(url, SHARED_FILE)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
def import_link(self, url) -> str:
|
||||||
|
file_id = self.extract_id(url, SHARED_FILE)
|
||||||
|
meta = self.get_meta(file_id)
|
||||||
|
return f"{file_id}/{meta['name']}"
|
||||||
|
|
||||||
|
def folder_import(self, url) -> list[str]:
|
||||||
|
folder_id = self.extract_id(url, SHARED_FOLDER)
|
||||||
|
_, files = self.listdir(folder_id)
|
||||||
|
return files
|
||||||
|
"""
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from django.shortcuts import resolve_url, redirect
|
||||||
|
from django.views.generic import FormView
|
||||||
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
|
from library.views import CollectionMixin
|
||||||
|
from library.models import Work, Document
|
||||||
|
from library import forms
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
self.object = self.get_object()
|
||||||
|
|
||||||
|
try:
|
||||||
|
folderid = storage.get_folder_id(link)
|
||||||
|
self.object.meta_info.update_or_create(
|
||||||
|
name="folderid", defaults={"value": folderid}
|
||||||
|
)
|
||||||
|
return redirect("work_detail", self.collection.pk, self.kwargs["pk"])
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass # not a folder id
|
||||||
|
|
||||||
|
try:
|
||||||
|
link = self.collection.storage.instance().import_link(link)
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
form.add_error("link", str(e))
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
work = self.collection.works.get(pk=self.kwargs["pk"])
|
||||||
|
|
||||||
|
doc = Document(
|
||||||
|
work=work,
|
||||||
|
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"])
|
||||||
@@ -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 library.models import Work, Collection
|
||||||
|
|
||||||
|
instance = getattr(settings, "INDEXER", "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)
|
||||||
@@ -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 library import models
|
||||||
|
from 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 library.models import Work, Collection
|
||||||
|
from 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")
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('byostorage', '0006_alter_userstorage_settings_data'),
|
||||||
|
('interface', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Collection',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(help_text='Name of the collection', max_length=255)),
|
||||||
|
('prefix', models.SlugField(default='default', help_text='Folder to store works in', max_length=30)),
|
||||||
|
('location', models.CharField(blank=True, help_text='Physical location (institution, town...)', max_length=100)),
|
||||||
|
('notes', models.TextField(blank=True, help_text='Publicly visible notes about collection and loans policy (markdown format)')),
|
||||||
|
('administrators', models.ManyToManyField(help_text='Administrators for this collection', related_name='collections', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('storage', models.ForeignKey(blank=True, help_text='User storage for documents', null=True, on_delete=django.db.models.deletion.CASCADE, to='byostorage.userstorage')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ProjectItem',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('checkout', models.DateTimeField()),
|
||||||
|
('due', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('returned', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('order', models.SmallIntegerField(default=0)),
|
||||||
|
('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')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['order', 'work'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Work',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('slug', models.SlugField(editable=False, help_text='Used as folder name', max_length=100)),
|
||||||
|
('name', models.CharField(help_text='Original name of the work', max_length=255)),
|
||||||
|
('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')),
|
||||||
|
('max_projects', models.IntegerField(default=1, help_text='How many active projects can this work be attached to')),
|
||||||
|
('running_time', models.DurationField(blank=True, help_text='Running time in seconds', null=True)),
|
||||||
|
('notes', models.TextField(blank=True)),
|
||||||
|
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='works', to='library.collection')),
|
||||||
|
('parent', models.ForeignKey(blank=True, help_text='Arrangement of another work or part of an anthology', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_works', to='library.work')),
|
||||||
|
('projects', models.ManyToManyField(help_text='Current usage', related_name='works', through='library.ProjectItem', to='interface.Project')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='WorkMeta',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.SlugField(choices=[('tag', 'Tag'), ('arr', 'Arranger'), ('lyrics', 'Lyracist'), ('genre', 'Genre'), ('style', 'Style'), ('orchestration', 'Orchestration')], max_length=20)),
|
||||||
|
('value', models.CharField(max_length=255)),
|
||||||
|
('work', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meta_info', to='library.work')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Section',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('tag', models.CharField(max_length=50)),
|
||||||
|
('start', models.SmallIntegerField(blank=True, null=True)),
|
||||||
|
('end', models.SmallIntegerField(blank=True, null=True)),
|
||||||
|
('doc', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='library.document')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['doc', 'start', 'pk'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
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',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Generated by Django 3.2.7 on 2023-01-01 04:35
|
||||||
|
|
||||||
|
import byostorage.cached
|
||||||
|
import byostorage.user
|
||||||
|
from django.db import migrations, models
|
||||||
|
import library.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('library', '0003_auto_20221201_1540'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='section',
|
||||||
|
options={'ordering': ['doc', 'type', 'start', 'pk']},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='section',
|
||||||
|
name='type',
|
||||||
|
field=models.SmallIntegerField(choices=[(1, 'Instrument'), (2, 'Movement')], default=1),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='document',
|
||||||
|
name='upload',
|
||||||
|
field=models.FileField(storage=byostorage.cached.CachedStorage(byostorage.user.BYOStorage()), upload_to=library.models.doc_upload_filename),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='work',
|
||||||
|
name='original_parts',
|
||||||
|
field=models.JSONField(default=dict, help_text='Original printed parts (IMSLP format)'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='work',
|
||||||
|
unique_together=set(),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 3.2.7 on 2023-01-01 04:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('library', '0004_auto_20230101_1535'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='section',
|
||||||
|
options={'ordering': ['type', 'ordinal', 'doc', 'start', 'pk']},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='section',
|
||||||
|
name='ordinal',
|
||||||
|
field=models.IntegerField(default=0),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 3.2.7 on 2023-02-01 21:04
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('library', '0005_auto_20230101_1547'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='section',
|
||||||
|
name='tag',
|
||||||
|
field=models.CharField(blank=True, max_length=50),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='section',
|
||||||
|
name='type',
|
||||||
|
field=models.SmallIntegerField(choices=[(1, 'Instrument'), (2, 'Movement'), (3, 'Excerpt')]),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='work',
|
||||||
|
name='licence',
|
||||||
|
field=models.PositiveSmallIntegerField(choices=[(2, 'Public Domain'), (4, 'Copyright Expired'), (5, 'Recording Licence'), (6, 'Performance Licence'), (8, 'Perusal Licence'), (10, 'Internal use only')], default=6, help_text='Copyright status'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 3.2.7 on 2023-02-14 04:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
def add_default_orchestration(apps, schema_editor):
|
||||||
|
m = apps.get_model('library', 'Orchestration').objects
|
||||||
|
m.create(pk=1, name="Custom", instruments="")
|
||||||
|
|
||||||
|
|
||||||
|
def remove_default_orchestration(apps, schema_editor):
|
||||||
|
m = apps.get_model('library', 'Orchestration').objects
|
||||||
|
m.filter(pk=1).delete()
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('interface', '0004_auto_20230210_0938'),
|
||||||
|
('library', '0006_auto_20230202_0804'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Orchestration',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
('instruments', models.TextField()),
|
||||||
|
('ensemble', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='orchestrations', to='interface.ensemble')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.RunPython(add_default_orchestration, remove_default_orchestration),
|
||||||
|
]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 3.2.7 on 2023-02-14 05:05
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('library', '0007_orchestration'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='work',
|
||||||
|
name='orchestration',
|
||||||
|
field=models.ForeignKey(default=1, help_text='Orchestration for the work', on_delete=django.db.models.deletion.SET_DEFAULT, to='library.orchestration'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.7 on 2023-02-15 03:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('library', '0010_work_orchestration'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='section',
|
||||||
|
name='page',
|
||||||
|
field=models.SmallIntegerField(choices=[(0, 'auto'), (1, 'left'), (2, 'right')], default=0),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 3.2.7 on 2023-02-19 23:13
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('library', '0011_section_page'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='orchestration',
|
||||||
|
name='ensemble',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='orchestration',
|
||||||
|
name='collection',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='custom_orchestrations', to='library.collection'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='work',
|
||||||
|
name='original_parts',
|
||||||
|
field=models.JSONField(blank=True, default=dict, help_text='Original printed parts (IMSLP format)'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='work',
|
||||||
|
name='running_time',
|
||||||
|
field=models.DurationField(blank=True, help_text='Running time in mm:ss format', null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 3.2.7 on 2023-02-23 02:22
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('library', '0012_auto_20230220_1013'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='section',
|
||||||
|
options={'ordering': ['doc', 'start', 'pk']},
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='section',
|
||||||
|
name='ordinal',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='section',
|
||||||
|
name='type',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 3.2.7 on 2023-02-23 03:22
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('library', '0013_auto_20230223_1322'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='collection',
|
||||||
|
name='nonce',
|
||||||
|
field=models.SmallIntegerField(default=1, help_text='Increment this to reset the authentication links'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='work',
|
||||||
|
name='code',
|
||||||
|
field=models.CharField(blank=True, help_text='Collection specific code or number. Will be auto-generated if not supplied', max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='work',
|
||||||
|
name='composer',
|
||||||
|
field=models.CharField(default='Anon', help_text='Composer or compilation editor. Use <b>Surname, Initial</b> for easy searching', max_length=255),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.0.6 on 2024-06-18 05:57
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('library', '0014_auto_20230223_1422'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='collection',
|
||||||
|
name='settings',
|
||||||
|
field=models.JSONField(default=dict, help_text='Storage specific settings'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='document',
|
||||||
|
name='doctype',
|
||||||
|
field=models.PositiveSmallIntegerField(choices=[(1, 'PDF'), (2, 'Audio'), (3, 'Video'), (4, 'Misc')], default=1),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
from django.shortcuts import resolve_url
|
||||||
|
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.db.models import Count, Min, Max
|
||||||
|
|
||||||
|
import re
|
||||||
|
import os.path
|
||||||
|
|
||||||
|
from byostorage.user import BYOStorage
|
||||||
|
from byostorage.cached import CachedStorage
|
||||||
|
|
||||||
|
from library.music_tags import MusicTag, auto_tag
|
||||||
|
from interface.utils import sign_data
|
||||||
|
|
||||||
|
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__)
|
||||||
|
|
||||||
|
# FIXME: move back to settings
|
||||||
|
library_storage = CachedStorage(BYOStorage())
|
||||||
|
|
||||||
|
|
||||||
|
class Orchestration(models.Model):
|
||||||
|
"""
|
||||||
|
Stores a list of instrument codes as a single entry (space delimited).
|
||||||
|
Can be global or ensemble specific
|
||||||
|
"""
|
||||||
|
|
||||||
|
collection = models.ForeignKey(
|
||||||
|
"Collection",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="custom_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, MusicTag.from_tag(t)) for t in tags if t]
|
||||||
|
|
||||||
|
def tag_order(self):
|
||||||
|
tags = [t.strip() for t in self.instruments.split(" ") if t]
|
||||||
|
order = {"score": 0}
|
||||||
|
for i, t in enumerate(tags):
|
||||||
|
order.setdefault(t.strip("-0123456789"), i * 2 + 1)
|
||||||
|
|
||||||
|
return order
|
||||||
|
|
||||||
|
def sorter(self):
|
||||||
|
tag_order = self.tag_order()
|
||||||
|
|
||||||
|
def f(x):
|
||||||
|
return (tag_order.get(x[0].strip("-0123456789"), 1000), x[0])
|
||||||
|
|
||||||
|
return f
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["order", "work"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"<{self.project_id}:{slugify(self.work.name)}>"
|
||||||
|
|
||||||
|
|
||||||
|
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.CharField(
|
||||||
|
max_length=255, 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)",
|
||||||
|
)
|
||||||
|
settings = models.JSONField(
|
||||||
|
default=dict, blank=True, help_text="Storage specific settings"
|
||||||
|
)
|
||||||
|
nonce = models.SmallIntegerField(
|
||||||
|
default=1, help_text="Increment this to reset the authentication links"
|
||||||
|
)
|
||||||
|
|
||||||
|
def meta(self, name):
|
||||||
|
items = (
|
||||||
|
WorkMeta.objects.filter(work__collection=self.pk, name=name)
|
||||||
|
.values_list("value", flat=True)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tags(self):
|
||||||
|
return self.meta("tag")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def genres(self):
|
||||||
|
return self.meta("genre")
|
||||||
|
|
||||||
|
def has_administrator(self, user):
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return False
|
||||||
|
if user.is_superuser:
|
||||||
|
return True
|
||||||
|
return user.pk in self.administrators.values_list("pk", flat=True)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return resolve_url("collection_work_list", self.pk)
|
||||||
|
|
||||||
|
def auth(self):
|
||||||
|
return sign_data(f"{self.pk}-{self.nonce}", 12)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class EnsembleAccess(models.Model):
|
||||||
|
"""
|
||||||
|
Can have different access levels to a collection
|
||||||
|
"""
|
||||||
|
|
||||||
|
ACCESS_UNLIMITED = 1
|
||||||
|
ACCESS_APPROVED = 2
|
||||||
|
|
||||||
|
ACCESS_TYPES = (
|
||||||
|
(ACCESS_UNLIMITED, "Unlimited"),
|
||||||
|
(ACCESS_APPROVED, "Approval required"),
|
||||||
|
)
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
class Work(models.Model):
|
||||||
|
"""
|
||||||
|
A musical work 'owned' by a collection from a licencing perspective.
|
||||||
|
"""
|
||||||
|
|
||||||
|
LICENCE_PUBLIC = 2
|
||||||
|
LICENCE_EXPIRED = 4
|
||||||
|
LICENCE_RECORDING = 5
|
||||||
|
LICENCE_PERFORMANCE = 6
|
||||||
|
LICENCE_PERUSAL = 8
|
||||||
|
LICENCE_NONE = 10
|
||||||
|
|
||||||
|
LICENCE_TYPES = (
|
||||||
|
(LICENCE_PUBLIC, "Public Domain"),
|
||||||
|
(LICENCE_EXPIRED, "Copyright Expired"),
|
||||||
|
(LICENCE_RECORDING, "Recording Licence"),
|
||||||
|
(LICENCE_PERFORMANCE, "Performance Licence"),
|
||||||
|
(LICENCE_PERUSAL, "Perusal Licence"),
|
||||||
|
(LICENCE_NONE, "Internal use only"),
|
||||||
|
)
|
||||||
|
|
||||||
|
name = models.CharField(max_length=255, help_text="Original name of the work")
|
||||||
|
edition = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
help_text="Edition details to distinguish multiple versions",
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
default="Anon",
|
||||||
|
help_text="Composer or compilation editor. Use <b>Surname, Initial</b> for easy searching",
|
||||||
|
)
|
||||||
|
|
||||||
|
orchestration = models.ForeignKey(
|
||||||
|
Orchestration,
|
||||||
|
on_delete=models.SET_DEFAULT,
|
||||||
|
default=1,
|
||||||
|
help_text="Orchestration for the work",
|
||||||
|
)
|
||||||
|
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. Will be auto-generated if not supplied",
|
||||||
|
)
|
||||||
|
licence = models.PositiveSmallIntegerField(
|
||||||
|
choices=LICENCE_TYPES, default=6, help_text="Copyright status"
|
||||||
|
)
|
||||||
|
max_projects = models.IntegerField(
|
||||||
|
default=1, help_text="How many active projects can this work be attached to"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extra info
|
||||||
|
running_time = models.DurationField(
|
||||||
|
null=True, blank=True, help_text="Running time in mm:ss format"
|
||||||
|
)
|
||||||
|
notes = models.TextField(blank=True)
|
||||||
|
|
||||||
|
# Allocation to projects
|
||||||
|
projects = models.ManyToManyField(
|
||||||
|
"interface.Project",
|
||||||
|
through="ProjectItem",
|
||||||
|
related_name="works",
|
||||||
|
help_text="Current usage",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def folder(self):
|
||||||
|
return f"{slugify(self.composer)}/{slugify(self.name)}-{self.pk:04d}"
|
||||||
|
|
||||||
|
def tagged_sections(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 qs
|
||||||
|
|
||||||
|
def list_sections(self, *tags):
|
||||||
|
return list(self.tagged_sections(*tags).values_list("upload", "start", "end"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def digital_parts(self):
|
||||||
|
sections = [(s.tag, s.name) for s in Section.objects.filter(doc__work=self.pk)]
|
||||||
|
sections.sort(key=self.orchestration.sorter())
|
||||||
|
# return [ s[1] for s in sections ]
|
||||||
|
sections = list(dict(sections).items()) # primitive unique()
|
||||||
|
return sections
|
||||||
|
|
||||||
|
def pdfs(self):
|
||||||
|
return self.docs.filter(doctype=Document.DOCTYPE_PDF)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def physical_parts(self):
|
||||||
|
if not self.original_parts:
|
||||||
|
return []
|
||||||
|
parts = list(self.original_parts.items())
|
||||||
|
parts.sort(key=self.orchestration.sorter())
|
||||||
|
return [(MusicTag.from_tag(x[0]), x[1]) for x in parts]
|
||||||
|
|
||||||
|
@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")
|
||||||
|
|
||||||
|
@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"
|
||||||
|
composer = re.sub(r"[^\w]", "", composer)
|
||||||
|
words = self.name.split()
|
||||||
|
work = words[0][:3]
|
||||||
|
|
||||||
|
return f"{composer[:4]}-{work}-{self.pk:05d}".upper()
|
||||||
|
|
||||||
|
def assigned_instruments(self):
|
||||||
|
return Section.objects.filter(doc__work_id=self.pk).values_list(
|
||||||
|
"tag", flat=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def unassigned_instruments(self):
|
||||||
|
assigned = set(self.assigned_instruments())
|
||||||
|
return [x for x in self.orchestration.as_list() if x[0] not in assigned]
|
||||||
|
|
||||||
|
def music_tags(self):
|
||||||
|
tags = dict(self.orchestration.as_list())
|
||||||
|
for section in Section.objects.filter(doc__work_id=self.pk):
|
||||||
|
tags.setdefault(section.tag, section.name)
|
||||||
|
|
||||||
|
return tags.items()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.composer})"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkMeta(models.Model):
|
||||||
|
META_CHOICES = (
|
||||||
|
("tag", "Tag"),
|
||||||
|
("arr", "Arranger"),
|
||||||
|
("lyrics", "Lyracist"),
|
||||||
|
("genre", "Genre"),
|
||||||
|
("style", "Style"),
|
||||||
|
("orchestration", "Orchestration"),
|
||||||
|
("folderid", "GDrive"),
|
||||||
|
)
|
||||||
|
|
||||||
|
work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name="meta_info")
|
||||||
|
name = models.SlugField(max_length=20, choices=META_CHOICES)
|
||||||
|
value = models.CharField(max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
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.folder}/{filename}"
|
||||||
|
|
||||||
|
|
||||||
|
class Document(models.Model):
|
||||||
|
"""
|
||||||
|
Document represents a single file stored in the storage backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DOCTYPE_PDF = 1
|
||||||
|
DOCTYPE_AUDIO = 2
|
||||||
|
DOCTYPE_VIDEO = 3
|
||||||
|
DOCTYPE_MISC = 4
|
||||||
|
|
||||||
|
DOCTYPES = (
|
||||||
|
(DOCTYPE_PDF, "PDF"),
|
||||||
|
(DOCTYPE_AUDIO, "Audio"),
|
||||||
|
(DOCTYPE_VIDEO, "Video"),
|
||||||
|
(DOCTYPE_MISC, "Misc"),
|
||||||
|
)
|
||||||
|
|
||||||
|
DOCTYPE_MAP = {
|
||||||
|
".pdf": DOCTYPE_PDF,
|
||||||
|
".mp3": DOCTYPE_AUDIO,
|
||||||
|
".mp4": DOCTYPE_VIDEO,
|
||||||
|
}
|
||||||
|
|
||||||
|
work = models.ForeignKey("Work", on_delete=models.CASCADE, related_name="docs")
|
||||||
|
doctype = models.PositiveSmallIntegerField(choices=DOCTYPES, default=DOCTYPE_PDF)
|
||||||
|
upload = models.FileField(upload_to=doc_upload_filename, storage=library_storage)
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
version = models.CharField(max_length=30, blank=True)
|
||||||
|
|
||||||
|
def auto_tag(self):
|
||||||
|
if self.doctype != self.DOCTYPE_PDF:
|
||||||
|
return
|
||||||
|
filename = os.path.basename(str(self.upload))
|
||||||
|
inst = auto_tag(filename)
|
||||||
|
if inst:
|
||||||
|
self.sections.get_or_create(tag=inst.abbreviate())
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
self.upload.delete(save=False)
|
||||||
|
return super().delete(*args, **kwargs)
|
||||||
|
|
||||||
|
def filename(self):
|
||||||
|
return os.path.basename(self.upload.name)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.upload.name
|
||||||
|
|
||||||
|
|
||||||
|
class Section(models.Model):
|
||||||
|
"""
|
||||||
|
Section is a tagged portion of a Document
|
||||||
|
"""
|
||||||
|
|
||||||
|
PAGE_AUTO = 0
|
||||||
|
PAGE_LEFT = 1
|
||||||
|
PAGE_RIGHT = 2
|
||||||
|
|
||||||
|
PAGE_PREFERENCE = (
|
||||||
|
(PAGE_AUTO, "auto"),
|
||||||
|
(PAGE_LEFT, "left"),
|
||||||
|
(PAGE_RIGHT, "right"),
|
||||||
|
)
|
||||||
|
|
||||||
|
doc = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="sections")
|
||||||
|
tag = models.CharField(max_length=50, blank=True)
|
||||||
|
start = models.SmallIntegerField(null=True, blank=True)
|
||||||
|
end = models.SmallIntegerField(null=True, blank=True)
|
||||||
|
page = models.SmallIntegerField(
|
||||||
|
default=PAGE_AUTO, choices=PAGE_PREFERENCE
|
||||||
|
) # NOT CURRENTLY USED
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["doc", "start", "pk"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def music_tag(self):
|
||||||
|
return MusicTag.from_tag(self.tag)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return str(self.music_tag)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bulma_class(self):
|
||||||
|
return "success" if self.music_tag.is_general else "info"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def filename(self):
|
||||||
|
return slugify(f"{self.doc.work.name} - {self.name}").title() + ".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 self.name
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
from collections import namedtuple
|
||||||
|
import re
|
||||||
|
|
||||||
|
GENERAL = """
|
||||||
|
mvmt Movement
|
||||||
|
ex Excerpt
|
||||||
|
sec Section
|
||||||
|
pce Piece
|
||||||
|
no Number
|
||||||
|
"""
|
||||||
|
|
||||||
|
# taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_MusicTags
|
||||||
|
# Include any aliases at the top
|
||||||
|
|
||||||
|
INSTRUMENTS = """
|
||||||
|
score Score
|
||||||
|
|
||||||
|
cb Double Bass
|
||||||
|
mall Mallet Percussion
|
||||||
|
vln Violin
|
||||||
|
vla Viola
|
||||||
|
|
||||||
|
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 MusicTags
|
||||||
|
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
|
||||||
|
drum Drumset
|
||||||
|
dlcn Dulcian
|
||||||
|
dom Domra
|
||||||
|
dulc Dulcimer
|
||||||
|
egtr Electric guitar
|
||||||
|
eh English horn (Cor anglais)
|
||||||
|
elec Electronic MusicTags
|
||||||
|
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 MusicTag
|
||||||
|
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 MusicTags
|
||||||
|
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 MusicTags
|
||||||
|
xiao Xiao
|
||||||
|
xyl Xylophone
|
||||||
|
zith Zither
|
||||||
|
"""
|
||||||
|
|
||||||
|
MUSIC_TAGS = []
|
||||||
|
GENERAL_TAGS = set()
|
||||||
|
for i, abbreviations in enumerate((GENERAL, INSTRUMENTS)):
|
||||||
|
for line in abbreviations.split("\n"):
|
||||||
|
parts = line.strip().split(maxsplit=1)
|
||||||
|
if len(parts) < 2:
|
||||||
|
continue
|
||||||
|
name, _, _ = parts[1].partition("(")
|
||||||
|
MUSIC_TAGS.append((parts[0], name))
|
||||||
|
if i == 0:
|
||||||
|
GENERAL_TAGS.add(parts[0])
|
||||||
|
|
||||||
|
|
||||||
|
MUSIC_NAME_BY_TAG = dict(MUSIC_TAGS)
|
||||||
|
MUSIC_TAG_BY_NAME = dict(((x[1].lower(), x[0]) for x in MUSIC_TAGS))
|
||||||
|
|
||||||
|
|
||||||
|
class MusicTag(namedtuple("MusicTag", ("name", "variant"), defaults=[None])):
|
||||||
|
@classmethod
|
||||||
|
def from_tag(cls, tag):
|
||||||
|
"""
|
||||||
|
>>> MusicTag.from_tag('vn-1')
|
||||||
|
MusicTag(name='Violin', variant='1')
|
||||||
|
>>> MusicTag.from_tag('db')
|
||||||
|
MusicTag(name='Double Bass', variant=None)
|
||||||
|
>>> MusicTag.from_tag('Jaws Harp')
|
||||||
|
MusicTag(name='Jaws Harp', variant=None)
|
||||||
|
>>> MusicTag.from_tag('mvmt-2')
|
||||||
|
MusicTag(name='Movement', variant='2')
|
||||||
|
>>> MusicTag.from_tag('pce-A2')
|
||||||
|
MusicTag(name='Piece', variant='A2')
|
||||||
|
"""
|
||||||
|
abbr, _, variant = tag.partition("-")
|
||||||
|
name = MUSIC_NAME_BY_TAG.get(abbr.lower(), abbr)
|
||||||
|
|
||||||
|
if variant:
|
||||||
|
return cls(name, variant)
|
||||||
|
return cls(name, None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tag(self):
|
||||||
|
lc = self.name.lower()
|
||||||
|
return MUSIC_TAG_BY_NAME.get(lc, lc)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_general(self):
|
||||||
|
"""
|
||||||
|
>>> MusicTag('Piece', 'A3').is_general
|
||||||
|
True
|
||||||
|
>>> MusicTag('Violin', 2).is_general
|
||||||
|
False
|
||||||
|
"""
|
||||||
|
return self.tag in GENERAL_TAGS
|
||||||
|
|
||||||
|
def abbreviate(self):
|
||||||
|
"""
|
||||||
|
>>> MusicTag('Violin', 1).abbreviate()
|
||||||
|
'vn-1'
|
||||||
|
>>> MusicTag('Double Bass').abbreviate()
|
||||||
|
'db'
|
||||||
|
"""
|
||||||
|
tag = MUSIC_TAG_BY_NAME.get(self.name.lower())
|
||||||
|
if self.variant:
|
||||||
|
tag = f"{tag}-{self.variant}"
|
||||||
|
return tag
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""
|
||||||
|
>>> str(MusicTag('Violin', 1))
|
||||||
|
'Violin 1'
|
||||||
|
>>> str(MusicTag('Double Bass'))
|
||||||
|
'Double Bass'
|
||||||
|
"""
|
||||||
|
if self.variant:
|
||||||
|
return f"{self.name} {self.variant}"
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
PATTERNS = [
|
||||||
|
re.compile(r"(?P<inst>[A-Za-z]+)[_\- ]*(?P<ord>\d+)"),
|
||||||
|
re.compile(r"(?P<ord>\d+)(st|nd|rd|th)[_\- ]*(?P<inst>[A-Za-z]+)"),
|
||||||
|
re.compile(r"(?P<inst>[A-Za-z]+)()"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def auto_tag(filename):
|
||||||
|
"""
|
||||||
|
|
||||||
|
>>> auto_tag('Ode to Joy - Violin 1.pdf')
|
||||||
|
MusicTag(name='Violin', variant=1)
|
||||||
|
>>> auto_tag('Ode to Joy_Cello.pdf')
|
||||||
|
MusicTag(name='Cello', variant=None)
|
||||||
|
>>> auto_tag('Ode to Joy violin - 1.pdf')
|
||||||
|
MusicTag(name='Violin', variant=1)
|
||||||
|
>>> auto_tag('Ode to Joy - vla.pdf')
|
||||||
|
MusicTag(name='Viola', variant=None)
|
||||||
|
>>> auto_tag('Ode to Joy - fl-2 (piccolo).pdf')
|
||||||
|
MusicTag(name='Flute', variant=2)
|
||||||
|
>>> auto_tag('1st Violin - Ode to Joy.pdf')
|
||||||
|
MusicTag(name='Violin', variant=1)
|
||||||
|
>>> auto_tag('Ode to Joy - 2nd Violin.pdf')
|
||||||
|
MusicTag(name='Violin', variant=2)
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
for pattern in PATTERNS:
|
||||||
|
for m in pattern.finditer(filename):
|
||||||
|
inst = m["inst"].lower()
|
||||||
|
try:
|
||||||
|
ordinal = int(m["ord"])
|
||||||
|
except IndexError:
|
||||||
|
ordinal = None
|
||||||
|
if inst in MUSIC_TAG_BY_NAME:
|
||||||
|
return MusicTag(inst.title(), ordinal)
|
||||||
|
if inst in MUSIC_NAME_BY_TAG:
|
||||||
|
return MusicTag(MUSIC_NAME_BY_TAG[inst], ordinal)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import doctest
|
||||||
|
|
||||||
|
print(doctest.testmod())
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import tempfile
|
||||||
|
import subprocess
|
||||||
|
import os.path
|
||||||
|
import string
|
||||||
|
|
||||||
|
SAFECHARS = string.ascii_letters + string.digits + " _-"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_pages(source, bookmark, start=None, end=None, count=1):
|
||||||
|
|
||||||
|
return extract_and_concat([(source, bookmark, start, end, count)])
|
||||||
|
|
||||||
|
|
||||||
|
def extract_and_concat(items):
|
||||||
|
|
||||||
|
# create a temporary directory for our sections
|
||||||
|
d = tempfile.TemporaryDirectory(prefix="polyphonic_")
|
||||||
|
pdfmarks = os.path.join(d.name, "pdfmarks.txt")
|
||||||
|
marks = open(pdfmarks, "w")
|
||||||
|
|
||||||
|
sections = []
|
||||||
|
current_page = 1
|
||||||
|
|
||||||
|
for i, (source, bookmark, start, end, count) in enumerate(items):
|
||||||
|
if count == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not start:
|
||||||
|
sections.append(source)
|
||||||
|
|
||||||
|
else:
|
||||||
|
if not end:
|
||||||
|
end = start
|
||||||
|
|
||||||
|
dest = os.path.join(d.name, f"section_{i}.pdf")
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"gs",
|
||||||
|
"-sDEVICE=pdfwrite",
|
||||||
|
"-dBATCH",
|
||||||
|
"-dNOPAUSE",
|
||||||
|
f"-dFirstPage={start}",
|
||||||
|
f"-dLastPage={end}",
|
||||||
|
f"-sOutputFile={dest}",
|
||||||
|
source,
|
||||||
|
]
|
||||||
|
|
||||||
|
bookmark = "".join(filter(lambda c: c in SAFECHARS, bookmark))
|
||||||
|
|
||||||
|
marks.write(f"[/Title ({bookmark}) /Page {current_page} /OUT pdfmark\n")
|
||||||
|
|
||||||
|
p = subprocess.run(cmd, check=True, capture_output=True)
|
||||||
|
pages = len(
|
||||||
|
[
|
||||||
|
x
|
||||||
|
for x in p.stdout.splitlines()
|
||||||
|
if x.decode("utf8").startswith("Page ")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for j in range(count):
|
||||||
|
sections.append(dest)
|
||||||
|
current_page += pages
|
||||||
|
|
||||||
|
marks.close()
|
||||||
|
|
||||||
|
# concat the items
|
||||||
|
output = tempfile.NamedTemporaryFile(prefix="polyphonic_", suffix=".pdf")
|
||||||
|
|
||||||
|
cmd = ["gs", "-sDEVICE=pdfwrite", "-q", "-dBATCH", "-dNOPAUSE", "-sOutputFile=-"]
|
||||||
|
cmd.extend(sections)
|
||||||
|
cmd.append(pdfmarks)
|
||||||
|
|
||||||
|
subprocess.run(cmd, stdout=output)
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
return output
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{% extends "interface/project_base.html" %}
|
||||||
|
|
||||||
|
{% block page %}
|
||||||
|
<h3 class="title">Library collections for {% firstof request.user.first_name request.user.username %}</h3>
|
||||||
|
|
||||||
|
<div class="block">
|
||||||
|
<form method="GET" action="{% url 'work_list' %}">
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<input class="input" name="q" type="text" placeholder="Find a work" value="{{ request.GET.filter }}"/>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<a class="button" href="?"><i class="fas fa-times"></i></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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' collection=collection.id %}">
|
||||||
|
<p class="card-header-title">{{ collection.name }}</p>
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
<div class="card-content">
|
||||||
|
<p>
|
||||||
|
{% if collection.location %}{{ collection.location }},{% endif %}
|
||||||
|
{{ collection.works.count }} item{{ collection.works.count|pluralize }}.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{% for tag in collection.tags %}
|
||||||
|
<a href="{% url 'collection_work_list' collection.pk %}?q=tag:{{ tag }}" class="tag is-success">{{ tag }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% for genre in collection.genres %}
|
||||||
|
<a href="{% url 'collection_work_list' collection.pk %}?q=genre:{{ genre }}" class="tag is-warning">{{ genre }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<small>{{ ensemble.ensemble_code }}</small>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
{% extends "interface/project_base.html" %}
|
||||||
|
|
||||||
|
{% block admin %}
|
||||||
|
<a href="#" onclick="saveTags()" class="button is-link">
|
||||||
|
<span class="icon"><i class="fas fa-save"></i></span>
|
||||||
|
<span>Save</span>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'work_detail' collection=collection.pk pk=object.work_id %}" class="button is-link is-light">
|
||||||
|
<span>Cancel</span>
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block media %}
|
||||||
|
<style>
|
||||||
|
.tag-grid {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.grid-column {
|
||||||
|
margin-left: 1em;
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
|
.grid-page {
|
||||||
|
height: 25px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
border: 1px solid #999;
|
||||||
|
border-radius: 5px;
|
||||||
|
text-align: center;
|
||||||
|
width: 50px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.grid-page.is-active {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.grid-tag {
|
||||||
|
border: 2px solid var(--primary);
|
||||||
|
background-color: rgba(240, 240, 240, 0.5);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding-left: 1em;
|
||||||
|
padding-right: 1em;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
min-width: 200px;
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
#tag-area {
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
.instrument {
|
||||||
|
display: list-item !important;
|
||||||
|
}
|
||||||
|
.instrument:hover {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page %}
|
||||||
|
<h3 class="subtitle"><a href="{% url 'work_detail' collection.pk document.work_id %}">{{ document.work.name }}</a></h3>
|
||||||
|
<div id="annotation-area" class="columns is-centered">
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<button class="button is-small is-primary" id="prev"><</button>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<span id="page-num">-</span> / <span id="page-count">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<button class="button is-small is-primary" id="next">></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul id="unassigned-area">
|
||||||
|
{% for tag, inst in document.work.music_tags %}
|
||||||
|
<li class="is-clickable" onclick="assignInstrument('{{tag}}', this)")>{{ inst }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<a onclick="document.getElementById('add-modal').classList.add('is-active')">Add Tag</a>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<div class="has-text-centered">
|
||||||
|
<div class="box" style="display: inline-block;">
|
||||||
|
<canvas id="inline-viewer" style="width: 500px;"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow">
|
||||||
|
|
||||||
|
<div class="tag-grid">
|
||||||
|
<div class="grid-column" id="page-list">
|
||||||
|
</div>
|
||||||
|
<div class="grid-column" id="tag-area">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="button is-primary" style="width: 100%" onclick="expandEntries()">Expand</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" id="add-modal">
|
||||||
|
<div class="modal-background" onclick="closeAddModal()"></div>
|
||||||
|
<div class="modal-card">
|
||||||
|
<header class="modal-card-head">
|
||||||
|
<p class="modal-card-title">Add Instrument</p>
|
||||||
|
<button class="delete" aria-label="close" onclick="closeAddModal()"></button>
|
||||||
|
</header>
|
||||||
|
<section class="modal-card-body">
|
||||||
|
<div class="field has-addons">
|
||||||
|
<span class="control">
|
||||||
|
<input type="text" class="input" list="instrument-list" id="add-instrument-name"/>
|
||||||
|
<datalist id="instrument-list">
|
||||||
|
{% for inst in json_data.instruments.values %}
|
||||||
|
<option value="{{inst}}"/>
|
||||||
|
{% endfor %}
|
||||||
|
</datalist>
|
||||||
|
</span>
|
||||||
|
<span class="control">
|
||||||
|
<input type="number" class="input" max="4" min="1" size="3" id="add-instrument-variant"/>
|
||||||
|
</span>
|
||||||
|
<span class="control">
|
||||||
|
<button class="button is-primary" onclick="addInstrument();">Add</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>{{ document.upload.name }}</p>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.7.570/pdf.min.js"
|
||||||
|
integrity="sha512-g4FwCPWM/fZB1Eie86ZwKjOP+yBIxSBM/b2gQAiSVqCgkyvZ0XxYPDEcN2qqaKKEvK6a05+IPL1raO96RrhYDQ=="
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
|
{{ json_data|json_script:"data" }}
|
||||||
|
<script type="text/javascript">
|
||||||
|
let url = "{% url 'document_download' collection.pk document.pk %}";
|
||||||
|
|
||||||
|
// Loaded via <script> tag, create shortcut to access PDF.js exports.
|
||||||
|
let pdfjsLib = window['pdfjs-dist/build/pdf'];
|
||||||
|
|
||||||
|
// The workerSrc property shall be specified.
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.7.570/pdf.worker.min.js';
|
||||||
|
|
||||||
|
// get current page tags
|
||||||
|
let data = JSON.parse(document.getElementById('data').textContent);
|
||||||
|
let tagArea = document.getElementById('tag-area');
|
||||||
|
var dirty = false;
|
||||||
|
|
||||||
|
//document.getElementById('tag-list').onclick = (e) => setTag(e.target.dataset.tag);
|
||||||
|
|
||||||
|
var pdfDoc = null,
|
||||||
|
pageNum = 1,
|
||||||
|
pageRendering = false,
|
||||||
|
pageNumPending = null,
|
||||||
|
scale = 1,
|
||||||
|
canvas = document.getElementById('inline-viewer'),
|
||||||
|
ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get page info from document, resize canvas accordingly, and render page.
|
||||||
|
* @param num Page number.
|
||||||
|
*/
|
||||||
|
function renderPage(num) {
|
||||||
|
pageRendering = true;
|
||||||
|
// Using promise to fetch the page
|
||||||
|
pdfDoc.getPage(num).then(function (page) {
|
||||||
|
var viewport = page.getViewport({ scale: scale });
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
|
||||||
|
// Render PDF page into canvas context
|
||||||
|
var renderContext = {
|
||||||
|
canvasContext: ctx,
|
||||||
|
viewport: viewport
|
||||||
|
};
|
||||||
|
var renderTask = page.render(renderContext);
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.getElementById('grid-page-' + pageNum).classList.remove('is-active');
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
pageNum = num;
|
||||||
|
document.getElementById('grid-page-' + pageNum).classList.add('is-active');
|
||||||
|
|
||||||
|
// Wait for rendering to finish
|
||||||
|
renderTask.promise.then(function () {
|
||||||
|
pageRendering = false;
|
||||||
|
if (pageNumPending !== null) {
|
||||||
|
// New page rendering is pending
|
||||||
|
renderPage(pageNumPending);
|
||||||
|
pageNumPending = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update page counters
|
||||||
|
document.getElementById('page-num').textContent = num;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If another page rendering in progress, waits until the rendering is
|
||||||
|
* finised. Otherwise, executes rendering immediately.
|
||||||
|
*/
|
||||||
|
function queueRenderPage(num) {
|
||||||
|
if (pageRendering) {
|
||||||
|
pageNumPending = num;
|
||||||
|
} else {
|
||||||
|
renderPage(num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays previous page.
|
||||||
|
*/
|
||||||
|
function onPrevPage() {
|
||||||
|
if (pageNum <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queueRenderPage(pageNum-1);
|
||||||
|
}
|
||||||
|
document.getElementById('prev').addEventListener('click', onPrevPage);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays next page.
|
||||||
|
*/
|
||||||
|
function onNextPage() {
|
||||||
|
if (pageNum >= pdfDoc.numPages) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queueRenderPage(pageNum+1);
|
||||||
|
}
|
||||||
|
document.getElementById('next').addEventListener('click', onNextPage);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronously downloads PDF.
|
||||||
|
*/
|
||||||
|
pdfjsLib.getDocument(url).promise.then(function (pdfDoc_) {
|
||||||
|
pdfDoc = pdfDoc_;
|
||||||
|
|
||||||
|
const pageList = document.getElementById('page-list');
|
||||||
|
pageList.innerHTML = '';
|
||||||
|
for (var i=0; i<pdfDoc.numPages; i++) {
|
||||||
|
let page = i+1;
|
||||||
|
let el = document.createElement('div');
|
||||||
|
el.className = 'grid-page';
|
||||||
|
el.id = 'grid-page-' + page;
|
||||||
|
el.innerHTML = page;
|
||||||
|
el.addEventListener('click', (evt) => {
|
||||||
|
queueRenderPage(page);
|
||||||
|
});
|
||||||
|
pageList.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('page-count').textContent = pdfDoc.numPages;
|
||||||
|
|
||||||
|
// Initial/first page rendering
|
||||||
|
renderPage(pageNum);
|
||||||
|
|
||||||
|
tagArea.innerHTML = '';
|
||||||
|
for (let pageTag of data.pageTags) {
|
||||||
|
addTag(pageTag[0], pageTag[1], pageTag[2]);
|
||||||
|
}
|
||||||
|
dirty = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function closeAddModal() {
|
||||||
|
document.getElementById('add-modal').classList.remove('is-active');
|
||||||
|
document.getElementById('add-instrument-name').value = "";
|
||||||
|
document.getElementById('add-instrument-variant').value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function addInstrument() {
|
||||||
|
let name = document.getElementById('add-instrument-name');
|
||||||
|
let variant = document.getElementById('add-instrument-variant');
|
||||||
|
|
||||||
|
let inst_name = name.value;
|
||||||
|
var tag = null;
|
||||||
|
for (let key in data.instruments) {
|
||||||
|
if (data.instruments[key] == inst_name) {
|
||||||
|
tag = key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tag) {
|
||||||
|
alert("Unknown tag: " + name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant.value) {
|
||||||
|
tag += "-" + variant.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
variant.value = '';
|
||||||
|
name.value = '';
|
||||||
|
|
||||||
|
let li = document.createElement('li');
|
||||||
|
li.classList.add("is-clickable");
|
||||||
|
li.addEventListener('click', () => assignInstrument(tag, li));
|
||||||
|
li.innerHTML = get_instrument(tag);
|
||||||
|
document.getElementById('unassigned-area').appendChild(li);
|
||||||
|
|
||||||
|
addTag(tag, pageNum, pageNum);
|
||||||
|
closeAddModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignInstrument(tag, el) {
|
||||||
|
addTag(tag, pageNum, pageNum);
|
||||||
|
//el.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTag(tag, start, end) {
|
||||||
|
console.log("addTag", tag, start, end);
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'grid-tag';
|
||||||
|
el.dataset.tag = tag;
|
||||||
|
el.dataset.start = start;
|
||||||
|
el.dataset.end = end;
|
||||||
|
|
||||||
|
let setStart = document.createElement('span');
|
||||||
|
setStart.className = "icon is-action";
|
||||||
|
setStart.innerHTML = '<i class="fas fa-sort-amount-down" title="Set start page"></i>';
|
||||||
|
setStart.addEventListener('click', () => setTagStart(el));
|
||||||
|
el.appendChild(setStart);
|
||||||
|
|
||||||
|
|
||||||
|
let label = document.createElement('span');
|
||||||
|
|
||||||
|
let name = document.createElement('b');
|
||||||
|
name.innerHTML = get_instrument(tag);
|
||||||
|
label.appendChild(name);
|
||||||
|
|
||||||
|
let del = document.createElement('span');
|
||||||
|
del.className = "icon is-action";
|
||||||
|
del.innerHTML = '<i class="fas fa-trash-alt" title="Remove this tag"></i>';
|
||||||
|
del.addEventListener('click', () => {
|
||||||
|
el.remove();
|
||||||
|
dirty=true;
|
||||||
|
});
|
||||||
|
label.appendChild(del)
|
||||||
|
|
||||||
|
el.appendChild(label);
|
||||||
|
|
||||||
|
|
||||||
|
let setEnd = document.createElement('span');
|
||||||
|
setEnd.className = "icon is-action";
|
||||||
|
setEnd.innerHTML = '<i class="fas fa-sort-amount-up" title="Set end page"></i>';
|
||||||
|
setEnd.addEventListener('click', () => setTagEnd(el));
|
||||||
|
el.appendChild(setEnd);
|
||||||
|
|
||||||
|
updateTag(el);
|
||||||
|
tagArea.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTag(tag) {
|
||||||
|
let start = tag.dataset.start;
|
||||||
|
let end = tag.dataset.end;
|
||||||
|
let span = end-start+1;
|
||||||
|
let height = span * 25 + (span-1) * 5;
|
||||||
|
let top = (start-1) * 30;
|
||||||
|
|
||||||
|
tag.style.height = height + 'px';
|
||||||
|
tag.style.marginTop = top + 'px';
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTagStart(el) {
|
||||||
|
if (pageNum > el.dataset.end) {
|
||||||
|
alert("Select the first page and click extend");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.dataset.start = pageNum;
|
||||||
|
updateTag(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTagEnd(el) {
|
||||||
|
if (pageNum < el.dataset.start) {
|
||||||
|
alert("Select the last page and click extend");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.dataset.end = pageNum;
|
||||||
|
updateTag(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandEntries() {
|
||||||
|
const entries = Array.from(tagArea.children);
|
||||||
|
entries.sort((a, b) => a.dataset.start-b.dataset.start);
|
||||||
|
const c = entries.length;
|
||||||
|
for (let i=0; i<c-1; i++) {
|
||||||
|
entries[i].dataset.end = entries[i+1].dataset.start - 1;
|
||||||
|
updateTag(entries[i]);
|
||||||
|
|
||||||
|
}
|
||||||
|
entries[c-1].dataset.end = pdfDoc.numPages;
|
||||||
|
updateTag(entries[c-1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTags() {
|
||||||
|
const pageTags = [];
|
||||||
|
for (let pageTag of tagArea.children ) {
|
||||||
|
let start = pageTag.dataset.start;
|
||||||
|
let end = pageTag.dataset.end;
|
||||||
|
if (start == 1 && end == pdfDoc.numPages) {
|
||||||
|
start = null;
|
||||||
|
end = null;
|
||||||
|
}
|
||||||
|
pageTags.push([pageTag.dataset.tag, start, end])
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(pageTags);
|
||||||
|
fetch("", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "X-CSRFToken": "{{ csrf_token }}" },
|
||||||
|
body: JSON.stringify(pageTags)
|
||||||
|
}
|
||||||
|
).then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
window.location = "{% url 'work_detail' collection.pk document.work_id %}"
|
||||||
|
} else {
|
||||||
|
alert("Failed: " + response.statusText)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_instrument(s) {
|
||||||
|
let parts = s.split('-');
|
||||||
|
let instrument = data.instruments[parts[0]];
|
||||||
|
if (parts.length == 2) {
|
||||||
|
return instrument + " " + parts[1];
|
||||||
|
}
|
||||||
|
return instrument;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkSaved(e) {
|
||||||
|
if (dirty) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('beforeunload', checkSaved);
|
||||||
|
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{% extends "interface/project_base.html" %}
|
||||||
|
|
||||||
|
{% block page %}
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-half is-centered">
|
||||||
|
<div class="block">
|
||||||
|
<p>Are you sure you want to delete<br>
|
||||||
|
<b>"{{ object.upload.name }}"</b>?</p>
|
||||||
|
</div>
|
||||||
|
<form method="post">{% csrf_token %}
|
||||||
|
<div class="field is-grouped">
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-link">Yes</button>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<a class="button is-link is-light" href="{% url 'work_detail' collection.pk object.work.pk %}">No</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{% load path_filters %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ doc.upload.url }}" target="_blank">
|
||||||
|
{{ doc.upload.name|basename }}</a></td>
|
||||||
|
<td>
|
||||||
|
{% for section in doc.sections.all %}
|
||||||
|
<a class="tag is-{{ section.bulma_class }}" target="_blank" href="{% url 'part_download' collection.pk section.pk section.filename %}">{{ section.name }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td class="has-text-right" style="white-space: nowrap;">
|
||||||
|
{% if request.is_admin %}
|
||||||
|
{% if doc.doctype == 1 %}
|
||||||
|
<a href="{% url 'document_annotate' collection.pk doc.pk %}"><i class="fas fa-tags"
|
||||||
|
title="Manage Tags"></i></a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{% url 'document_delete' collection.pk doc.pk %}"><i class="fas fa-trash-alt" title="Delete Document"></i></a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{% extends 'interface/project_base.html' %}
|
||||||
|
|
||||||
|
{% block page %}
|
||||||
|
<h2>{{ folder.name }}</h2>
|
||||||
|
<h3>Works</h3>
|
||||||
|
<ul>
|
||||||
|
{% for work in folder.works.all %}
|
||||||
|
<li>{{ work.name }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{% extends "interface/project_base.html" %}
|
||||||
|
|
||||||
|
{% block admin %}
|
||||||
|
<a href="{% url 'work_detail' collection=collection.pk pk=object.pk %}" class="button is-link is-light">
|
||||||
|
<span>Back to work</span>
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page %}
|
||||||
|
<h3 class="subtitle"><a href="{% url 'work_detail' collection.pk object.pk %}">{{ object.name }}</a></h3>
|
||||||
|
<div class="m-3">
|
||||||
|
<p>This page lets you link a work to a google drive folder. You can either paste a public link to a folder to enable syncing or a file to add individually</p>
|
||||||
|
</div>
|
||||||
|
<div class="m-3">
|
||||||
|
<p>
|
||||||
|
{% if meta.folderid %}
|
||||||
|
This work is currently linked to <b>{{ meta.folderid }}</b>. Pasting a new folder link will overwrite this.
|
||||||
|
{% else %}
|
||||||
|
There is currently no shared drive folder linked to this work - paste one here to enable syncing.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<form method="post">
|
||||||
|
<div class="field">
|
||||||
|
<div class="control">
|
||||||
|
<input name="link" class="input is-expanded" type="text" placeholder="Shared link">
|
||||||
|
</div>
|
||||||
|
{% for error in form.errors.link %}
|
||||||
|
<p class="help is-danger">{{ error }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-info" type="submit">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% csrf_token %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
{% extends "interface/project_base.html" %}
|
||||||
|
|
||||||
|
{% block page %}
|
||||||
|
<form action="" method="post" target="_blank">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div>
|
||||||
|
<div class="field is-grouped is-grouped-centered is-grouped-multiline">
|
||||||
|
<div class="field has-addons control has-addons-centered is-expanded">
|
||||||
|
|
||||||
|
<span class="control is-hidden-mobile">
|
||||||
|
<a class="button is-static">Instrument</a>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="control">
|
||||||
|
<input type="text" class="input" list="instrument-list" id="instrument-name" onchange="updateParts()"/>
|
||||||
|
<datalist id="instrument-list">
|
||||||
|
{% for inst in instruments %}
|
||||||
|
<option value="{{inst.1}}"/>
|
||||||
|
{% endfor %}
|
||||||
|
</datalist>
|
||||||
|
</span>
|
||||||
|
<span class="control">
|
||||||
|
<span class="select" onchange="updateParts()">
|
||||||
|
<select id="part-preference">
|
||||||
|
<option value="0">Any</option>
|
||||||
|
<option value="1">1st</option>
|
||||||
|
<option value="2">2nd</option>
|
||||||
|
<option value="3">3rd</option>
|
||||||
|
<option value="4">4th</option>
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="control">
|
||||||
|
<button type="submit" class="button is-primary">
|
||||||
|
<span class="icon"><i class="fas fa-copy"></i></span>
|
||||||
|
<span>Get My Parts!</span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table is-striped mx-auto">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th/>
|
||||||
|
<th>Piece</th>
|
||||||
|
<th class="is-hidden-mobile">Running time</th>
|
||||||
|
<th>Part</th>
|
||||||
|
<th/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in object_list %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ forloop.counter }}.</td>
|
||||||
|
<td>
|
||||||
|
{% if request.is_admin %}
|
||||||
|
<a href="{% url 'work_detail' item.work.collection.pk item.work.pk %}">{{ item.work.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ item.work.name }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="is-hidden-mobile">{% firstof item.work.running_time "------" %}</td>
|
||||||
|
<td class="select-cell">
|
||||||
|
<input type="hidden" name="works" value="{{ item.work.pk }}"/>
|
||||||
|
<span class="select is-small" style="width: 100%">
|
||||||
|
<select name="instruments" id="part-{{ item.work.pk }}" style="width: 100%">
|
||||||
|
<option value='-'>None</option>
|
||||||
|
{% for tag, name in item.work.digital_parts %}
|
||||||
|
<option value='{{ tag }}'>{{ name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="is-action" onclick="downloadPart({{ item.work.collection_id }}, {{ item.work.pk }})">
|
||||||
|
<i class="fas fa-download" title="Download Part"></i>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td/>
|
||||||
|
<td/>
|
||||||
|
<td>{% firstof running_time "------" %}</td>
|
||||||
|
<td/>
|
||||||
|
<td>
|
||||||
|
<!--
|
||||||
|
<button class="button is-link is-small" type="submit"><span class="icon"><i class="fas fa-copy"></i></span><span>Single combined PDF</span></button>
|
||||||
|
<a class="button is-link is-small"><span class="icon"><i class="fas fa-archive"></i></span><span>Individual files (zipped)</span></a>
|
||||||
|
-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="content">
|
||||||
|
This page lets you download a complete set of music for your part by selecting your instrument
|
||||||
|
and optionally a part (e.g. Flute 1 or 2).<br/>
|
||||||
|
You can also tweak which parts you get for each piece, or click on a piece for more download options.
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{{ instruments|json_script:'instruments' }}
|
||||||
|
<script type="text/javascript">
|
||||||
|
|
||||||
|
const INSTRUMENTS = JSON.parse(document.getElementById('instruments').innerText);
|
||||||
|
|
||||||
|
function updateParts() {
|
||||||
|
var inst = document.getElementById("instrument-name").value;
|
||||||
|
window.localStorage.setItem('instrument-name', inst);
|
||||||
|
|
||||||
|
for (let i of INSTRUMENTS) {
|
||||||
|
if (i[1] === inst) inst = i[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let part = document.getElementById("part-preference").value;
|
||||||
|
window.localStorage.setItem('part-preference', part);
|
||||||
|
|
||||||
|
|
||||||
|
selectParts(inst, part);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectParts(inst, part) {
|
||||||
|
|
||||||
|
let prefix = inst + "-" + part;
|
||||||
|
|
||||||
|
let instruments = document.getElementsByName("instruments");
|
||||||
|
for(let i=0; i<instruments.length; i++) {
|
||||||
|
var result = "-"
|
||||||
|
let options = instruments[i].children;
|
||||||
|
for(let j=0; j<options.length; j++) {
|
||||||
|
let value = options[j].value;
|
||||||
|
if (value.startsWith(prefix)) {
|
||||||
|
result = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (result==="-" && value.startsWith(inst)) {
|
||||||
|
result = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
instruments[i].value = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadPart(collection, work) {
|
||||||
|
let part = document.getElementById("part-" + work).value;
|
||||||
|
if (part == '-') {
|
||||||
|
alert("No part selected!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(part);
|
||||||
|
let url = "/collections/" + collection + "/works/" + work + "/download?tag=" + part;
|
||||||
|
window.location = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("instrument-name").value = localStorage.getItem('instrument-name', 'All');
|
||||||
|
document.getElementById("part-preference").value = localStorage.getItem('part-preference', 'Any');
|
||||||
|
updateParts();
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
{% extends "interface/project_base.html" %}
|
||||||
|
|
||||||
|
{% block admin %}
|
||||||
|
<a href="{% url 'item_list_append' project.pk %}" class="button is-link">
|
||||||
|
<span class="icon"><i class="fas fa-plus-circle"></i></span>
|
||||||
|
<span>Add</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" onclick="save()" class="button is-link">
|
||||||
|
<span class="icon"><i class="fas fa-save"></i></span>
|
||||||
|
<span>Save</span>
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page %}
|
||||||
|
<table style="max-width: 600px; margin: 10pt auto;" class="zebra">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Item</th>
|
||||||
|
<th>Time</th>
|
||||||
|
<th/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="work-list">
|
||||||
|
{% for item in object_list %}
|
||||||
|
<tr data-pk="{{ item.pk }}" data-order="{{ forloop.counter }}">
|
||||||
|
<td>{{ item.work.name }}</td>
|
||||||
|
<td>{{ item.work.duration }}</td>
|
||||||
|
<td style="text-align: center;">
|
||||||
|
<i class="fas fa-arrow-up clickable" title="Move up" onclick="moveItem({{ item.pk }}, -1)"></i>
|
||||||
|
<i class="fas fa-arrow-down clickable" title="Move down" onclick="moveItem({{ item.pk }}, 1)"></i>
|
||||||
|
<i class="fas fa-trash clickable" title="Remove" onClick="moveItem({{ item.pk }}, 0)"></i>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="2">No items</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script type="text/javascript">
|
||||||
|
|
||||||
|
let workList = document.getElementById('work-list');
|
||||||
|
var dirty = false;
|
||||||
|
|
||||||
|
function moveItem(pk, dir) {
|
||||||
|
let items = Array.prototype.slice.call(workList.children, 0);
|
||||||
|
var i=0;
|
||||||
|
pk = "" + pk;
|
||||||
|
for(i=0; i<items.length; i++) {
|
||||||
|
if(items[i].dataset.pk === pk) break;
|
||||||
|
}
|
||||||
|
if(i >= items.length) return;
|
||||||
|
|
||||||
|
// check the direction is sensible
|
||||||
|
if (i + dir < 0 || i + dir >= items.length) return;
|
||||||
|
|
||||||
|
if (dir === 0) {
|
||||||
|
items[i].dataset.order = -1;
|
||||||
|
items[i].style = "display: none";
|
||||||
|
} else {
|
||||||
|
items[i].dataset.order = parseInt(items[i].dataset.order) + dir;
|
||||||
|
items[i+dir].dataset.order = parseInt(items[i+dir].dataset.order) - dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.sort((a, b) => parseInt(a.dataset.order) - parseInt(b.dataset.order))
|
||||||
|
|
||||||
|
workList.innerHTML = ""
|
||||||
|
for(let j=0; j<items.length; j++) {
|
||||||
|
workList.appendChild(items[j]);
|
||||||
|
}
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
|
||||||
|
let items = Array.prototype.slice.call(workList.children, 0);
|
||||||
|
let data = {};
|
||||||
|
for(var i=0; i<items.length; i++) {
|
||||||
|
let item = items[i].dataset;
|
||||||
|
data[item.pk] = item.order;
|
||||||
|
}
|
||||||
|
console.log(data);
|
||||||
|
|
||||||
|
fetch("", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json", "X-CSRFToken": "{{ csrf_token }}"},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
}).then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
dirty=false;
|
||||||
|
window.location = "{% url 'item_list' project=project.pk %}";
|
||||||
|
} else {
|
||||||
|
alert("Failed: " + response.statusText)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkSaved(e) {
|
||||||
|
if(dirty) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('beforeunload', checkSaved);
|
||||||
|
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<div class="panel">
|
||||||
|
<p class="panel-heading">
|
||||||
|
Items
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% for item in project.items.all %}
|
||||||
|
{% with work=item.work %}
|
||||||
|
<a class="panel-block" onclick="showDownloadOptions('{% url 'work_parts' work.collection_id work.pk %}')">
|
||||||
|
{{ item.work.name }}
|
||||||
|
</a>
|
||||||
|
{% endwith %}
|
||||||
|
{% empty %}
|
||||||
|
<span class="panel-block">
|
||||||
|
There are no items listed yet for this project - please check back later...
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" id="download-modal">
|
||||||
|
<div class="modal-background" onclick="closeDownloadModal()"></div>
|
||||||
|
<div class="modal-card">
|
||||||
|
<header class="modal-card-head">
|
||||||
|
<p class="modal-card-title">Parts</p>
|
||||||
|
<button class="delete" aria-label="close" onclick="closeDownloadModal()"></button>
|
||||||
|
</header>
|
||||||
|
<section class="modal-card-body" id="download-target">
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
function showDownloadOptions(url) {
|
||||||
|
let target = document.getElementById("download-target");
|
||||||
|
target.innerHTML = "Loading available parts...";
|
||||||
|
|
||||||
|
document.getElementById("download-modal").classList.add('is-active');
|
||||||
|
fetch(url).then((data) => {
|
||||||
|
console.log(data);
|
||||||
|
data.text().then((text) => {
|
||||||
|
target.innerHTML = text;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDownloadModal() {
|
||||||
|
document.getElementById("download-modal").classList.remove('is-active');
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<p class='menu-label'>Library</p>
|
||||||
|
<ul class="menu-list">
|
||||||
|
{% if project %}
|
||||||
|
<li><a href="{% url 'item_list' project=project.pk %}">My Music</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% if collection %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% 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>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{% load path_filters %}
|
||||||
|
<div>
|
||||||
|
<h3>Listing for {{ collection }}</h3>
|
||||||
|
<ul>
|
||||||
|
{% for item in folders %}
|
||||||
|
<li><a href="{% url 'storage_browser_folder' collection=collection.pk folder=item %}">{{ item|basename }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
{% for item in files %}
|
||||||
|
<li>{{ item|basename }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
{% extends 'interface/project_base.html' %}
|
||||||
|
{% load path_filters %}
|
||||||
|
|
||||||
|
{% block media %}
|
||||||
|
<script src="https://unpkg.com/dropzone@5/dist/min/dropzone.min.js"></script>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/dropzone@5/dist/min/dropzone.min.css" type="text/css" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block admin %}
|
||||||
|
<a href="{% url 'work_edit' collection.pk work.pk %}" class="button is-link">
|
||||||
|
<span class="icon"><i class="fas fa-edit"></i></span>
|
||||||
|
<span>Edit</span>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'work_add_to_project' collection.pk work.pk %}" class="button is-link">
|
||||||
|
<span class="icon"><i class="fas fa-plus-circle"></i></span>
|
||||||
|
<span>Add to project</span>
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page %}
|
||||||
|
<h3 class="title">
|
||||||
|
{{ work.name }}
|
||||||
|
{% for tag in work.tags %}
|
||||||
|
<a href="{% url 'collection_work_list' work.collection.pk %}?filter=tag:{{ tag }}" class="tag is-success">{{ tag }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</h3>
|
||||||
|
<p class="subtitle">{% firstof work.composer "Unattributed" %}{% if work.edition %} - {{ work.edition }}{% endif %}</p>
|
||||||
|
<section class="block">
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-half">
|
||||||
|
<p class="block">{{ work.notes }}</p>
|
||||||
|
|
||||||
|
<p class="block">
|
||||||
|
<table class="table">
|
||||||
|
<tr>
|
||||||
|
<th>Location:</th><td><a href="{% url 'collection_work_list' work.collection.pk %}">{{ work.collection }}</a> [{{ work.identifier }}]</td>
|
||||||
|
<th>Orchestration:</th><td>{{ work.orchestration }}</td>
|
||||||
|
</tr><tr>
|
||||||
|
<th>Running time:</th><td>{% firstof work.duration 'Unknown' %}</td>
|
||||||
|
<th>Licence:</th><td>{{ work.get_licence_display }}</td>
|
||||||
|
</tr><tr>
|
||||||
|
<td colspan="4">
|
||||||
|
{% for meta in work.meta %}
|
||||||
|
<a href="{% url 'collection_work_list' work.collection.pk %}?filter={{ meta.name}}:{{ meta.value }}" class="tag" >
|
||||||
|
{{ meta.get_name_display }}:
|
||||||
|
{{ meta.value }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if work.parent %}
|
||||||
|
<p>From <a href="{% url 'work_detail' work.parent.collection.pk work.parent.pk %}">{{ work.parent.name }} - {{ work.parent.composer }}</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if work.related_works.count %}
|
||||||
|
<h3>Related</h3>
|
||||||
|
<ul>
|
||||||
|
{% for related in work.related_works.all %}
|
||||||
|
<li><a href="{% url 'work_detail' related.collection.pk related.pk %}">{{ related.name }} - {{ related.composer }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="column is-half">
|
||||||
|
<div class="box">
|
||||||
|
<h4 class="subtitle is-size-4">
|
||||||
|
<span class="icon"><i class="fas fa-book"></i></span>
|
||||||
|
Printed Parts
|
||||||
|
</h4>
|
||||||
|
<div class="tags">
|
||||||
|
{% for inst, c in work.physical_parts %}
|
||||||
|
<span class="tag is-warning">{{ inst }}{% if c > 1 %} [<strong>{{ c }}</strong>]{% endif %}</span>
|
||||||
|
{% empty %}
|
||||||
|
<p class="is-italic">No printed parts listed</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box">
|
||||||
|
<h4 class="subtitle is-size-4">
|
||||||
|
<span class="icon"><i class="fas fa-print"></i></span>
|
||||||
|
Digital Parts
|
||||||
|
</h4>
|
||||||
|
<div class="tags">
|
||||||
|
{% with sections=work.digital_parts %}
|
||||||
|
{% if sections %}
|
||||||
|
<a class="tag is-danger" href="{% url 'work_partset' collection.pk work.pk %}">Full Set</a>
|
||||||
|
{% endif %}
|
||||||
|
{% for tag, name in sections %}
|
||||||
|
<a class="tag is-info" href="{% url 'work_download' collection.pk work.pk %}?tag={{ tag }}"
|
||||||
|
target="section_{{ tag }}" rel="">{{ name }}</a>
|
||||||
|
{% empty %}
|
||||||
|
<p class="is-italic">No digital parts available</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="block">
|
||||||
|
<div class="box">
|
||||||
|
<div class="level">
|
||||||
|
<h4 class="subtitle is-size-4">
|
||||||
|
<span class="icon"><i class="fas fa-file"></i></span>
|
||||||
|
Files
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<table class="table is-fullwidth">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Filename</th>
|
||||||
|
<th>Sections</th>
|
||||||
|
<th/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="doc-list">
|
||||||
|
{% for doc in work.docs.all %}
|
||||||
|
{% include 'library/document_entry.html' %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% if request.is_admin %}
|
||||||
|
<div class="column is-one-quarter">
|
||||||
|
<h4 class="is-size-5">Add Files</h4>
|
||||||
|
{% if "gdrive" in methods %}
|
||||||
|
<div class="has-text-centered mt-3">
|
||||||
|
<a class="button button-primary" href="{% url 'work_gdrive' collection.pk object.pk %}">Link Google Drive Files</a><br/>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if "upload" in methods %}
|
||||||
|
<form action="{% url 'document_add' collection.pk object.pk %}" class="dropzone" id="doc-upload" style="-moz-user-select: none">
|
||||||
|
{% csrf_token %}
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% if request.is_admin %}
|
||||||
|
<section class="block">
|
||||||
|
<div class="box">
|
||||||
|
<div class="level">
|
||||||
|
<h4 class="is-size-4">
|
||||||
|
<span class="icon"><i class="fas fa-book-reader"></i></span>
|
||||||
|
Loans
|
||||||
|
</h4>
|
||||||
|
<span class="level-right">
|
||||||
|
<a class="icon-text" href="{% url 'work_add_to_project' collection.pk work.pk %}"><span class="icon"><i
|
||||||
|
class="fas fa-plus-circle"></i></span> Checkout</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<table class="table is-fullwidth">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Ensemble</th>
|
||||||
|
<th>Project</th>
|
||||||
|
<th>Checked Out</th>
|
||||||
|
<th>Due Back</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in work.current_loans %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{% url 'ensemble_detail' item.project.ensemble_id %}">
|
||||||
|
{{ item.project.ensemble.name }}
|
||||||
|
</a></td>
|
||||||
|
<td><a href="{% url 'project_detail' item.project.pk %}">{{ item.project.name }}</a></td>
|
||||||
|
<td>{{ item.checkout.date|date:"d/m/Y" }}</td>
|
||||||
|
<td>{{ item.due.date|date:"d/m/Y" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td>No current loans</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
Dropzone.options.docUpload = { // camelized version of the `id`
|
||||||
|
paramName: "upload", // The name that will be used to transfer the file
|
||||||
|
maxFilesize: 50, // MB
|
||||||
|
createImageThumbnails: false,
|
||||||
|
thumbnailWidth: 60,
|
||||||
|
thumbnailHeight: 60,
|
||||||
|
init: function () {
|
||||||
|
this.on("complete", file => {
|
||||||
|
console.log(file);
|
||||||
|
let data = JSON.parse(file.xhr.response);
|
||||||
|
console.log(data);
|
||||||
|
let tbody = document.getElementById('doc-list');
|
||||||
|
let tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = data.entry;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
this.removeFile(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
{% extends "interface/project_base.html" %}
|
||||||
|
{% load url_tools %}
|
||||||
|
|
||||||
|
{% block admin %}
|
||||||
|
{% if collection %}
|
||||||
|
<a href="{% url 'work_add' collection.pk %}" 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="q" type="text" placeholder="Filter" value="{{ request.GET.q }}"/>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<a class="button" href="?"><i class="fas fa-times"></i></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if letters %}
|
||||||
|
<div class="has-text-centered">
|
||||||
|
<a href="?">_</a>
|
||||||
|
{% for letter in letters %}
|
||||||
|
<a href="?start={{ letter }}">{{ letter }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<table class="table is-striped is-fullwidth">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Work</th>
|
||||||
|
<th>Composer</th>
|
||||||
|
<th class="is-hidden-mobile">Edition</th>
|
||||||
|
{% if not collection %}<th class="is-hidden-touch">Collection</th>{% endif %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for work in object_list %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'work_detail' collection=work.collection_id 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 not collection %}<td class="is-hidden-touch">{{ work.collection.name }}</td>{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="4">No works found</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
|
||||||
|
<a class="pagination-previous"
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
href="{% url_update page=page_obj.previous_page_number %}"
|
||||||
|
{% else %}
|
||||||
|
disabled
|
||||||
|
{% endif %}>
|
||||||
|
Previous</a>
|
||||||
|
<a class="pagination-next"
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
href="{% url_update page=page_obj.next_page_number %}"
|
||||||
|
{% else %}
|
||||||
|
disabled
|
||||||
|
{% endif %}>
|
||||||
|
Next
|
||||||
|
</a>
|
||||||
|
<ul class="pagination-list">
|
||||||
|
{% for page in page_range %}
|
||||||
|
<li>
|
||||||
|
{% if page == '...' %}
|
||||||
|
{{ page }}
|
||||||
|
{% else %}
|
||||||
|
<a class="pagination-link {% if page == page_obj.number %}is-current{% endif %}" href="{% url_update page=page %}" aria-label="Goto page {{ page }}">{{ page }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{% if meta %}
|
||||||
|
<div class="has-text-right has-text-grey-light is-size-7">
|
||||||
|
Query="{{ meta.query }}"
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<h3 class="subtitle">{{ work.name }}</h3>
|
||||||
|
<div class="block tags">
|
||||||
|
{% for tag, name in work.digital_parts %}
|
||||||
|
<a class="tag is-info" href="{% url 'work_download' work.collection_id work.pk %}?tag={{ tag }}" target="polyphonic_parts">{{ name }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
<h3 class="subtitle">Files</h3>
|
||||||
|
<table class="table is-narrow is-fullwidth">
|
||||||
|
<tbody>
|
||||||
|
{% for doc in work.pdfs %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ doc.upload.url }}">{{ doc.filename }}</a></td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td>There are no files available for this work</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
-->
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="{% url 'work_detail' work.collection_id work.pk %}">More details...</a>
|
||||||
|
</p>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{% extends "interface/project_base.html" %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{% block page %}
|
||||||
|
<h3 class="subtitle"><a href="{% url 'work_detail' collection.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-centered">
|
||||||
|
<div class="column is-narrow">
|
||||||
|
|
||||||
|
<form action="" method="post" target="_blank">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for tag, name in work.digital_parts %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ name }}</td>
|
||||||
|
<td>
|
||||||
|
<input name="parts" type="hidden" value="{{ tag }}">
|
||||||
|
<input class="input is-small" name="copies" type="number" value="{% if tag == 'score' %}0{% else %}1{% endif %}">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="field is-grouped">
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-link">
|
||||||
|
<span class="icon"><i class="fas fa-print"></i></span>
|
||||||
|
<span>Print Set</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<a class="button is-link is-light" href="{% url 'work_detail' collection.pk object.pk %}">
|
||||||
|
<span>Cancel</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from library.models import Collection, Work
|
||||||
|
from library.indexer import index_works, model_search
|
||||||
|
from library.indexer import whoosh
|
||||||
|
|
||||||
|
JAZZ_STANDARDS = (
|
||||||
|
["But Not For Me", "Gershwin, George & Ira"],
|
||||||
|
["Autumn Leaves", "Kosma, Joseph"],
|
||||||
|
["Best of Gershwin", "Compilation"],
|
||||||
|
)
|
||||||
|
|
||||||
|
CLASSICAL_WORKS = (
|
||||||
|
["Symphony No.5", "Beethoven, L"],
|
||||||
|
["March from Aieda", "Verdi"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WhooshIndexTestCase(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
jazz = Collection.objects.create(name="Jazz Standards", prefix="jazz")
|
||||||
|
classical = Collection.objects.create(
|
||||||
|
name="Classical Music", prefix="classical"
|
||||||
|
)
|
||||||
|
|
||||||
|
for name, composer in JAZZ_STANDARDS:
|
||||||
|
jazz.works.create(name=name, composer=composer)
|
||||||
|
|
||||||
|
for name, composer in CLASSICAL_WORKS:
|
||||||
|
classical.works.create(name=name, composer=composer)
|
||||||
|
|
||||||
|
def test_setup(self):
|
||||||
|
self.assertEqual(Collection.objects.all().count(), 2)
|
||||||
|
self.assertEqual(Work.objects.all().count(), 5)
|
||||||
|
|
||||||
|
def test_indexer(self):
|
||||||
|
|
||||||
|
expected = [
|
||||||
|
("beethoven", [], ["Symph"]),
|
||||||
|
("Ira", [], ["But N"]),
|
||||||
|
("bethoven", [], ["Symph"]),
|
||||||
|
("George", [1], ["But N"]),
|
||||||
|
("George", [2], []),
|
||||||
|
("George", [1, 2], ["But N"]),
|
||||||
|
("not for me", [], ["But N"]),
|
||||||
|
("Gershwin", [], ["Best ", "But N"]),
|
||||||
|
("composer:Gershwin", [], ["But N"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
with TemporaryDirectory() as d:
|
||||||
|
whoosh.index_path = d
|
||||||
|
index_works(Work.objects.all())
|
||||||
|
|
||||||
|
for query, collections, result in expected:
|
||||||
|
self.assertListEqual(
|
||||||
|
[x.name[:5] for x in model_search(query, collections)], result
|
||||||
|
)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user