Compare commits
50 Commits
2023-04-Pr
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e69cdeca4 | |||
| e797876c16 | |||
| 1301d19c08 | |||
| ca3effcad1 | |||
| 5468f6d3e7 | |||
| e46d8145a7 | |||
| 3444cdbc59 | |||
| 6e77474d15 | |||
| 72bad08a61 | |||
| b217fbea5e | |||
| cec64ecd2f | |||
| d30005d5b6 | |||
| ef981e06e8 | |||
| 4cef5800bc | |||
| 78441dc142 | |||
| c96a2ad80b | |||
| 7e0c77f260 | |||
| 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 |
8
.gitignore
vendored
8
.gitignore
vendored
@ -1,10 +1,16 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
*.pyc
|
*.pyc
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
*.swp
|
||||||
credentials.json
|
credentials.json
|
||||||
credentials
|
credentials
|
||||||
local_settings.py
|
local_settings.py
|
||||||
|
local.mk
|
||||||
.coverage
|
.coverage
|
||||||
|
.lint
|
||||||
|
.deploy
|
||||||
|
Session.vim
|
||||||
|
poetry.lock
|
||||||
/env
|
/env
|
||||||
/data
|
/data
|
||||||
/old
|
/old
|
||||||
@ -13,3 +19,5 @@ local_settings.py
|
|||||||
/cache
|
/cache
|
||||||
/local_storage
|
/local_storage
|
||||||
/media
|
/media
|
||||||
|
/index
|
||||||
|
/dist
|
||||||
|
|||||||
28
Dockerfile
28
Dockerfile
@ -1,21 +1,25 @@
|
|||||||
FROM alpine:3.14
|
FROM alpine:latest
|
||||||
|
|
||||||
RUN apk add --no-cache python3 git ghostscript sqlite
|
ENV TARGET=/opt/polyphonic
|
||||||
|
ENV RELEASE=polyphonic-0.8.4-py3-none-any.whl
|
||||||
|
#ENV RELEASE=git+https://gitea.tfconsulting.com.au/projects/polyphonic.git
|
||||||
|
|
||||||
|
RUN apk add --no-cache python3 py3-pip git ghostscript sqlite
|
||||||
|
|
||||||
WORKDIR /root
|
WORKDIR /root
|
||||||
RUN python3 -m ensurepip
|
|
||||||
RUN pip3 install -U pip --no-cache-dir
|
|
||||||
|
|
||||||
COPY app/requirements.txt .
|
RUN python3 -m venv ${TARGET}
|
||||||
RUN pip3 install -r requirements.txt --no-cache-dir
|
ENV PATH="${TARGET}/bin:$PATH"
|
||||||
|
|
||||||
COPY app /opt/polyphonic
|
COPY dist/${RELEASE} .
|
||||||
WORKDIR /opt/polyphonic
|
RUN pip3 install ${RELEASE} --no-cache-dir
|
||||||
|
RUN pip3 install gunicorn whitenoise
|
||||||
|
|
||||||
COPY docker_settings.py polyphonic/local_settings.py
|
WORKDIR ${TARGET}
|
||||||
RUN SECRET_KEY=_ python3 manage.py collectstatic --noinput
|
|
||||||
|
RUN SECRET_KEY=_ poly-tool collectstatic --noinput
|
||||||
|
|
||||||
VOLUME ["/var/polyphonic"]
|
VOLUME ["/var/polyphonic"]
|
||||||
|
EXPOSE 8000/tcp
|
||||||
|
|
||||||
ENTRYPOINT ["python3", "manage.py"]
|
CMD ["gunicorn", "-b", "0.0.0.0", "polyphonic.config.wsgi"]
|
||||||
CMD ["runserver", "0.0.0.0:8000", "--insecure"]
|
|
||||||
|
|||||||
42
Makefile
42
Makefile
@ -1,20 +1,42 @@
|
|||||||
PYTHON=env/bin/python
|
PYTHON=env/bin/python
|
||||||
DROPZONE=5.7.0
|
DROPZONE=5.7.0
|
||||||
|
|
||||||
test:
|
VERSION=0.8.4
|
||||||
coverage run --include "app/*" --omit "*/migrations/*" app/manage.py test app
|
|
||||||
coverage html
|
export DJANGO_SETTINGS_MODULE=polyphonic.config.settings.dev
|
||||||
coverage report
|
|
||||||
|
-include local.mk
|
||||||
|
|
||||||
|
test: .coverage
|
||||||
|
|
||||||
|
check: .lint
|
||||||
|
|
||||||
|
pre-commit: check test
|
||||||
|
|
||||||
|
.coverage: polyphonic
|
||||||
|
poetry run coverage run --include "polyphonic/*" --omit "*/migrations/*" polyphonic/manage.py test polyphonic
|
||||||
|
poetry run coverage html
|
||||||
|
poetry run coverage report
|
||||||
|
|
||||||
|
.lint: polyphonic
|
||||||
|
poetry run ruff check polyphonic
|
||||||
|
poetry run ruff format --check polyphonic
|
||||||
|
touch $@
|
||||||
|
|
||||||
|
|
||||||
|
build: dist/polyphonic-${VERSION}-py3-none-any.whl
|
||||||
|
|
||||||
|
dist/polyphonic-${VERSION}-py3-none-any.whl: polyphonic
|
||||||
|
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
|
||||||
|
|||||||
13
README.md
13
README.md
@ -6,20 +6,23 @@ No registration required for ensemble participants - just a one time code and pa
|
|||||||
|
|
||||||
### Library App
|
### Library App
|
||||||
|
|
||||||
Store all your scores on your own cloud account (Amazon S3, Google Files etc). Tag up the scores so you can generate
|
Store all your scores on your own cloud account (Amazon S3, Google Files etc).
|
||||||
custom part sets and assign them to projects so people can easily print just their parts.
|
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
|
### Submissions App
|
||||||
|
|
||||||
Accept video/audio submissions direct to your cloud storage. Was developed and used during 2020 lockdown period for
|
Accept video/audio submissions direct to your cloud storage. Was developed and
|
||||||
virtual choirs/orchestras but could have more uses.
|
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/">
|
||||||
@ -40,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",
|
||||||
@ -64,3 +68,4 @@ Create with programatic access (copy keys) and an inline policy for the bucket.
|
|||||||
```
|
```
|
||||||
|
|
||||||
3.
|
3.
|
||||||
|
|
||||||
|
|||||||
19
TODO.md
Normal file
19
TODO.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
## Polyphonic TODO
|
||||||
|
|
||||||
|
## Core interface
|
||||||
|
|
||||||
|
* Shift from crispy forms to native component templates
|
||||||
|
* Make long running calls async (Django 5)
|
||||||
|
* Deprecate Django 4 portions
|
||||||
|
|
||||||
|
### Library App
|
||||||
|
|
||||||
|
* Remove music tags and replace with strings vn1 -> 'Violin 1'
|
||||||
|
* GDrive selector
|
||||||
|
* Move upload to modal from 'Upload' button
|
||||||
|
* Tagging app - migrate to AlpineJS
|
||||||
|
* Allow other tags (movements, sections, pieces)
|
||||||
|
|
||||||
|
### Submissions App
|
||||||
|
|
||||||
|
* None currently pending
|
||||||
@ -1,62 +0,0 @@
|
|||||||
from django import forms
|
|
||||||
from crispy_forms.helper import FormHelper
|
|
||||||
from crispy_forms.layout import Submit, HTML, Div
|
|
||||||
from crispy_bulma.layout import FormGroup
|
|
||||||
|
|
||||||
from . import models, fields
|
|
||||||
|
|
||||||
class BaseForm(forms.Form):
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.helper = self.get_form_helper()
|
|
||||||
|
|
||||||
def get_form_helper(self):
|
|
||||||
helper = FormHelper(self)
|
|
||||||
#helper.add_input(Submit('submit', 'Submit', css_class='button is-link'))
|
|
||||||
#helper.layout.subm append(HTML('<a class="button is-light">Cancel</a>'))
|
|
||||||
#print(helper.layout)
|
|
||||||
helper.layout.append(FormGroup(
|
|
||||||
Submit('submit', 'Save', css_class="button is-primary"),
|
|
||||||
HTML('{% if view.cancel_url %}<div class="control"><a href="{{ view.cancel_url }}" class="button is-light">Cancel</a></div>{% endif %}')
|
|
||||||
))
|
|
||||||
return helper
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectForm(forms.ModelForm, BaseForm):
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.Project
|
|
||||||
fields = ['name', 'description', 'modules', 'event_date']
|
|
||||||
#widgets = {
|
|
||||||
# 'event_date': forms.DateTimeInput(attrs={'type': 'date'})
|
|
||||||
#}
|
|
||||||
|
|
||||||
modules = forms.MultipleChoiceField(choices=[ (x, x.title()) for x in models.settings.POLYPHONIC_MODULES ],
|
|
||||||
widget=forms.CheckboxSelectMultiple, required=False)
|
|
||||||
|
|
||||||
class ResourceForm(forms.ModelForm, BaseForm):
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.Resource
|
|
||||||
fields = ['name', 'media_type', 'description', 'file']
|
|
||||||
|
|
||||||
def get_form_helper(self):
|
|
||||||
helper = super().get_form_helper()
|
|
||||||
helper[3].wrap(fields.BulmaFileUpload)
|
|
||||||
return helper
|
|
||||||
|
|
||||||
class WikiForm(forms.ModelForm, BaseForm):
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.WikiPage
|
|
||||||
fields = ['title', 'markdown']
|
|
||||||
|
|
||||||
class CodeForm(BaseForm):
|
|
||||||
code = forms.CharField(max_length=14,
|
|
||||||
widget=forms.TextInput(attrs={'placeholder': 'xxx-xxx-xxx', 'inputmode': 'numeric'}))
|
|
||||||
passphrase = forms.CharField(max_length=32)
|
|
||||||
|
|
||||||
class ResourceUploadForm(forms.Form):
|
|
||||||
pass
|
|
||||||
# file = S3UploadField()
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
from django import template
|
|
||||||
from django.utils import timesince
|
|
||||||
|
|
||||||
register = template.Library()
|
|
||||||
|
|
||||||
def roughtimesince(value):
|
|
||||||
return timesince.timesince(value, depth=1)
|
|
||||||
|
|
||||||
register.filter('roughtimesince', roughtimesince)
|
|
||||||
|
|
||||||
def roughtimeuntil(value):
|
|
||||||
return timesince.timeuntil(value, depth=1)
|
|
||||||
|
|
||||||
register.filter('roughtimeuntil', roughtimeuntil)
|
|
||||||
@ -1,183 +0,0 @@
|
|||||||
from django.test import TestCase, Client
|
|
||||||
|
|
||||||
from interface import models, utils
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
|
|
||||||
from . import AccessTestCase
|
|
||||||
|
|
||||||
class InterfaceAccessTestCase(AccessTestCase):
|
|
||||||
|
|
||||||
USERS = (
|
|
||||||
{'username': 'admin', 'password': 'secret', 'is_superuser': True, 'is_staff': True},
|
|
||||||
{'username': 'homer', 'password': 'maggie'},
|
|
||||||
)
|
|
||||||
|
|
||||||
ENSEMBLES = (
|
|
||||||
{'name': 'The Be Sharps', 'slug': 'be-sharps', 'admins': ['homer']},
|
|
||||||
{'name': 'Lisa & the Bleeding Gums', 'slug': 'bleeding-gums'},
|
|
||||||
{'name': 'Party Posse'},
|
|
||||||
)
|
|
||||||
|
|
||||||
PROJECTS = (
|
|
||||||
{'name': 'Baker St', 'ensemble': 'bleeding-gums', 'when': -12},
|
|
||||||
{'name': 'Navy Recruitment Day', 'ensemble': 'party-posse', 'when': 6},
|
|
||||||
{'name': 'Barbershop Contest', 'ensemble': 'be-sharps', 'when': 28},
|
|
||||||
{'name': 'Open Mic Night', 'ensemble': 'bleeding-gums', 'when': 1 },
|
|
||||||
{'name': 'Current Repertoire', 'ensemble': 'be-sharps'},
|
|
||||||
)
|
|
||||||
|
|
||||||
PROTECTED_URLS = (
|
|
||||||
'/ensembles/be-sharps',
|
|
||||||
'/ensembles/be-sharps/new-project',
|
|
||||||
|
|
||||||
'/projects/3',
|
|
||||||
'/projects/3/resources',
|
|
||||||
'/projects/3/resources/add',
|
|
||||||
|
|
||||||
'/admin/interface/ensemble/',
|
|
||||||
'/admin/interface/project/',
|
|
||||||
'/admin/interface/resource/',
|
|
||||||
'/admin/interface/wikipage/',
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_bad_login(self):
|
|
||||||
with self.assertRaisesMessage(self.failureException, 'Failed to login as admin'):
|
|
||||||
self.login('admin', 'admin')
|
|
||||||
|
|
||||||
def test_superuser_ensembles(self):
|
|
||||||
self.login('admin', 'secret')
|
|
||||||
response = self.client.get('/ensembles')
|
|
||||||
self.assertObjectList(response, ['The Be Sharps', 'Lisa & the Bleeding Gums', 'Party Posse'])
|
|
||||||
self.assertContains(response, 'Django Admin')
|
|
||||||
|
|
||||||
def test_superuser_ensemble_permissions(self):
|
|
||||||
self.login('admin', 'secret')
|
|
||||||
response = self.client.get('/ensembles/party-posse')
|
|
||||||
self.assertTrue(response.context['request'].is_admin)
|
|
||||||
self.assertContains(response, "Add project")
|
|
||||||
self.assertAccess({
|
|
||||||
'/ensembles/be-sharps': True,
|
|
||||||
'/ensembles/bleeding-gums': True,
|
|
||||||
'/ensembles/party-posse': True,
|
|
||||||
'/ensembles/unknown': False,
|
|
||||||
'/ensembles/be-sharps/new-project': True,
|
|
||||||
})
|
|
||||||
|
|
||||||
def test_superuser_projects(self):
|
|
||||||
self.login('admin', 'secret')
|
|
||||||
response = self.client.get('/projects')
|
|
||||||
self.assertObjectList(response, ['Current Repertoire', 'Open Mic Night', 'Navy Recruitment Day', 'Barbershop Contest'])
|
|
||||||
|
|
||||||
self.assertObjectList(self.client.get('/ensembles/bleeding-gums'), ['Open Mic Night'])
|
|
||||||
self.assertObjectList(self.client.get('/ensembles/bleeding-gums?inactive'), ['Open Mic Night', 'Baker St'])
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_ensembles(self):
|
|
||||||
self.login('homer', 'maggie')
|
|
||||||
response = self.client.get('/ensembles')
|
|
||||||
self.assertObjectList(response, ['The Be Sharps'])
|
|
||||||
|
|
||||||
self.assertNotContains(response, 'Django Admin')
|
|
||||||
|
|
||||||
def test_user_ensemble_permissions(self):
|
|
||||||
self.login('homer', 'maggie')
|
|
||||||
response = self.client.get('/ensembles/be-sharps')
|
|
||||||
self.assertTrue(response.context['request'].is_admin)
|
|
||||||
self.assertContains(response, "Add project")
|
|
||||||
self.assertContains(response, 'Show all')
|
|
||||||
self.assertAccess({
|
|
||||||
'/ensembles/be-sharps': True,
|
|
||||||
'/ensembles/bleeding-gums': False,
|
|
||||||
'/ensembles/party-posse': False,
|
|
||||||
'/ensembles/be-sharps/new-project': True,
|
|
||||||
'/ensembles/party-posse/new-project': False,
|
|
||||||
})
|
|
||||||
|
|
||||||
self.authorize(models.Ensemble, slug='bleeding-gums')
|
|
||||||
self.assertAccess({
|
|
||||||
'/ensembles/be-sharps': True,
|
|
||||||
'/ensembles/bleeding-gums': True,
|
|
||||||
'/ensembles/party-posse': False,
|
|
||||||
'/ensembles/be-sharps/new-project': True,
|
|
||||||
'/ensembles/party-posse/new-project': False,
|
|
||||||
})
|
|
||||||
response = self.client.get('/ensembles/bleeding-gums')
|
|
||||||
self.assertFalse(response.context['request'].is_admin)
|
|
||||||
self.assertNotContains(response, 'Add project')
|
|
||||||
self.assertNotContains(response, 'Show all')
|
|
||||||
|
|
||||||
def test_user_projects(self):
|
|
||||||
self.login('homer', 'maggie')
|
|
||||||
response = self.client.get('/projects')
|
|
||||||
self.assertObjectList(response, ['Current Repertoire', 'Barbershop Contest'])
|
|
||||||
response = self.client.get('/projects/3')
|
|
||||||
self.assertTrue(response.context['request'].is_admin)
|
|
||||||
|
|
||||||
|
|
||||||
self.assertAccess({
|
|
||||||
'/projects/3': True,
|
|
||||||
'/projects/3/resources': True,
|
|
||||||
'/projects/3/resources/add': True,
|
|
||||||
'/projects/4': False,
|
|
||||||
'/projects/4/resources': False,
|
|
||||||
'/projects/4/resources/add': False,
|
|
||||||
})
|
|
||||||
|
|
||||||
self.authorize(models.Project, pk=4)
|
|
||||||
response = self.client.get('/projects')
|
|
||||||
self.assertObjectList(response, ['Current Repertoire', 'Open Mic Night', 'Barbershop Contest'])
|
|
||||||
response = self.client.get('/projects/4')
|
|
||||||
self.assertFalse(response.context['request'].is_admin)
|
|
||||||
|
|
||||||
def test_anon_ensembles(self):
|
|
||||||
response = self.client.get('/ensembles')
|
|
||||||
self.assertObjectList(response, [])
|
|
||||||
self.assertContains(response, 'You don\'t currently have access to any ensembles')
|
|
||||||
|
|
||||||
def test_anon_authorized_ensemble(self):
|
|
||||||
self.authorize(models.Ensemble, slug='party-posse')
|
|
||||||
response = self.client.get('/ensembles/party-posse')
|
|
||||||
self.assertContains(response, 'Party Posse')
|
|
||||||
|
|
||||||
response = self.client.get('/ensembles')
|
|
||||||
self.assertObjectList(response, ['Party Posse'])
|
|
||||||
|
|
||||||
self.assertAccess({
|
|
||||||
'/ensembles/be-sharps': False,
|
|
||||||
'/ensembles/party-posse': True,
|
|
||||||
'/ensembles/bleeding-gums': False,
|
|
||||||
'/ensembles/unknown': False,
|
|
||||||
})
|
|
||||||
response = self.client.get('/projects')
|
|
||||||
self.assertObjectList(response, ['Navy Recruitment Day'])
|
|
||||||
|
|
||||||
def test_anon_authorized_project(self):
|
|
||||||
self.authorize(models.Project, pk=4)
|
|
||||||
self.assertObjectList(self.client.get('/projects'), ['Open Mic Night'])
|
|
||||||
self.assertObjectList(self.client.get('/ensembles'), ['Lisa & the Bleeding Gums'])
|
|
||||||
|
|
||||||
self.assertAccess({
|
|
||||||
'/projects/4': True,
|
|
||||||
'/projects/4/resources': True,
|
|
||||||
'/projects/1': False,
|
|
||||||
'/projects/1/resources': False,
|
|
||||||
})
|
|
||||||
|
|
||||||
def test_anon_permission_denied(self):
|
|
||||||
self.assertAccess({
|
|
||||||
'/ensembles': True,
|
|
||||||
'/ensembles/be-sharps': False,
|
|
||||||
'/ensembles/party-posse': False,
|
|
||||||
'/ensembles/bleeding-gums': False,
|
|
||||||
'/ensembles/unknown': False,
|
|
||||||
})
|
|
||||||
|
|
||||||
def test_anon_deauthorize_project(self):
|
|
||||||
self.authorize(models.Project, pk=4)
|
|
||||||
self.assertAccess({
|
|
||||||
'/projects/4': True
|
|
||||||
})
|
|
||||||
models.Project.objects.filter(pk=4).update(nonce=2)
|
|
||||||
self.assertAccess({
|
|
||||||
'/projects/4': False
|
|
||||||
})
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
from django.urls import path
|
|
||||||
from django.contrib.auth import views as auth_views
|
|
||||||
from django.views.generic.base import RedirectView
|
|
||||||
|
|
||||||
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', auth_views.LogoutView.as_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"),
|
|
||||||
]
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
if settings.DEBUG:
|
|
||||||
from django.views.static import serve
|
|
||||||
urlpatterns.append(path('local_storage/<path:path>', serve, {'document_root': 'local_storage'}))
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
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())
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
from django.core.management.base import BaseCommand, CommandError
|
|
||||||
import argparse
|
|
||||||
import csv
|
|
||||||
|
|
||||||
from library import models
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = 'Imports works from a csv file'
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument('collection', type=int, help="Collection ID")
|
|
||||||
parser.add_argument('source', type=argparse.FileType('r'), help="Source CSV")
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
|
|
||||||
collection = models.Collection.objects.get(pk=options['collection'])
|
|
||||||
|
|
||||||
reader = csv.DictReader(options['source'])
|
|
||||||
for row in reader:
|
|
||||||
collection.works.create(name=row['Piece'], composer=row['Composer'], notes=row['Notes'])
|
|
||||||
@ -1,408 +0,0 @@
|
|||||||
from os import SCHED_OTHER
|
|
||||||
from django.conf import settings
|
|
||||||
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.core.files.storage import get_storage_class
|
|
||||||
from django.db.models import Q, Count, Min, Max
|
|
||||||
|
|
||||||
import re
|
|
||||||
import os.path
|
|
||||||
|
|
||||||
from byostorage.user import BYOStorage
|
|
||||||
from byostorage.cached import CachedStorage
|
|
||||||
|
|
||||||
from library.music_tags import MusicTag
|
|
||||||
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.SlugField(max_length=30, default="default",
|
|
||||||
help_text="Folder to store works in")
|
|
||||||
administrators = models.ManyToManyField('auth.User', related_name="collections",
|
|
||||||
help_text="Administrators for this collection")
|
|
||||||
location = models.CharField(max_length=100,
|
|
||||||
help_text="Physical location (institution, town...)", blank=True)
|
|
||||||
storage = models.ForeignKey('byostorage.UserStorage', on_delete=models.CASCADE, null=True, blank=True,
|
|
||||||
help_text="User storage for documents")
|
|
||||||
notes = models.TextField(blank=True,
|
|
||||||
help_text="Publicly visible notes about collection and loans policy (markdown format)")
|
|
||||||
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('[^\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 not x[0] 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'),
|
|
||||||
)
|
|
||||||
|
|
||||||
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 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
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
{% 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="filter" type="text" placeholder="Filter" value="{{ request.GET.filter }}"/>
|
|
||||||
</div>
|
|
||||||
<div class="control">
|
|
||||||
<a class="button" href="?"><i class="fas fa-times"></i></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<table class="table is-striped is-fullwidth">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Work</th>
|
|
||||||
<th>Composer</th>
|
|
||||||
<th class="is-hidden-mobile">Edition</th>
|
|
||||||
{% if 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.pk 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>
|
|
||||||
|
|
||||||
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
|
|
||||||
{% if page_obj.has_previous %}
|
|
||||||
<a class="pagination-previous" href="{% url_update page=page_obj.prev_page_number %}">Previous</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if page_obj.has_next %}
|
|
||||||
<a class="pagination-next" href="{% url_update page=page_obj.next_page_number %}">Next page</a>
|
|
||||||
{% endif %}
|
|
||||||
<ul class="pagination-list">
|
|
||||||
{% for page in page_obj.paginator.page_range %}
|
|
||||||
<li>
|
|
||||||
<a class="pagination-link {% if forloop.counter == page_obj.number %}is-current{% endif %}" href="{% url_update page=forloop.counter %}" aria-label="Goto page {{ forloop.counter }}">{{ forloop.counter }}</a>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,176 +0,0 @@
|
|||||||
from interface.tests import AccessTestCase
|
|
||||||
|
|
||||||
from byostorage.user import UserStorage
|
|
||||||
from . import models
|
|
||||||
from .views.api import WorkSerializer
|
|
||||||
|
|
||||||
import tempfile
|
|
||||||
import json
|
|
||||||
|
|
||||||
class LibraryTestCase(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 },
|
|
||||||
)
|
|
||||||
|
|
||||||
COLLECTIONS = (
|
|
||||||
{'name': 'Springfield Elementary Library', 'prefix': 'sel'},
|
|
||||||
{'name': 'Neds Library', 'prefix': 'ned', 'admins': ['homer']},
|
|
||||||
)
|
|
||||||
|
|
||||||
WORKS = (
|
|
||||||
{'name': 'Baby on Board', 'collection': 'ned', 'docs': [{'upload': 'local:baby_on_board.pdf'}]},
|
|
||||||
{'name': 'Star Spangled Banner', 'collection': 'sel'},
|
|
||||||
)
|
|
||||||
|
|
||||||
PROTECTED_URLS = (
|
|
||||||
'/collections/1',
|
|
||||||
'/collections/1/add',
|
|
||||||
'/collections/2/works/1',
|
|
||||||
'/collections/2/works/1/edit',
|
|
||||||
'/collections/2/works/1/partset',
|
|
||||||
'/collections/2/works/1/add_to_project',
|
|
||||||
'/collections/2/works/1/upload',
|
|
||||||
'/collections/2/docs/1/annotate',
|
|
||||||
|
|
||||||
# Need to add storage before we can test these
|
|
||||||
'/api/collections/2',
|
|
||||||
'/api/collections/2/works/1',
|
|
||||||
|
|
||||||
'/admin/library/collection/',
|
|
||||||
'/admin/library/document/',
|
|
||||||
'/admin/library/ensembleaccess/',
|
|
||||||
'/admin/library/orchestration/',
|
|
||||||
'/admin/library/projectitem/',
|
|
||||||
'/admin/library/work/',
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setUpTestData(cls):
|
|
||||||
super().setUpTestData()
|
|
||||||
|
|
||||||
cls.temp_dir = tempfile.TemporaryDirectory()
|
|
||||||
cls.storage = UserStorage.objects.create(name='local', storage='django.core.files.storage.FileSystemStorage',
|
|
||||||
settings_data=json.dumps({'location': cls.temp_dir.name, 'base_url': 'file://' + cls.temp_dir.name}))
|
|
||||||
cls.collections = {}
|
|
||||||
for details in cls.COLLECTIONS:
|
|
||||||
admins = details.pop('admins', [])
|
|
||||||
obj = models.Collection.objects.create(storage=cls.storage, **details)
|
|
||||||
for admin in admins:
|
|
||||||
obj.administrators.add(cls.users[admin])
|
|
||||||
cls.collections[details['prefix']] = obj
|
|
||||||
|
|
||||||
cls.works = {}
|
|
||||||
for details in cls.WORKS:
|
|
||||||
collection = cls.collections[details.pop('collection')]
|
|
||||||
#details.setdefault('docs', [])
|
|
||||||
#details.setdefault('meta_info', [])
|
|
||||||
#s = WorkSerializer(data=details)
|
|
||||||
#assert s.is_valid(), s.errors
|
|
||||||
#s.save(collection_id=collection.pk)
|
|
||||||
docs = details.pop('docs', [])
|
|
||||||
obj = models.Work.objects.create(collection=collection, **details)
|
|
||||||
for doc in docs:
|
|
||||||
obj.docs.create(**doc)
|
|
||||||
cls.works[details['name']] = obj
|
|
||||||
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def tearDownClass(cls):
|
|
||||||
cls.temp_dir.cleanup()
|
|
||||||
|
|
||||||
|
|
||||||
def test_integration(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_superuser_access(self):
|
|
||||||
self.login('admin', 'secret')
|
|
||||||
self.assertAccess({
|
|
||||||
'/collections': True,
|
|
||||||
'/collections/1': True,
|
|
||||||
'/collections/2/works/1': True,
|
|
||||||
})
|
|
||||||
|
|
||||||
def test_administrator_access(self):
|
|
||||||
self.login('homer', 'maggie')
|
|
||||||
self.assertAccess({
|
|
||||||
'/collections': True,
|
|
||||||
'/collections/1': False,
|
|
||||||
'/collections/2': True,
|
|
||||||
'/collections/2/works/1': True,
|
|
||||||
})
|
|
||||||
|
|
||||||
def test_link_access(self):
|
|
||||||
self.assertAccess({
|
|
||||||
'/collections': True,
|
|
||||||
'/collections/1': False,
|
|
||||||
'/collections/2': False,
|
|
||||||
'/collections/2/works/1': False,
|
|
||||||
})
|
|
||||||
|
|
||||||
self.authorize(models.Collection, pk=2)
|
|
||||||
self.assertAccess({
|
|
||||||
'/collections': True,
|
|
||||||
'/collections/1': False,
|
|
||||||
'/collections/2': True,
|
|
||||||
'/collections/2/works/1': True,
|
|
||||||
})
|
|
||||||
|
|
||||||
def test_anon_access(self):
|
|
||||||
self.assertAccess({
|
|
||||||
'/collections': True,
|
|
||||||
'/collections/1': False,
|
|
||||||
'/collections/2': False,
|
|
||||||
'/collections/2/works/1': False,
|
|
||||||
})
|
|
||||||
|
|
||||||
def test_export_and_import(self):
|
|
||||||
self.login('admin', 'secret')
|
|
||||||
data = self.client.get('/api/collections/1/works/2', HTTP_ACCEPT="application/json").json()
|
|
||||||
response = self.client.post('/api/collections/2/import', data, "application/json")
|
|
||||||
self.assertEqual(response.status_code, 201)
|
|
||||||
|
|
||||||
def test_movement_from_large_work(self):
|
|
||||||
'''
|
|
||||||
Will be common to store a work which has several movements, but the project is only going to play one.
|
|
||||||
This also should give us the ability to store an anthology as one Work have Project reference 'no:23'
|
|
||||||
'''
|
|
||||||
|
|
||||||
work = self.collections['sel'].works.create(name="Some Quartet", composer="Beethoven")
|
|
||||||
for g in ('vl-1', 'vl-2', 'vla', 'vc'):
|
|
||||||
doc = work.docs.create(upload=f'sel/beethoven/some_quartet/some_quartet_{g}.pdf')
|
|
||||||
doc.sections.create(tag='mvmt-1', start=1, end=3)
|
|
||||||
doc.sections.create(tag='mvmt-2', start=4, end=8)
|
|
||||||
doc.sections.create(tag='mvmt-3', start=9, end=12)
|
|
||||||
doc.sections.create(tag=g)
|
|
||||||
|
|
||||||
# no tags - get nothing (should it be everything?)
|
|
||||||
self.assertEqual(work.list_sections(), [])
|
|
||||||
|
|
||||||
# single tag - should get just that range
|
|
||||||
self.assertEqual(work.list_sections('vl-1'), [('sel/beethoven/some_quartet/some_quartet_vl-1.pdf', None, None)])
|
|
||||||
|
|
||||||
# single tag - returns all documents with that range
|
|
||||||
result = work.list_sections('mvmt-2')
|
|
||||||
self.assertEqual(len(result), 4)
|
|
||||||
|
|
||||||
# multiple tags - returns the overlapping portion of all documents that have all tags
|
|
||||||
self.assertEqual(work.list_sections('vl-1', 'mvmt-2'), [('sel/beethoven/some_quartet/some_quartet_vl-1.pdf', 4, 8)])
|
|
||||||
self.assertEqual(work.list_sections('vl-1', 'vl-2'), [])
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
from django.urls import path, include
|
|
||||||
from django.contrib.auth import views as auth_views
|
|
||||||
from rest_framework import routers
|
|
||||||
|
|
||||||
from . import views
|
|
||||||
|
|
||||||
from library.views import api
|
|
||||||
|
|
||||||
#router = routers.DefaultRouter()
|
|
||||||
#router.register(r'collection', external.CollectionViewSet, basename="collection")
|
|
||||||
#router.register(r'work', external.WorkViewSet, basename="work")
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
|
|
||||||
path('projects/<int:project>/items', views.ProjectItemListView.as_view(), name="item_list"),
|
|
||||||
path('projects/<int:project>/items/manage', views.ProjectItemManageView.as_view(), name="item_list_manage"),
|
|
||||||
path('projects/<int:project>/items/append', views.ProjectItemAddView.as_view(), name="item_list_append"),
|
|
||||||
|
|
||||||
path('library', views.LibraryWorkListView.as_view(), name="work_list"),
|
|
||||||
|
|
||||||
path('collections', views.CollectionListView.as_view(), name="collection_list"),
|
|
||||||
path('collections/<int:collection>', views.CollectionWorkListView.as_view(), name="collection_work_list"),
|
|
||||||
path('collections/<int:collection>/add', views.WorkAddView.as_view(), name="work_add"),
|
|
||||||
|
|
||||||
path('collections/<int:collection>/works/<int:pk>', views.WorkDetailView.as_view(), name="work_detail"),
|
|
||||||
path('collections/<int:collection>/works/<int:pk>/edit', views.WorkUpdateView.as_view(), name="work_edit"),
|
|
||||||
path('collections/<int:collection>/works/<int:pk>/partset', views.WorkPartSetView.as_view(), name="work_partset"),
|
|
||||||
path('collections/<int:collection>/works/<int:pk>/parts', views.WorkPartsView.as_view(), name="work_parts"),
|
|
||||||
path('collections/<int:collection>/works/<int:pk>/add_to_project', views.WorkAddToProject.as_view(), name="work_add_to_project"),
|
|
||||||
path('collections/<int:collection>/works/<int:pk>/upload', views.WorkAddDocumentView.as_view(), name="document_add"),
|
|
||||||
path('collections/<int:collection>/works/<int:pk>/download', views.WorkDownloadView.as_view(), name="work_download"),
|
|
||||||
|
|
||||||
path('collections/<int:collection>/docs/<int:pk>/delete', views.DocumentDeleteView.as_view(), name="document_delete"),
|
|
||||||
path('collections/<int:collection>/docs/<int:pk>/download', views.DocumentDownloadView.as_view(), name="document_download"),
|
|
||||||
path('collections/<int:collection>/docs/<int:pk>/annotate', views.DocumentAnnotateView.as_view(), name="document_annotate"),
|
|
||||||
|
|
||||||
path('collections/<int:collection>/download/<int:section>/<str:filename>', views.PartDownloadView.as_view(), name="part_download"),
|
|
||||||
|
|
||||||
#path('api/', include(router.urls))
|
|
||||||
path('api/collections/<int:pk>', api.CollectionExportView.as_view(), name="collection_export"),
|
|
||||||
path('api/collections/<int:collection>/works/<int:pk>', api.WorkExportView.as_view(), name="work_export"),
|
|
||||||
path('api/collections/<int:collection>/import', api.WorkImportView.as_view(), name="work_import"),
|
|
||||||
path('api/collections/<int:collection>/bulk_import', api.CollectionImportView.as_view(), name="collection_import"),
|
|
||||||
]
|
|
||||||
@ -1,482 +0,0 @@
|
|||||||
from django.shortcuts import get_object_or_404, redirect, resolve_url
|
|
||||||
from django.views.generic.detail import DetailView, SingleObjectMixin, View
|
|
||||||
from django.views.generic.list import ListView, MultipleObjectMixin
|
|
||||||
from django.views.generic.edit import CreateView, FormView, UpdateView, DeleteView
|
|
||||||
from django.http import FileResponse, HttpResponse, JsonResponse
|
|
||||||
from django.db import IntegrityError
|
|
||||||
from django.db.models import Q, Count, Sum
|
|
||||||
from django.db import transaction
|
|
||||||
from django.utils.timezone import now
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.template.loader import render_to_string
|
|
||||||
from django.core.exceptions import SuspiciousOperation
|
|
||||||
from django.http import Http404, HttpResponseRedirect
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os.path
|
|
||||||
import re
|
|
||||||
|
|
||||||
from interface.views import EnsembleMixin, ProjectMixin, AuthorizedResourceMixin
|
|
||||||
from interface.models import Project
|
|
||||||
from library.models import Collection, Work, Document, Section
|
|
||||||
from library.music_tags import MUSIC_TAGS, MusicTag, auto_tag
|
|
||||||
from library import forms, models
|
|
||||||
from library.pdf_utils import extract_pages, extract_and_concat
|
|
||||||
|
|
||||||
import logging
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
class ProjectItemListView(ProjectMixin, ListView):
|
|
||||||
template_name = "library/item_list.html"
|
|
||||||
model = models.ProjectItem
|
|
||||||
|
|
||||||
def post(self, request, **kwargs):
|
|
||||||
|
|
||||||
project_works = self.project.works.all()
|
|
||||||
|
|
||||||
instruments = request.POST.getlist('instruments')
|
|
||||||
works = request.POST.getlist('works')
|
|
||||||
|
|
||||||
self.request.session['part'] = request.POST.get('part', '')
|
|
||||||
self.request.session['instrument'] = request.POST.get('instrument')
|
|
||||||
|
|
||||||
valid_pks = [ x.pk for x in project_works ]
|
|
||||||
|
|
||||||
sections = []
|
|
||||||
|
|
||||||
for i, pk in enumerate(works):
|
|
||||||
|
|
||||||
if int(pk) not in valid_pks:
|
|
||||||
raise Exception(f"Not a valid work pk: {pk}")
|
|
||||||
|
|
||||||
tag = instruments[i]
|
|
||||||
if tag == '-':
|
|
||||||
continue
|
|
||||||
|
|
||||||
part = Section.objects.filter(tag=tag, doc__work=pk).select_related('doc').get()
|
|
||||||
sections.append((part.doc.upload.path, part.doc.work.name, part.start, part.end, 1))
|
|
||||||
|
|
||||||
result = extract_and_concat(sections)
|
|
||||||
|
|
||||||
download_name = f'{self.project.name}.pdf'
|
|
||||||
|
|
||||||
response = FileResponse(result, content_type="application/pdf")
|
|
||||||
response['Content-Disposition'] = f'inline; filename="{download_name}"'
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return super(ProjectItemListView, self).get_queryset().select_related('project', 'work')
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
|
||||||
data = super(ProjectItemListView, self).get_context_data(**kwargs)
|
|
||||||
data['instruments'] = MUSIC_TAGS
|
|
||||||
data['instrument'] = self.request.session.get('instrument', 'Score')
|
|
||||||
data['part'] = self.request.session.get('part', '0')
|
|
||||||
data['running_time'] = self.get_queryset().aggregate(Sum('work__running_time'))['work__running_time__sum']
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectItemManageView(ProjectMixin, ListView):
|
|
||||||
template_name = "library/item_list_manage.html"
|
|
||||||
model = models.ProjectItem
|
|
||||||
|
|
||||||
def post(self, request, **kwargs):
|
|
||||||
self.request = request
|
|
||||||
self.kwargs = kwargs
|
|
||||||
|
|
||||||
data = json.loads(request.body)
|
|
||||||
|
|
||||||
q = self.get_queryset()
|
|
||||||
for pk, order in data.items():
|
|
||||||
order = int(order)
|
|
||||||
if order == -1:
|
|
||||||
q.filter(pk=pk).delete()
|
|
||||||
else:
|
|
||||||
i = q.filter(pk=pk).update(order=order)
|
|
||||||
|
|
||||||
return HttpResponse(status=204)
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return super(ProjectItemManageView, self).get_queryset().select_related('project', 'work')
|
|
||||||
|
|
||||||
class ProjectItemAddView(ProjectMixin, UpdateView):
|
|
||||||
form_class = forms.PlaylistAddForm
|
|
||||||
template_name = "interface/default_form.html"
|
|
||||||
|
|
||||||
def get_success_url(self):
|
|
||||||
return resolve_url('item_list_manage', project=self.kwargs['project'])
|
|
||||||
|
|
||||||
def get_object(self):
|
|
||||||
return self.get_project()
|
|
||||||
|
|
||||||
""" COLLECTION VIEWS """
|
|
||||||
|
|
||||||
class CollectionMixin(AuthorizedResourceMixin):
|
|
||||||
|
|
||||||
collection = None
|
|
||||||
|
|
||||||
def is_authorized(self):
|
|
||||||
collection_id = self.kwargs['collection']
|
|
||||||
self.collection = get_object_or_404(models.Collection, pk=collection_id)
|
|
||||||
|
|
||||||
if super().is_authorized():
|
|
||||||
return True
|
|
||||||
|
|
||||||
if self.collection.has_administrator(self.request.user):
|
|
||||||
self.request.is_admin = True
|
|
||||||
return True
|
|
||||||
|
|
||||||
if self.is_authorized_key('collection', collection_id, self.collection.nonce):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
|
||||||
data = super().get_context_data(**kwargs)
|
|
||||||
if self.collection:
|
|
||||||
data['collection'] = self.collection
|
|
||||||
return data
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return super().get_queryset().filter(collection=self.collection)
|
|
||||||
|
|
||||||
|
|
||||||
class CollectionListView(ListView):
|
|
||||||
paginate_by = 20
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
collections = models.Collection.objects.order_by('name')
|
|
||||||
|
|
||||||
if self.request.user.is_anonymous:
|
|
||||||
return models.Collection.objects.none()
|
|
||||||
|
|
||||||
if self.request.user.is_staff:
|
|
||||||
return collections
|
|
||||||
|
|
||||||
return collections.filter(Q(administrators=self.request.user) | Q(allowed_ensembles__ensemble__admins=self.request.user))
|
|
||||||
|
|
||||||
class WorkListView(CollectionMixin, ListView):
|
|
||||||
paginate_by = 20
|
|
||||||
|
|
||||||
def get_context_data(self, *args, **kwargs):
|
|
||||||
data = super(WorkListView, self).get_context_data(*args, **kwargs)
|
|
||||||
#data['title'] = f'Music available to {self.ensemble.name}'
|
|
||||||
data['title'] = "My Library"
|
|
||||||
return data
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
works = self.get_works()
|
|
||||||
|
|
||||||
q = self.request.GET.get('filter')
|
|
||||||
if q:
|
|
||||||
if ":" in q:
|
|
||||||
name, _, value = q.partition(":")
|
|
||||||
works = works.filter(meta_info__name=name, meta_info__value__contains=value)
|
|
||||||
else:
|
|
||||||
works = works.filter(Q(name__contains=q) | Q(composer__contains=q) | Q(meta_info__value__contains=q))
|
|
||||||
|
|
||||||
return works.order_by('name', 'composer', 'edition', 'pk').distinct()
|
|
||||||
|
|
||||||
class LibraryWorkListView(WorkListView):
|
|
||||||
|
|
||||||
def is_authorized(self):
|
|
||||||
return True
|
|
||||||
|
|
||||||
def get_works(self):
|
|
||||||
collections = models.Collection.objects.all()
|
|
||||||
|
|
||||||
if not self.request.user.is_superuser:
|
|
||||||
collections = collections.filter(administrators=self.request.user)
|
|
||||||
|
|
||||||
return Work.objects.filter(collection__in=collections).select_related('collection')
|
|
||||||
|
|
||||||
class CollectionWorkListView(WorkListView):
|
|
||||||
|
|
||||||
def request_denied(self):
|
|
||||||
if 'auth' in self.request.GET:
|
|
||||||
if self.request.GET['auth'] != self.collection.auth():
|
|
||||||
raise SuspiciousOperation("Bad collection link")
|
|
||||||
self.add_authorized_key('collection', self.collection.pk, self.collection.nonce)
|
|
||||||
return HttpResponseRedirect(self.request.path)
|
|
||||||
return super().request_denied()
|
|
||||||
|
|
||||||
def get_works(self):
|
|
||||||
works = Work.objects.filter(collection=self.kwargs['collection'])
|
|
||||||
|
|
||||||
#if self.request.is_admin:
|
|
||||||
# loan_count = Count('project_items', Q(project_items__checkout__lte=now(), project_items__returned=None))
|
|
||||||
# works = works.annotate(loan_count=loan_count)
|
|
||||||
return works
|
|
||||||
|
|
||||||
def get_context_data(self, *args, **kwargs):
|
|
||||||
data = super(CollectionWorkListView, self).get_context_data(*args, **kwargs)
|
|
||||||
data['title'] = self.collection.name
|
|
||||||
return data
|
|
||||||
|
|
||||||
class WorkAddView(CollectionMixin, FormView):
|
|
||||||
template_name = "interface/default_form.html"
|
|
||||||
form_class = forms.WorkCreateForm
|
|
||||||
|
|
||||||
title = "Add a new work"
|
|
||||||
|
|
||||||
def get_form(self, form_class=None):
|
|
||||||
form = super().get_form(form_class)
|
|
||||||
qs = models.Orchestration.objects.filter(Q(collection=None) | Q(collection=self.collection))
|
|
||||||
form.fields['orchestration'].queryset = qs.order_by('-collection_id', 'pk')
|
|
||||||
return form
|
|
||||||
|
|
||||||
def form_valid(self, form):
|
|
||||||
work = form.save(commit=False)
|
|
||||||
#work.ensemble_id = self.request.ensemble_id
|
|
||||||
work.collection_id = self.collection.pk
|
|
||||||
work.save()
|
|
||||||
|
|
||||||
# handle the files
|
|
||||||
uploads = self.request.FILES.getlist('uploads')
|
|
||||||
docs = []
|
|
||||||
for f in uploads:
|
|
||||||
docs.append(work.docs.create(upload=f).pk)
|
|
||||||
|
|
||||||
if len(docs) == 1:
|
|
||||||
return redirect('document_annotate', docs[0])
|
|
||||||
else:
|
|
||||||
return redirect('work_detail', collection=self.collection.pk, pk=work.pk)
|
|
||||||
|
|
||||||
class WorkDetailView(CollectionMixin, DetailView):
|
|
||||||
model = models.Work
|
|
||||||
|
|
||||||
class WorkUpdateView(CollectionMixin, UpdateView):
|
|
||||||
model = models.Work
|
|
||||||
form_class = forms.WorkCreateForm
|
|
||||||
template_name = 'interface/default_form.html'
|
|
||||||
|
|
||||||
def get_success_url(self):
|
|
||||||
return resolve_url('work_detail', self.collection.pk, self.kwargs['pk'])
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class WorkAddToProject(CollectionMixin, FormView):
|
|
||||||
admin_required = True
|
|
||||||
form_class = forms.ProjectSelectForm
|
|
||||||
template_name = "interface/default_form.html"
|
|
||||||
title = "Select project to add work to"
|
|
||||||
|
|
||||||
def get_object(self):
|
|
||||||
return Work.objects.get(pk=self.kwargs['pk'])
|
|
||||||
|
|
||||||
def get_form(self):
|
|
||||||
f = super(WorkAddToProject, self).get_form()
|
|
||||||
qs = f.fields['project'].queryset.select_related('ensemble')
|
|
||||||
|
|
||||||
# Limit to projects for ensembles where we are an admin and they haven't occured yet
|
|
||||||
qs = qs.for_user(self.request.user).current()
|
|
||||||
|
|
||||||
# dont show projects already added to
|
|
||||||
work = self.get_object()
|
|
||||||
qs = qs.exclude(pk__in=work.projects.all())
|
|
||||||
|
|
||||||
f.fields['project'].queryset = qs.order_by('ensemble__name', 'name')
|
|
||||||
return f
|
|
||||||
|
|
||||||
def form_valid(self, form):
|
|
||||||
work = self.get_object()
|
|
||||||
project = form.cleaned_data['project']
|
|
||||||
work.project_items.create(project=project, approved_by=self.request.user, checkout=now())
|
|
||||||
return redirect('item_list', project=project.pk)
|
|
||||||
|
|
||||||
class WorkPartsView(CollectionMixin, DetailView):
|
|
||||||
model = models.Work
|
|
||||||
template_name = "library/work_parts_fragment.html"
|
|
||||||
|
|
||||||
class WorkPartSetView(CollectionMixin, DetailView):
|
|
||||||
template_name = "library/work_partset.html"
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
|
|
||||||
work = self.get_object()
|
|
||||||
|
|
||||||
parts = request.POST.getlist('parts')
|
|
||||||
copies = request.POST.getlist('copies')
|
|
||||||
|
|
||||||
sections = []
|
|
||||||
|
|
||||||
for i, tag in enumerate(parts):
|
|
||||||
c = int(copies[i])
|
|
||||||
if c > 0:
|
|
||||||
for part in models.Section.objects.select_related('doc').filter(tag=tag, doc__work=work):
|
|
||||||
sections.append((part.doc.upload.path, part.name, part.start, part.end, c))
|
|
||||||
|
|
||||||
result = extract_and_concat(sections)
|
|
||||||
|
|
||||||
download_name = f'{work.name}.pdf'
|
|
||||||
|
|
||||||
response = FileResponse(result, content_type="application/pdf")
|
|
||||||
response['Content-Disposition'] = f'inline; filename="{download_name}"'
|
|
||||||
return response
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
works = Work.objects.all()
|
|
||||||
|
|
||||||
if not self.request.is_admin:
|
|
||||||
works = works.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id)
|
|
||||||
return works
|
|
||||||
|
|
||||||
class WorkDownloadView(CollectionMixin, SingleObjectMixin, View):
|
|
||||||
model = models.Work
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
self.object = self.get_object()
|
|
||||||
|
|
||||||
tags = request.GET.getlist('tag')
|
|
||||||
|
|
||||||
if not tags:
|
|
||||||
raise Http404("No tags given")
|
|
||||||
|
|
||||||
sections = list(self.object.tagged_sections(*tags))
|
|
||||||
print(sections)
|
|
||||||
|
|
||||||
if len(sections) == 0:
|
|
||||||
raise Http404("No matching sections")
|
|
||||||
|
|
||||||
if len(sections) == 1 and sections[0].start == 0:
|
|
||||||
# bypass extraction and redirect to the url
|
|
||||||
logger.debug("Redirecting to url")
|
|
||||||
return redirect(sections[0].upload.url)
|
|
||||||
|
|
||||||
result = extract_and_concat([ (s.upload.path, s.upload.name, s.start, s.end, 1) for s in sections ])
|
|
||||||
|
|
||||||
tag_names = " - ".join([ str(MusicTag.from_tag(tag)) for tag in tags ])
|
|
||||||
|
|
||||||
download_name = f'{self.object.name} - {tag_names}.pdf'
|
|
||||||
|
|
||||||
response = FileResponse(result, content_type="application/pdf")
|
|
||||||
response['Content-Disposition'] = f'inline; filename="{download_name}"'
|
|
||||||
return response
|
|
||||||
|
|
||||||
class WorkAddDocumentView(CollectionMixin, CreateView):
|
|
||||||
template_name = "interface/default_form.html"
|
|
||||||
model = Document
|
|
||||||
fields = ['upload']
|
|
||||||
|
|
||||||
def title(self):
|
|
||||||
work = Work.objects.get(pk=self.kwargs['pk'])
|
|
||||||
return f"Add a document to {work.name}"
|
|
||||||
|
|
||||||
def form_invalid(self, form):
|
|
||||||
if self.request.headers['Accept'] == 'application/json':
|
|
||||||
return HttpResponse(status=400)
|
|
||||||
return super().form_invalid(form)
|
|
||||||
|
|
||||||
def form_valid(self, form):
|
|
||||||
orig_name, ext = os.path.splitext(form.cleaned_data['upload'].name)
|
|
||||||
logger.info("Uploaded: %s", orig_name)
|
|
||||||
|
|
||||||
doc = form.save(commit=False)
|
|
||||||
doc.doctype = models.Document.DOCTYPE_MAP.get(ext.lower(), models.Document.DOCTYPE_MISC)
|
|
||||||
doc.work_id = self.kwargs['pk']
|
|
||||||
doc.save()
|
|
||||||
|
|
||||||
# auto tag the document
|
|
||||||
|
|
||||||
#name, ext = os.path.splitext(os.path.basename(doc.upload.name))
|
|
||||||
if doc.doctype == models.Document.DOCTYPE_PDF:
|
|
||||||
inst = auto_tag(orig_name)
|
|
||||||
if inst:
|
|
||||||
doc.sections.create(tag=inst.abbreviate())
|
|
||||||
|
|
||||||
if self.request.headers['Accept'] == 'application/json':
|
|
||||||
filename = os.path.basename(doc.upload.name)
|
|
||||||
return JsonResponse({
|
|
||||||
"message": "created",
|
|
||||||
"id": doc.pk,
|
|
||||||
"entry": render_to_string('library/document_entry.html',
|
|
||||||
{'collection': self.collection, 'doc': doc, 'request': self.request}
|
|
||||||
)
|
|
||||||
}, status=201)
|
|
||||||
|
|
||||||
return redirect('document_annotate', self.collection.pk, doc.pk)
|
|
||||||
|
|
||||||
class DocumentMixin(CollectionMixin):
|
|
||||||
model = models.Document
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
qs = models.Document.objects.select_related('work')
|
|
||||||
if self.request.is_admin:
|
|
||||||
return qs
|
|
||||||
return qs.filter(work__collection=self.collection)
|
|
||||||
|
|
||||||
class DocumentDetailView(DocumentMixin, DetailView):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class DocumentDownloadView(DocumentMixin, SingleObjectMixin, View):
|
|
||||||
|
|
||||||
def get(self, request, **args):
|
|
||||||
self.request = request
|
|
||||||
self.args = args
|
|
||||||
self.object = self.get_object()
|
|
||||||
|
|
||||||
#response = FileResponse(self.object.upload, content_type="application/pdf")
|
|
||||||
#return response
|
|
||||||
return redirect(self.object.upload.url)
|
|
||||||
|
|
||||||
class DocumentAnnotateView(DocumentMixin, DetailView):
|
|
||||||
template_name = 'library/document_annotate.html'
|
|
||||||
|
|
||||||
def post(self, request, **args):
|
|
||||||
self.request = request
|
|
||||||
self.args = args
|
|
||||||
self.object = self.get_object()
|
|
||||||
|
|
||||||
data = json.loads(request.body)
|
|
||||||
|
|
||||||
with transaction.atomic():
|
|
||||||
self.object.sections.all().delete()
|
|
||||||
for tag, start, end in data:
|
|
||||||
#pages.sort()
|
|
||||||
#end = pages[-1] if len(pages) > 1 else None
|
|
||||||
o = self.object.sections.create(tag=tag, start=start, end=end)
|
|
||||||
|
|
||||||
return HttpResponse(status=204)
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
|
||||||
data = super(DocumentAnnotateView, self).get_context_data(**kwargs)
|
|
||||||
|
|
||||||
pages = []
|
|
||||||
for part in data['document'].sections.all():
|
|
||||||
pages.append((part.tag, part.start, part.end))
|
|
||||||
|
|
||||||
data['json_data'] = {'pageTags': pages, 'instruments': dict(MUSIC_TAGS)}
|
|
||||||
return data
|
|
||||||
|
|
||||||
class DocumentDeleteView(DocumentMixin, DeleteView):
|
|
||||||
|
|
||||||
#def get_template_names(self):
|
|
||||||
# return ["interface/default_form.html"]
|
|
||||||
|
|
||||||
def get_success_url(self):
|
|
||||||
return resolve_url('work_detail', self.collection.pk, self.object.work_id)
|
|
||||||
|
|
||||||
class PartDownloadView(CollectionMixin, SingleObjectMixin, View):
|
|
||||||
|
|
||||||
pk_url_kwarg = 'section'
|
|
||||||
|
|
||||||
def get(self, request, **args):
|
|
||||||
self.request = request
|
|
||||||
self.args = args
|
|
||||||
self.object = self.get_object()
|
|
||||||
|
|
||||||
if self.object.start is None:
|
|
||||||
return redirect(self.object.doc.upload.url)
|
|
||||||
|
|
||||||
result = extract_pages(self.object.doc.upload.path, self.object.doc.work.name, self.object.start, self.object.end)
|
|
||||||
|
|
||||||
#download_name = f'{self.object.doc.work.name}_{self.object.instrument}.pdf'
|
|
||||||
|
|
||||||
response = FileResponse(result, content_type="application/pdf")
|
|
||||||
response['Content-Disposition'] = f'inline; filename="{self.args["filename"]}"'
|
|
||||||
return response
|
|
||||||
|
|
||||||
def get_object(self):
|
|
||||||
|
|
||||||
return Section.objects.filter(doc__work__collection=self.collection).select_related('doc', 'doc__work').get(pk=self.kwargs['section'])
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
"""
|
|
||||||
Django settings for polyphonic project.
|
|
||||||
|
|
||||||
Generated by 'django-admin startproject' using Django 3.1.1.
|
|
||||||
|
|
||||||
For more information on this file, see
|
|
||||||
https://docs.djangoproject.com/en/3.1/topics/settings/
|
|
||||||
|
|
||||||
For the full list of settings and their values, see
|
|
||||||
https://docs.djangoproject.com/en/3.1/ref/settings/
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from os import environ
|
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
SECRET_KEY=environ.get('SECRET_KEY')
|
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
|
||||||
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
|
|
||||||
|
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
|
||||||
DEBUG = False
|
|
||||||
|
|
||||||
ALLOWED_HOSTS = ['localhost']
|
|
||||||
|
|
||||||
# Application definition
|
|
||||||
|
|
||||||
POLYPHONIC_MODULES = [
|
|
||||||
'library'
|
|
||||||
]
|
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
'django.contrib.admin',
|
|
||||||
'django.contrib.auth',
|
|
||||||
'django.contrib.contenttypes',
|
|
||||||
'django.contrib.sessions',
|
|
||||||
'django.contrib.messages',
|
|
||||||
'django.contrib.staticfiles',
|
|
||||||
'django_markdown2',
|
|
||||||
'rest_framework',
|
|
||||||
'crispy_forms',
|
|
||||||
'crispy_bulma',
|
|
||||||
'byostorage',
|
|
||||||
'interface',
|
|
||||||
]
|
|
||||||
|
|
||||||
CRISPY_ALLOWED_TEMPLATE_PACKS = ("bulma",)
|
|
||||||
CRISPY_TEMPLATE_PACK = "bulma"
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
|
||||||
'django.middleware.security.SecurityMiddleware',
|
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
|
||||||
'django.middleware.common.CommonMiddleware',
|
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
|
||||||
]
|
|
||||||
|
|
||||||
ROOT_URLCONF = 'polyphonic.urls'
|
|
||||||
|
|
||||||
TEMPLATES = [
|
|
||||||
{
|
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
|
||||||
'DIRS': [],
|
|
||||||
'APP_DIRS': True,
|
|
||||||
'OPTIONS': {
|
|
||||||
'context_processors': [
|
|
||||||
'django.template.context_processors.debug',
|
|
||||||
'django.template.context_processors.request',
|
|
||||||
'django.contrib.auth.context_processors.auth',
|
|
||||||
'django.contrib.messages.context_processors.messages',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
WSGI_APPLICATION = 'polyphonic.wsgi.application'
|
|
||||||
|
|
||||||
|
|
||||||
# Database
|
|
||||||
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases
|
|
||||||
|
|
||||||
DATABASES = {
|
|
||||||
'default': {
|
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
|
||||||
'NAME': BASE_DIR / 'db.sqlite3',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
|
|
||||||
|
|
||||||
# Password validation
|
|
||||||
# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators
|
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
LOGIN_REDIRECT_URL = "/"
|
|
||||||
|
|
||||||
# Internationalization
|
|
||||||
# https://docs.djangoproject.com/en/3.1/topics/i18n/
|
|
||||||
|
|
||||||
# Localisation (localization?)
|
|
||||||
LANGUAGE_CODE = 'en-us'
|
|
||||||
TIME_ZONE = 'Australia/Melbourne'
|
|
||||||
|
|
||||||
USE_I18N = True
|
|
||||||
|
|
||||||
USE_L10N = True
|
|
||||||
|
|
||||||
USE_TZ = True
|
|
||||||
|
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
|
||||||
# https://docs.djangoproject.com/en/3.1/howto/static-files/
|
|
||||||
|
|
||||||
STATIC_URL = '/static/'
|
|
||||||
STATIC_ROOT = 'static'
|
|
||||||
|
|
||||||
# Need to set this
|
|
||||||
AWS_BUCKET = ''
|
|
||||||
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
try:
|
|
||||||
from .local_settings import *
|
|
||||||
except ImportError:
|
|
||||||
from .default_settings import *
|
|
||||||
|
|
||||||
|
|
||||||
INSTALLED_APPS += POLYPHONIC_MODULES
|
|
||||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
polyphonic:
|
||||||
|
image: "polyphonic:0.8.4"
|
||||||
|
build: "."
|
||||||
|
ports:
|
||||||
|
- "8001:8000"
|
||||||
|
volumes:
|
||||||
|
- "./data:/var/polyphonic"
|
||||||
|
- "./local_settings.py:/opt/polyphonic/local_settings.py"
|
||||||
|
env_file: "compose.env"
|
||||||
|
environment:
|
||||||
|
DJANGO_SETTINGS_MODULE: local_settings
|
||||||
|
PYTHONPATH: /opt/polyphonic
|
||||||
|
WORK_DIR: /var/polyphonic
|
||||||
@ -1,8 +0,0 @@
|
|||||||
from .default_settings import *
|
|
||||||
|
|
||||||
DATABASES = {
|
|
||||||
'default': {
|
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
|
||||||
'NAME': '/var/polyphonic/db.sqlite3',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -11,6 +11,6 @@ import os
|
|||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'polyphonic.settings')
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polyphonic.settings")
|
||||||
|
|
||||||
application = get_asgi_application()
|
application = get_asgi_application()
|
||||||
142
polyphonic/config/settings/base.py
Normal file
142
polyphonic/config/settings/base.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
"""
|
||||||
|
Django settings for polyphonic project.
|
||||||
|
|
||||||
|
Generated by 'django-admin startproject' using Django 3.1.1.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/3.1/topics/settings/
|
||||||
|
|
||||||
|
For the full list of settings and their values, see
|
||||||
|
https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent
|
||||||
|
|
||||||
|
# A place to put things
|
||||||
|
WORK_DIR = os.environ.get("WORK_DIR") or os.path.join(BASE_DIR, "data")
|
||||||
|
|
||||||
|
# Will fail to start if not defined
|
||||||
|
SECRET_KEY = os.environ.get("SECRET_KEY")
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = False
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
|
||||||
|
POLYPHONIC_MODULES = ["polyphonic.library"]
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"django.contrib.admin",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"django.contrib.staticfiles",
|
||||||
|
"django_markdown2",
|
||||||
|
"rest_framework",
|
||||||
|
"crispy_forms",
|
||||||
|
"crispy_bulma",
|
||||||
|
"byostorage",
|
||||||
|
"polyphonic.interface",
|
||||||
|
]
|
||||||
|
|
||||||
|
INSTALLED_APPS += POLYPHONIC_MODULES
|
||||||
|
|
||||||
|
CRISPY_ALLOWED_TEMPLATE_PACKS = ("bulma",)
|
||||||
|
CRISPY_TEMPLATE_PACK = "bulma"
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = "polyphonic.config.urls"
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.debug",
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = "polyphonic.config.wsgi.application"
|
||||||
|
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
|
"NAME": os.path.join(WORK_DIR, "db.sqlite3"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
LOGIN_REDIRECT_URL = "/"
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/3.1/topics/i18n/
|
||||||
|
|
||||||
|
# Localisation (localization?)
|
||||||
|
LANGUAGE_CODE = "en-us"
|
||||||
|
TIME_ZONE = "Australia/Melbourne"
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
USE_L10N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/3.1/howto/static-files/
|
||||||
|
|
||||||
|
STATIC_URL = "/static/"
|
||||||
|
STATIC_ROOT = "static"
|
||||||
|
|
||||||
|
# Library settings
|
||||||
|
|
||||||
|
CACHED_STORAGE_REMOTE = "byostorage.user.BYOStorage"
|
||||||
|
CACHED_STORAGE_DIR = os.path.join(WORK_DIR, "cache")
|
||||||
|
WHOOSH_INDEX = os.path.join(WORK_DIR, "index")
|
||||||
|
STORAGE_CLASSES = ["polyphonic.library.gdrive.storage.GDriveLinkStorage"]
|
||||||
28
polyphonic/config/settings/dev.py
Normal file
28
polyphonic/config/settings/dev.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
from .base import * # noqa
|
||||||
|
from os import environ
|
||||||
|
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
|
SECRET_KEY = "DO NOT USE IN PRODUCTION"
|
||||||
|
|
||||||
|
# Enable debug toolbar
|
||||||
|
INSTALLED_APPS.append("debug_toolbar") # noqa
|
||||||
|
MIDDLEWARE.insert(1, "debug_toolbar.middleware.DebugToolbarMiddleware") # noqa
|
||||||
|
INTERNAL_IPS = ["127.0.0.1"]
|
||||||
|
|
||||||
|
LOGGING = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"handlers": {
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"level": environ.get("DEBUG_LEVEL", "WARNING"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"polyphonic": {
|
||||||
|
"handlers": ["console"],
|
||||||
|
"level": environ.get("DEBUG_LEVEL", "WARNING"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
4
polyphonic/config/settings/docker.py
Normal file
4
polyphonic/config/settings/docker.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
from .base import * # noqa
|
||||||
|
|
||||||
|
# Enable WhiteNoise for static files
|
||||||
|
MIDDLEWARE.insert(1, "whitenoise.middleware.WhiteNoiseMiddleware") # noqa
|
||||||
@ -13,18 +13,20 @@ Including another URLconf
|
|||||||
1. Import the include() function: from django.urls import include, path
|
1. Import the include() function: from django.urls import include, path
|
||||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, re_path, include
|
from django.urls import path, include
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path('', include('interface.urls')),
|
path("", include("polyphonic.interface.urls")),
|
||||||
#path('', include('submissions.urls')),
|
# path('', include('submissions.urls')),
|
||||||
path('', include('library.urls')),
|
path("", include("polyphonic.library.urls")),
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import debug_toolbar
|
import debug_toolbar
|
||||||
urlpatterns.append(path('__debug__', include(debug_toolbar.urls)))
|
|
||||||
|
urlpatterns.append(path("__debug__", include(debug_toolbar.urls)))
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
@ -11,6 +11,6 @@ import os
|
|||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'polyphonic.settings')
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polyphonic.config.settings.base")
|
||||||
|
|
||||||
application = get_wsgi_application()
|
application = get_wsgi_application()
|
||||||
@ -2,26 +2,31 @@ from django.contrib import admin
|
|||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
class EnsembleAdmin(admin.ModelAdmin):
|
class EnsembleAdmin(admin.ModelAdmin):
|
||||||
list_display = ['name', 'slug']
|
list_display = ["name", "slug"]
|
||||||
|
|
||||||
|
|
||||||
class ModuleInline(admin.StackedInline):
|
class ModuleInline(admin.StackedInline):
|
||||||
model = models.Module
|
model = models.Module
|
||||||
extra = 0
|
extra = 0
|
||||||
|
|
||||||
class ProjectAdmin(admin.ModelAdmin):
|
|
||||||
|
|
||||||
list_display = ['name', 'ensemble', 'event_date', 'active']
|
class ProjectAdmin(admin.ModelAdmin):
|
||||||
list_filter = ['ensemble', 'active']
|
list_display = ["name", "ensemble", "event_date", "active"]
|
||||||
|
list_filter = ["ensemble", "active"]
|
||||||
inlines = [ModuleInline]
|
inlines = [ModuleInline]
|
||||||
|
|
||||||
|
|
||||||
class ResourceAdmin(admin.ModelAdmin):
|
class ResourceAdmin(admin.ModelAdmin):
|
||||||
list_display = ['name', 'media_type', 'project']
|
list_display = ["name", "media_type", "project"]
|
||||||
list_filter = ['project']
|
list_filter = ["project"]
|
||||||
|
|
||||||
|
|
||||||
class WikiPageAdmin(admin.ModelAdmin):
|
class WikiPageAdmin(admin.ModelAdmin):
|
||||||
list_display = ['title', 'project']
|
list_display = ["title", "project"]
|
||||||
list_filter = ['project']
|
list_filter = ["project"]
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(models.Ensemble, EnsembleAdmin)
|
admin.site.register(models.Ensemble, EnsembleAdmin)
|
||||||
admin.site.register(models.Project, ProjectAdmin)
|
admin.site.register(models.Project, ProjectAdmin)
|
||||||
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
|||||||
|
|
||||||
|
|
||||||
class InterfaceConfig(AppConfig):
|
class InterfaceConfig(AppConfig):
|
||||||
name = 'interface'
|
name = "polyphonic.interface"
|
||||||
@ -1,4 +1,5 @@
|
|||||||
from crispy_forms.layout import Field
|
from crispy_forms.layout import Field
|
||||||
|
|
||||||
|
|
||||||
class BulmaFileUpload(Field):
|
class BulmaFileUpload(Field):
|
||||||
template = 'bulma/file_upload.html'
|
template = "bulma/file_upload.html"
|
||||||
76
polyphonic/interface/forms.py
Normal file
76
polyphonic/interface/forms.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
from django import forms
|
||||||
|
from crispy_forms.helper import FormHelper
|
||||||
|
from crispy_forms.layout import Submit, HTML
|
||||||
|
from crispy_bulma.layout import FormGroup
|
||||||
|
|
||||||
|
from . import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class BaseForm(forms.Form):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.helper = self.get_form_helper()
|
||||||
|
|
||||||
|
def get_form_helper(self):
|
||||||
|
helper = FormHelper(self)
|
||||||
|
# helper.add_input(Submit('submit', 'Submit', css_class='button is-link'))
|
||||||
|
# helper.layout.subm append(HTML('<a class="button is-light">Cancel</a>'))
|
||||||
|
# print(helper.layout)
|
||||||
|
helper.layout.append(
|
||||||
|
FormGroup(
|
||||||
|
Submit("submit", "Save", css_class="button is-primary"),
|
||||||
|
HTML(
|
||||||
|
'{% if view.cancel_url %}<div class="control"><a href="{{ view.cancel_url }}" class="button is-light">Cancel</a></div>{% endif %}'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return helper
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectForm(forms.ModelForm, BaseForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.Project
|
||||||
|
fields = ["name", "description", "modules", "event_date"]
|
||||||
|
# widgets = {
|
||||||
|
# 'event_date': forms.DateTimeInput(attrs={'type': 'date'})
|
||||||
|
# }
|
||||||
|
|
||||||
|
modules = forms.MultipleChoiceField(
|
||||||
|
choices=[(x, x.title()) for x in models.settings.POLYPHONIC_MODULES],
|
||||||
|
widget=forms.CheckboxSelectMultiple,
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceForm(forms.ModelForm, BaseForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.Resource
|
||||||
|
fields = ["name", "media_type", "description", "file"]
|
||||||
|
|
||||||
|
def get_form_helper(self):
|
||||||
|
helper = super().get_form_helper()
|
||||||
|
helper[3].wrap(fields.BulmaFileUpload)
|
||||||
|
return helper
|
||||||
|
|
||||||
|
|
||||||
|
class WikiForm(forms.ModelForm, BaseForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.WikiPage
|
||||||
|
fields = ["title", "markdown"]
|
||||||
|
|
||||||
|
|
||||||
|
class CodeForm(BaseForm):
|
||||||
|
code = forms.CharField(
|
||||||
|
max_length=14,
|
||||||
|
widget=forms.TextInput(
|
||||||
|
attrs={"placeholder": "xxx-xxx-xxx", "inputmode": "numeric"}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
passphrase = forms.CharField(max_length=32)
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceUploadForm(forms.Form):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# file = S3UploadField()
|
||||||
@ -4,7 +4,7 @@ import byostorage.user
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import interface.models
|
import polyphonic.interface.models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
@ -23,7 +23,7 @@ class Migration(migrations.Migration):
|
|||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('name', models.CharField(help_text='Display name', max_length=100)),
|
('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)),
|
('slug', models.SlugField(editable=False, help_text='Short name for the ensemble - used for folders', max_length=100, unique=True)),
|
||||||
('code', models.CharField(default=interface.models.generate_code, help_text='Ensemble registration code', max_length=9)),
|
('code', models.CharField(default=polyphonic.interface.models.generate_code, help_text='Ensemble registration code', max_length=9)),
|
||||||
('passphrase', models.CharField(help_text='Used to register ensembles', max_length=100)),
|
('passphrase', models.CharField(help_text='Used to register ensembles', max_length=100)),
|
||||||
('details', models.TextField(blank=True, help_text='Description of the ensemble (markdown)')),
|
('details', models.TextField(blank=True, help_text='Description of the ensemble (markdown)')),
|
||||||
('admins', models.ManyToManyField(related_name='ensembles', to=settings.AUTH_USER_MODEL)),
|
('admins', models.ManyToManyField(related_name='ensembles', to=settings.AUTH_USER_MODEL)),
|
||||||
@ -60,7 +60,7 @@ class Migration(migrations.Migration):
|
|||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('name', models.CharField(max_length=100)),
|
('name', models.CharField(max_length=100)),
|
||||||
('description', models.TextField(blank=True)),
|
('description', models.TextField(blank=True)),
|
||||||
('file', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=interface.models.resource_key)),
|
('file', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=polyphonic.interface.models.resource_key)),
|
||||||
('media_type', models.CharField(choices=[('audio', 'Audio'), ('video', 'Video'), ('general', 'General')], default='*', max_length=10)),
|
('media_type', models.CharField(choices=[('audio', 'Audio'), ('video', 'Video'), ('general', 'General')], default='*', max_length=10)),
|
||||||
('visible', models.BooleanField(default=True)),
|
('visible', models.BooleanField(default=True)),
|
||||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='resources', to='interface.project')),
|
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='resources', to='interface.project')),
|
||||||
@ -8,17 +8,16 @@ from byostorage.user import BYOStorage
|
|||||||
|
|
||||||
import random
|
import random
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import os.path
|
|
||||||
from .utils import sign_data
|
from .utils import sign_data
|
||||||
|
|
||||||
MEDIA_TYPES = [
|
MEDIA_TYPES = [
|
||||||
('audio', "Audio"),
|
("audio", "Audio"),
|
||||||
('video', "Video"),
|
("video", "Video"),
|
||||||
('general', "General"),
|
("general", "General"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def rough_date(d):
|
def rough_date(d):
|
||||||
if not d:
|
if not d:
|
||||||
return False, "sometime..."
|
return False, "sometime..."
|
||||||
@ -27,22 +26,22 @@ def rough_date(d):
|
|||||||
if in_past:
|
if in_past:
|
||||||
days = abs(days)
|
days = abs(days)
|
||||||
if days == 0:
|
if days == 0:
|
||||||
m = int((d-timezone.now()).seconds/60)
|
m = int((d - timezone.now()).seconds / 60)
|
||||||
if m > 60:
|
if m > 60:
|
||||||
return in_past, "{0:d} hours".format(int(m / 60))
|
return in_past, "{0:d} hours".format(int(m / 60))
|
||||||
return in_past, "{0:d} minutes!".format(int(m % 60))
|
return in_past, "{0:d} minutes!".format(int(m % 60))
|
||||||
if days >= 14:
|
if days >= 14:
|
||||||
return in_past, "{0:d} weeks".format(int(days/7))
|
return in_past, "{0:d} weeks".format(int(days / 7))
|
||||||
if days >= 7:
|
if days >= 7:
|
||||||
return in_past, "{0:d} weeks, {1:d} days".format(int(days / 7), int(days % 7))
|
return in_past, "{0:d} weeks, {1:d} days".format(int(days / 7), int(days % 7))
|
||||||
return in_past, f"{days} days"
|
return in_past, f"{days} days"
|
||||||
|
|
||||||
|
|
||||||
def generate_code(length=9):
|
def generate_code(length=9):
|
||||||
return "".join([ random.choice('0123456789') for _ in range(length) ])
|
return "".join([random.choice("0123456789") for _ in range(length)])
|
||||||
|
|
||||||
|
|
||||||
class EnsembleQuerySet(models.QuerySet):
|
class EnsembleQuerySet(models.QuerySet):
|
||||||
|
|
||||||
def for_user(self, user, ensemble_keys=[], project_keys=[]):
|
def for_user(self, user, ensemble_keys=[], project_keys=[]):
|
||||||
if user.is_superuser:
|
if user.is_superuser:
|
||||||
return self
|
return self
|
||||||
@ -54,26 +53,35 @@ class EnsembleQuerySet(models.QuerySet):
|
|||||||
|
|
||||||
return self.filter(f).distinct()
|
return self.filter(f).distinct()
|
||||||
|
|
||||||
class Ensemble(models.Model):
|
|
||||||
''' A group that plays together
|
|
||||||
|
|
||||||
'''
|
class Ensemble(models.Model):
|
||||||
name = models.CharField(max_length=100,
|
"""A group that plays together"""
|
||||||
help_text="Display name")
|
|
||||||
slug = models.SlugField(max_length=100, editable=False, unique=True,
|
name = models.CharField(max_length=100, help_text="Display name")
|
||||||
help_text="Short name for the ensemble - used for folders")
|
slug = models.SlugField(
|
||||||
admins = models.ManyToManyField('auth.User', related_name='ensembles')
|
max_length=100,
|
||||||
details = models.TextField(blank=True,
|
editable=False,
|
||||||
help_text="Description of the ensemble (markdown)")
|
unique=True,
|
||||||
storage = models.ForeignKey('byostorage.UserStorage', null=True, on_delete=models.SET_NULL,
|
help_text="Short name for the ensemble - used for folders",
|
||||||
help_text="Default storage for this ensemble")
|
)
|
||||||
nonce = models.SmallIntegerField(default=1,
|
admins = models.ManyToManyField("auth.User", related_name="ensembles")
|
||||||
help_text="Increment this to reset the authentication links")
|
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()
|
objects = EnsembleQuerySet.as_manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('slug', )
|
ordering = ("slug",)
|
||||||
|
|
||||||
def active_projects(self):
|
def active_projects(self):
|
||||||
return self.projects.active().current()
|
return self.projects.active().current()
|
||||||
@ -83,7 +91,7 @@ class Ensemble(models.Model):
|
|||||||
return False
|
return False
|
||||||
if user.is_superuser:
|
if user.is_superuser:
|
||||||
return True
|
return True
|
||||||
return user.pk in self.admins.values_list('pk', flat=True)
|
return user.pk in self.admins.values_list("pk", flat=True)
|
||||||
|
|
||||||
def save(self, **kwargs):
|
def save(self, **kwargs):
|
||||||
if not self.slug:
|
if not self.slug:
|
||||||
@ -91,17 +99,21 @@ class Ensemble(models.Model):
|
|||||||
super(Ensemble, self).save(**kwargs)
|
super(Ensemble, self).save(**kwargs)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return resolve_url('ensemble_detail', ensemble=self.slug)
|
return resolve_url("ensemble_detail", ensemble=self.slug)
|
||||||
|
|
||||||
def auth(self):
|
def auth(self):
|
||||||
return sign_data(f'{self.pk}-{self.nonce}', 12)
|
return sign_data(f"{self.pk}-{self.nonce}", 12)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class ProjectQuerySet(models.QuerySet):
|
class ProjectQuerySet(models.QuerySet):
|
||||||
def current(self):
|
def current(self):
|
||||||
return self.filter(models.Q(event_date__gte=(timezone.now()-timezone.timedelta(7))) | models.Q(event_date=None))
|
return self.filter(
|
||||||
|
models.Q(event_date__gte=(timezone.now() - timezone.timedelta(7)))
|
||||||
|
| models.Q(event_date=None)
|
||||||
|
)
|
||||||
|
|
||||||
def active(self):
|
def active(self):
|
||||||
return self.filter(active=True)
|
return self.filter(active=True)
|
||||||
@ -117,23 +129,26 @@ class ProjectQuerySet(models.QuerySet):
|
|||||||
|
|
||||||
return self.filter(f)
|
return self.filter(f)
|
||||||
|
|
||||||
|
|
||||||
class Project(models.Model):
|
class Project(models.Model):
|
||||||
''' A Project linked to an ensemble
|
"""A Project linked to an ensemble"""
|
||||||
'''
|
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
ensemble = models.ForeignKey(Ensemble, related_name='projects', on_delete=models.CASCADE, null=True)
|
ensemble = models.ForeignKey(
|
||||||
description = models.TextField(blank=True,
|
Ensemble, related_name="projects", on_delete=models.CASCADE, null=True
|
||||||
help_text="Markdown format")
|
)
|
||||||
|
description = models.TextField(blank=True, help_text="Markdown format")
|
||||||
active = models.BooleanField(default=True)
|
active = models.BooleanField(default=True)
|
||||||
event_date =models.DateTimeField(null=True, blank=True)
|
event_date = models.DateTimeField(null=True, blank=True)
|
||||||
owner = models.CharField(max_length=255, blank=True)
|
owner = models.CharField(max_length=255, blank=True)
|
||||||
nonce = models.SmallIntegerField(default=1,
|
nonce = models.SmallIntegerField(
|
||||||
help_text="Increment this to reset the authentication links")
|
default=1, help_text="Increment this to reset the authentication links"
|
||||||
|
)
|
||||||
|
|
||||||
objects = ProjectQuerySet.as_manager()
|
objects = ProjectQuerySet.as_manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['active', 'event_date']
|
ordering = ["active", "event_date"]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def days(self):
|
def days(self):
|
||||||
@ -162,62 +177,74 @@ class Project(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def active_modules(self):
|
def active_modules(self):
|
||||||
return self.modules.values_list('name', flat=True)
|
return self.modules.values_list("name", flat=True)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return resolve_url('project_detail', project=self.pk)
|
return resolve_url("project_detail", project=self.pk)
|
||||||
|
|
||||||
def auth(self):
|
def auth(self):
|
||||||
return sign_data(f'{self.pk}-{self.nonce}', 12)
|
return sign_data(f"{self.pk}-{self.nonce}", 12)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class Module(models.Model):
|
class Module(models.Model):
|
||||||
''' Enable modules on a oriject
|
"""Enable modules on a oriject"""
|
||||||
'''
|
|
||||||
name = models.SlugField(max_length=20, choices=[ (x, x.title()) for x in settings.POLYPHONIC_MODULES ])
|
name = models.SlugField(
|
||||||
project = models.ForeignKey(Project, related_name="modules", on_delete=models.CASCADE)
|
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):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
def resource_key(resource, filename):
|
def resource_key(resource, filename):
|
||||||
return f'{resource.project.folder}/resources/{filename}'
|
return f"{resource.project.folder}/resources/{filename}"
|
||||||
|
|
||||||
|
|
||||||
class Resource(models.Model):
|
class Resource(models.Model):
|
||||||
''' A viewable file resource attached to a project
|
"""A viewable file resource attached to a project
|
||||||
|
|
||||||
e.g PDF instructions, MP3 backing track
|
e.g PDF instructions, MP3 backing track
|
||||||
'''
|
"""
|
||||||
project = models.ForeignKey(Project, related_name='resources', on_delete=models.CASCADE)
|
|
||||||
|
project = models.ForeignKey(
|
||||||
|
Project, related_name="resources", on_delete=models.CASCADE
|
||||||
|
)
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
file = models.FileField(storage=BYOStorage(), upload_to=resource_key)
|
file = models.FileField(storage=BYOStorage(), upload_to=resource_key)
|
||||||
media_type = models.CharField(max_length=10, choices=MEDIA_TYPES, default='*')
|
media_type = models.CharField(max_length=10, choices=MEDIA_TYPES, default="*")
|
||||||
visible = models.BooleanField(default=True)
|
visible = models.BooleanField(default=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-visible', '-pk']
|
ordering = ["-visible", "-pk"]
|
||||||
|
|
||||||
def accept(self):
|
def accept(self):
|
||||||
if self.media_type == 'general':
|
if self.media_type == "general":
|
||||||
return ".*"
|
return ".*"
|
||||||
return f"{self.media_type}/*"
|
return f"{self.media_type}/*"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
class WikiPage(models.Model):
|
|
||||||
''' An editable wiki page for the project in markdown format
|
|
||||||
|
|
||||||
'''
|
class WikiPage(models.Model):
|
||||||
project = models.ForeignKey(Project, related_name='wiki_pages', on_delete=models.CASCADE)
|
"""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)
|
title = models.CharField(max_length=255)
|
||||||
markdown = models.TextField()
|
markdown = models.TextField()
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return resolve_url('wiki', project=self.project_id, pk=self.pk)
|
return resolve_url("wiki", project=self.project_id, pk=self.pk)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
Before Width: | Height: | Size: 426 KiB After Width: | Height: | Size: 426 KiB |
|
Before Width: | Height: | Size: 258 B After Width: | Height: | Size: 258 B |
@ -11,7 +11,8 @@
|
|||||||
<script src="{% static 'interface/js/interface.js' %}"></script>
|
<script src="{% static 'interface/js/interface.js' %}"></script>
|
||||||
<script src="//unpkg.com/alpinejs" defer></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="//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>
|
<!-- script src="//kit.fontawesome.com/c837098e5b.js" crossorigin="anonymous" defer></script -->
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" rel="stylesheet" />
|
||||||
<title>{% block title %}Polyphonic{% endblock %}</title>
|
<title>{% block title %}Polyphonic{% endblock %}</title>
|
||||||
{% block media %}{% endblock %}
|
{% block media %}{% endblock %}
|
||||||
<style>{% block style %}{% endblock %}</style>
|
<style>{% block style %}{% endblock %}</style>
|
||||||
@ -23,7 +24,7 @@
|
|||||||
<nav class="navbar" role="navigation">
|
<nav class="navbar" role="navigation">
|
||||||
<div class="navbar-brand has-text-primary">
|
<div class="navbar-brand has-text-primary">
|
||||||
<a class="navbar-item" href="/">
|
<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="icon fancy mx-4"><span class="material-symbols-outlined is-size-1 is-size-3-mobile">groups</span></span>
|
||||||
<span class="fancy is-size-2 is-size-4-mobile">Polyphonic</span>
|
<span class="fancy is-size-2 is-size-4-mobile">Polyphonic</span>
|
||||||
</a>
|
</a>
|
||||||
<span class="navbar-item is-hidden-mobile fancy is-size-5">Musical Ensemble Manager</span>
|
<span class="navbar-item is-hidden-mobile fancy is-size-5">Musical Ensemble Manager</span>
|
||||||
@ -6,7 +6,7 @@
|
|||||||
{% crispy_field field 'class' 'file-input'%}
|
{% crispy_field field 'class' 'file-input'%}
|
||||||
<span class="file-cta">
|
<span class="file-cta">
|
||||||
<span class="file-icon">
|
<span class="file-icon">
|
||||||
<i class="fas fa-upload"></i>
|
<span class="material-symbols-outlined">file_upload</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="file-label">
|
<span class="file-label">
|
||||||
Choose a file…
|
Choose a file…
|
||||||
@ -1,19 +1,20 @@
|
|||||||
{% extends "interface/project_base.html" %}
|
{% extends "interface/project_base.html" %}
|
||||||
{% load md2 %}
|
{% load md2 %}
|
||||||
|
{% load polyphonic %}
|
||||||
|
|
||||||
{% block admin %}
|
{% block admin %}
|
||||||
<a href="{% url 'project_create' object.slug %}" class="button is-link">
|
<a href="{% url 'project_create' object.slug %}" class="button is-link">
|
||||||
<span class="icon"><i class="fas fa-plus-circle"></i></span>
|
{{ "add_notes"|icon }}
|
||||||
<span>Add project</span>
|
<span>Add project</span>
|
||||||
</a>
|
</a>
|
||||||
{% if inactive %}
|
{% if inactive %}
|
||||||
<a href="?" class="button is-link">
|
<a href="?" class="button is-link">
|
||||||
<span class="icon"><i class="fas fa-archive"></i></span>
|
{{ "preview_off"|icon }}
|
||||||
<span>Hide old</span>
|
<span>Hide old</span>
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="?inactive" class="button is-link">
|
<a href="?inactive" class="button is-link">
|
||||||
<span class="icon"><i class="fas fa-archive"></i></span>
|
{{ "preview"|icon }}
|
||||||
<span>Show all</span>
|
<span>Show all</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -5,7 +5,7 @@
|
|||||||
{% comment %}
|
{% comment %}
|
||||||
<div class="admin-tools is-pulled-right">
|
<div class="admin-tools is-pulled-right">
|
||||||
<a class="button is-link" href="{% url 'register' %}">
|
<a class="button is-link" href="{% url 'register' %}">
|
||||||
<span class="icon"><i class="fas fa-plus-circle"></i></span>
|
{% icon "add_file" %}
|
||||||
<span>Register another</span>
|
<span>Register another</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -25,10 +25,13 @@
|
|||||||
<img src="https://www.gravatar.com/avatar/{{ ensemble.email }}?d=mp" alt="Placeholder image">
|
<img src="https://www.gravatar.com/avatar/{{ ensemble.email }}?d=mp" alt="Placeholder image">
|
||||||
</figure>
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
<div class="media-content" style="min-height: 60px">
|
<div class="media-content" style="min-height: 100px">
|
||||||
<a href="{% url 'ensemble_detail' ensemble.slug %}">
|
<a href="{% url 'ensemble_detail' ensemble.slug %}">
|
||||||
<p class="title is-4">{{ ensemble.name }}</p>
|
<p class="title is-4">{{ ensemble.name }}</p>
|
||||||
</a>
|
</a>
|
||||||
|
<div class="mt-3">
|
||||||
|
{{ ensemble.details|markdown }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -4,11 +4,11 @@
|
|||||||
|
|
||||||
{% block admin %}
|
{% block admin %}
|
||||||
<a href="{% url 'wiki_create' project=project.pk %}" class="button is-link">
|
<a href="{% url 'wiki_create' project=project.pk %}" class="button is-link">
|
||||||
<span class="icon"><i class="fas fa-file"></i></span>
|
{{ "add_notes"|icon }}
|
||||||
<span>Add Page</span>
|
<span>Add Page</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'project_edit' project=project.pk %}" class="button is-link">
|
<a href="{% url 'project_edit' project=project.pk %}" class="button is-link">
|
||||||
<span class="icon"><i class="fas fa-edit"></i></span>
|
{{ "edit"|icon }}
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
</a>
|
</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -1,9 +1,10 @@
|
|||||||
{% extends "interface/project_base.html" %}
|
{% extends "interface/project_base.html" %}
|
||||||
{% load md2 %}
|
{% load md2 %}
|
||||||
|
{% load polyphonic %}
|
||||||
|
|
||||||
{% block admin %}
|
{% block admin %}
|
||||||
<a class="button is-link" href="{% url 'resource_create' project=project.pk %}">
|
<a class="button is-link" href="{% url 'resource_create' project=project.pk %}">
|
||||||
<span class="icon"><i class="fas fa-plus-circle"></i></span>
|
{% icon "add_notes" %}
|
||||||
<span>Add new</span>
|
<span>Add new</span>
|
||||||
</a>
|
</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -27,11 +28,11 @@
|
|||||||
|
|
||||||
<div class="card-header-icon">
|
<div class="card-header-icon">
|
||||||
{% if request.is_admin %}
|
{% if request.is_admin %}
|
||||||
<a href="{% url 'resource_upload' project=project.pk pk=resource.pk %}" class="icon" title="Upload">
|
<a href="{% url 'resource_upload' project=project.pk pk=resource.pk %}" title="Upload">
|
||||||
<i class="fas fa-upload"></i>
|
{% icon "upload_file" %}
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'resource_edit' project=project.pk pk=resource.pk %}" class="icon" title="Edit">
|
<a href="{% url 'resource_edit' project=project.pk pk=resource.pk %}" title="Edit">
|
||||||
<i class="fas fa-edit"></i>
|
{% icon "edit" %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@ -1,8 +1,9 @@
|
|||||||
{% extends "interface/project_base.html" %}
|
{% extends "interface/project_base.html" %}
|
||||||
|
{% load polyphonic %}
|
||||||
|
|
||||||
{% block admin %}
|
{% block admin %}
|
||||||
<a href="{% url 'wiki_edit' project=project.pk pk=wikipage.pk %}" class="button is-link">
|
<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>
|
{{ "edit"|icon }}
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
</a>
|
</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -3,7 +3,9 @@ import os.path
|
|||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
def basename(value):
|
def basename(value):
|
||||||
return os.path.basename(value)
|
return os.path.basename(value)
|
||||||
|
|
||||||
register.filter('basename', basename)
|
|
||||||
|
register.filter("basename", basename)
|
||||||
36
polyphonic/interface/templatetags/polyphonic.py
Normal file
36
polyphonic/interface/templatetags/polyphonic.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
from django import template
|
||||||
|
from django.utils import timesince
|
||||||
|
from django.utils.html import format_html
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter("icon", is_safe=True)
|
||||||
|
def material_icon(value):
|
||||||
|
return f'<span class="icon"><span class="material-symbols-outlined">{value}</span></span>'
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def icon(name, element="span", classes=[]):
|
||||||
|
classes = ["icon"] + classes
|
||||||
|
return format_html(
|
||||||
|
'<{} class="{}"><span class="material-symbols-outlined">{}</span></{}>',
|
||||||
|
element,
|
||||||
|
" ".join(classes),
|
||||||
|
name,
|
||||||
|
element,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def roughtimesince(value):
|
||||||
|
return timesince.timesince(value, depth=1)
|
||||||
|
|
||||||
|
|
||||||
|
register.filter("roughtimesince", roughtimesince)
|
||||||
|
|
||||||
|
|
||||||
|
def roughtimeuntil(value):
|
||||||
|
return timesince.timeuntil(value, depth=1)
|
||||||
|
|
||||||
|
|
||||||
|
register.filter("roughtimeuntil", roughtimeuntil)
|
||||||
@ -2,6 +2,7 @@ from django import template
|
|||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag(takes_context=True)
|
@register.simple_tag(takes_context=True)
|
||||||
def url_update(context, **kwargs):
|
def url_update(context, **kwargs):
|
||||||
params = context.request.GET.copy()
|
params = context.request.GET.copy()
|
||||||
@ -1,11 +1,11 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from interface import models, utils
|
from polyphonic.interface import models
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
class AccessTestCase(TestCase):
|
|
||||||
|
|
||||||
|
class AccessTestCase(TestCase):
|
||||||
USERS = ()
|
USERS = ()
|
||||||
|
|
||||||
ENSEMBLES = ()
|
ENSEMBLES = ()
|
||||||
@ -16,16 +16,15 @@ class AccessTestCase(TestCase):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
|
||||||
cls.users = {}
|
cls.users = {}
|
||||||
for details in cls.USERS:
|
for details in cls.USERS:
|
||||||
cls.users[details['username']] = User.objects.create_user(**details)
|
cls.users[details["username"]] = User.objects.create_user(**details)
|
||||||
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
|
|
||||||
cls.ensembles = {}
|
cls.ensembles = {}
|
||||||
for details in cls.ENSEMBLES:
|
for details in cls.ENSEMBLES:
|
||||||
admins = details.pop('admins', [])
|
admins = details.pop("admins", [])
|
||||||
obj = models.Ensemble.objects.create(**details)
|
obj = models.Ensemble.objects.create(**details)
|
||||||
for admin in admins:
|
for admin in admins:
|
||||||
obj.admins.add(cls.users[admin])
|
obj.admins.add(cls.users[admin])
|
||||||
@ -33,37 +32,40 @@ class AccessTestCase(TestCase):
|
|||||||
|
|
||||||
cls.projects = {}
|
cls.projects = {}
|
||||||
for details in cls.PROJECTS:
|
for details in cls.PROJECTS:
|
||||||
when = details.pop('when', 0)
|
when = details.pop("when", 0)
|
||||||
ensemble = details.pop('ensemble')
|
ensemble = details.pop("ensemble")
|
||||||
details['event_date'] = now + timedelta(days=when) if when else None
|
details["event_date"] = now + timedelta(days=when) if when else None
|
||||||
obj = cls.ensembles[ensemble].projects.create(**details)
|
obj = cls.ensembles[ensemble].projects.create(**details)
|
||||||
cls.projects[details['name']] = obj
|
cls.projects[details["name"]] = obj
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
def test_protected_views(self):
|
def test_protected_views(self):
|
||||||
|
self.assertAccess({x: False for x in self.PROTECTED_URLS})
|
||||||
|
|
||||||
self.assertAccess({ x: False for x in self.PROTECTED_URLS })
|
if "admin" in self.users:
|
||||||
|
self.client.force_login(self.users["admin"])
|
||||||
if 'admin' in self.users:
|
self.assertAccess({x: True for x in self.PROTECTED_URLS})
|
||||||
self.client.force_login(self.users['admin'])
|
|
||||||
self.assertAccess({ x: True for x in self.PROTECTED_URLS })
|
|
||||||
|
|
||||||
def login(self, user, passwd):
|
def login(self, user, passwd):
|
||||||
response = self.client.post('/login', {'username': user, 'password': passwd})
|
response = self.client.post("/login", {"username": user, "password": passwd})
|
||||||
self.assertEqual(response.status_code, 302, f"Failed to login as {user}")
|
self.assertEqual(response.status_code, 302, f"Failed to login as {user}")
|
||||||
|
|
||||||
def authorize(self, model, **kwargs):
|
def authorize(self, model, **kwargs):
|
||||||
object = model.objects.get(**kwargs)
|
object = model.objects.get(**kwargs)
|
||||||
response = self.client.get(f'{object.get_absolute_url()}?auth={object.auth()}')
|
response = self.client.get(f"{object.get_absolute_url()}?auth={object.auth()}")
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
def assertAccess(self, urls):
|
def assertAccess(self, urls):
|
||||||
for url, expected in urls.items():
|
for url, expected in urls.items():
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
self.assertEqual(response.status_code == 200, expected, f"Expected {expected} for {url} (status: {response.status_code})")
|
self.assertEqual(
|
||||||
|
response.status_code == 200,
|
||||||
|
expected,
|
||||||
|
f"Expected {expected} for {url} (status: {response.status_code})",
|
||||||
|
)
|
||||||
|
|
||||||
def assertObjectList(self, response, expected, element='name'):
|
def assertObjectList(self, response, expected, element="name"):
|
||||||
self.assertEqual(response.status_code, 200, "No result returned")
|
self.assertEqual(response.status_code, 200, "No result returned")
|
||||||
objects = response.context['object_list'].values_list(element, flat=True)
|
objects = response.context["object_list"].values_list(element, flat=True)
|
||||||
self.assertEqual(list(objects), expected)
|
self.assertEqual(list(objects), expected)
|
||||||
214
polyphonic/interface/tests/test_access.py
Normal file
214
polyphonic/interface/tests/test_access.py
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
from polyphonic.interface import models
|
||||||
|
|
||||||
|
from . import AccessTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class InterfaceAccessTestCase(AccessTestCase):
|
||||||
|
USERS = (
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "secret",
|
||||||
|
"is_superuser": True,
|
||||||
|
"is_staff": True,
|
||||||
|
},
|
||||||
|
{"username": "homer", "password": "maggie"},
|
||||||
|
)
|
||||||
|
|
||||||
|
ENSEMBLES = (
|
||||||
|
{"name": "The Be Sharps", "slug": "be-sharps", "admins": ["homer"]},
|
||||||
|
{"name": "Lisa & the Bleeding Gums", "slug": "bleeding-gums"},
|
||||||
|
{"name": "Party Posse"},
|
||||||
|
)
|
||||||
|
|
||||||
|
PROJECTS = (
|
||||||
|
{"name": "Baker St", "ensemble": "bleeding-gums", "when": -12},
|
||||||
|
{"name": "Navy Recruitment Day", "ensemble": "party-posse", "when": 6},
|
||||||
|
{"name": "Barbershop Contest", "ensemble": "be-sharps", "when": 28},
|
||||||
|
{"name": "Open Mic Night", "ensemble": "bleeding-gums", "when": 1},
|
||||||
|
{"name": "Current Repertoire", "ensemble": "be-sharps"},
|
||||||
|
)
|
||||||
|
|
||||||
|
PROTECTED_URLS = (
|
||||||
|
"/ensembles/be-sharps",
|
||||||
|
"/ensembles/be-sharps/new-project",
|
||||||
|
"/projects/3",
|
||||||
|
"/projects/3/resources",
|
||||||
|
"/projects/3/resources/add",
|
||||||
|
"/admin/interface/ensemble/",
|
||||||
|
"/admin/interface/project/",
|
||||||
|
"/admin/interface/resource/",
|
||||||
|
"/admin/interface/wikipage/",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bad_login(self):
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
self.failureException, "Failed to login as admin"
|
||||||
|
):
|
||||||
|
self.login("admin", "admin")
|
||||||
|
|
||||||
|
def test_superuser_ensembles(self):
|
||||||
|
self.login("admin", "secret")
|
||||||
|
response = self.client.get("/ensembles")
|
||||||
|
self.assertObjectList(
|
||||||
|
response, ["The Be Sharps", "Lisa & the Bleeding Gums", "Party Posse"]
|
||||||
|
)
|
||||||
|
self.assertContains(response, "Django Admin")
|
||||||
|
|
||||||
|
def test_superuser_ensemble_permissions(self):
|
||||||
|
self.login("admin", "secret")
|
||||||
|
response = self.client.get("/ensembles/party-posse")
|
||||||
|
self.assertTrue(response.context["request"].is_admin)
|
||||||
|
self.assertContains(response, "Add project")
|
||||||
|
self.assertAccess(
|
||||||
|
{
|
||||||
|
"/ensembles/be-sharps": True,
|
||||||
|
"/ensembles/bleeding-gums": True,
|
||||||
|
"/ensembles/party-posse": True,
|
||||||
|
"/ensembles/unknown": False,
|
||||||
|
"/ensembles/be-sharps/new-project": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_superuser_projects(self):
|
||||||
|
self.login("admin", "secret")
|
||||||
|
response = self.client.get("/projects")
|
||||||
|
self.assertObjectList(
|
||||||
|
response,
|
||||||
|
[
|
||||||
|
"Current Repertoire",
|
||||||
|
"Open Mic Night",
|
||||||
|
"Navy Recruitment Day",
|
||||||
|
"Barbershop Contest",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertObjectList(
|
||||||
|
self.client.get("/ensembles/bleeding-gums"), ["Open Mic Night"]
|
||||||
|
)
|
||||||
|
self.assertObjectList(
|
||||||
|
self.client.get("/ensembles/bleeding-gums?inactive"),
|
||||||
|
["Open Mic Night", "Baker St"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_user_ensembles(self):
|
||||||
|
self.login("homer", "maggie")
|
||||||
|
response = self.client.get("/ensembles")
|
||||||
|
self.assertObjectList(response, ["The Be Sharps"])
|
||||||
|
|
||||||
|
self.assertNotContains(response, "Django Admin")
|
||||||
|
|
||||||
|
def test_user_ensemble_permissions(self):
|
||||||
|
self.login("homer", "maggie")
|
||||||
|
response = self.client.get("/ensembles/be-sharps")
|
||||||
|
self.assertTrue(response.context["request"].is_admin)
|
||||||
|
self.assertContains(response, "Add project")
|
||||||
|
self.assertContains(response, "Show all")
|
||||||
|
self.assertAccess(
|
||||||
|
{
|
||||||
|
"/ensembles/be-sharps": True,
|
||||||
|
"/ensembles/bleeding-gums": False,
|
||||||
|
"/ensembles/party-posse": False,
|
||||||
|
"/ensembles/be-sharps/new-project": True,
|
||||||
|
"/ensembles/party-posse/new-project": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.authorize(models.Ensemble, slug="bleeding-gums")
|
||||||
|
self.assertAccess(
|
||||||
|
{
|
||||||
|
"/ensembles/be-sharps": True,
|
||||||
|
"/ensembles/bleeding-gums": True,
|
||||||
|
"/ensembles/party-posse": False,
|
||||||
|
"/ensembles/be-sharps/new-project": True,
|
||||||
|
"/ensembles/party-posse/new-project": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = self.client.get("/ensembles/bleeding-gums")
|
||||||
|
self.assertFalse(response.context["request"].is_admin)
|
||||||
|
self.assertNotContains(response, "Add project")
|
||||||
|
self.assertNotContains(response, "Show all")
|
||||||
|
|
||||||
|
def test_user_projects(self):
|
||||||
|
self.login("homer", "maggie")
|
||||||
|
response = self.client.get("/projects")
|
||||||
|
self.assertObjectList(response, ["Current Repertoire", "Barbershop Contest"])
|
||||||
|
response = self.client.get("/projects/3")
|
||||||
|
self.assertTrue(response.context["request"].is_admin)
|
||||||
|
|
||||||
|
self.assertAccess(
|
||||||
|
{
|
||||||
|
"/projects/3": True,
|
||||||
|
"/projects/3/resources": True,
|
||||||
|
"/projects/3/resources/add": True,
|
||||||
|
"/projects/4": False,
|
||||||
|
"/projects/4/resources": False,
|
||||||
|
"/projects/4/resources/add": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.authorize(models.Project, pk=4)
|
||||||
|
response = self.client.get("/projects")
|
||||||
|
self.assertObjectList(
|
||||||
|
response, ["Current Repertoire", "Open Mic Night", "Barbershop Contest"]
|
||||||
|
)
|
||||||
|
response = self.client.get("/projects/4")
|
||||||
|
self.assertFalse(response.context["request"].is_admin)
|
||||||
|
|
||||||
|
def test_anon_ensembles(self):
|
||||||
|
response = self.client.get("/ensembles")
|
||||||
|
self.assertObjectList(response, [])
|
||||||
|
self.assertContains(
|
||||||
|
response, "You don't currently have access to any ensembles"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_anon_authorized_ensemble(self):
|
||||||
|
self.authorize(models.Ensemble, slug="party-posse")
|
||||||
|
response = self.client.get("/ensembles/party-posse")
|
||||||
|
self.assertContains(response, "Party Posse")
|
||||||
|
|
||||||
|
response = self.client.get("/ensembles")
|
||||||
|
self.assertObjectList(response, ["Party Posse"])
|
||||||
|
|
||||||
|
self.assertAccess(
|
||||||
|
{
|
||||||
|
"/ensembles/be-sharps": False,
|
||||||
|
"/ensembles/party-posse": True,
|
||||||
|
"/ensembles/bleeding-gums": False,
|
||||||
|
"/ensembles/unknown": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = self.client.get("/projects")
|
||||||
|
self.assertObjectList(response, ["Navy Recruitment Day"])
|
||||||
|
|
||||||
|
def test_anon_authorized_project(self):
|
||||||
|
self.authorize(models.Project, pk=4)
|
||||||
|
self.assertObjectList(self.client.get("/projects"), ["Open Mic Night"])
|
||||||
|
self.assertObjectList(
|
||||||
|
self.client.get("/ensembles"), ["Lisa & the Bleeding Gums"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertAccess(
|
||||||
|
{
|
||||||
|
"/projects/4": True,
|
||||||
|
"/projects/4/resources": True,
|
||||||
|
"/projects/1": False,
|
||||||
|
"/projects/1/resources": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_anon_permission_denied(self):
|
||||||
|
self.assertAccess(
|
||||||
|
{
|
||||||
|
"/ensembles": True,
|
||||||
|
"/ensembles/be-sharps": False,
|
||||||
|
"/ensembles/party-posse": False,
|
||||||
|
"/ensembles/bleeding-gums": False,
|
||||||
|
"/ensembles/unknown": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_anon_deauthorize_project(self):
|
||||||
|
self.authorize(models.Project, pk=4)
|
||||||
|
self.assertAccess({"/projects/4": True})
|
||||||
|
models.Project.objects.filter(pk=4).update(nonce=2)
|
||||||
|
self.assertAccess({"/projects/4": False})
|
||||||
@ -1,6 +1,6 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
class IntegrationTestCase(TestCase):
|
|
||||||
|
|
||||||
|
class IntegrationTestCase(TestCase):
|
||||||
def test_runs(self):
|
def test_runs(self):
|
||||||
self.assertTrue(True)
|
self.assertTrue(True)
|
||||||
79
polyphonic/interface/urls.py
Normal file
79
polyphonic/interface/urls.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from django.contrib.auth import views as auth_views
|
||||||
|
from django.views.generic.base import RedirectView
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", RedirectView.as_view(url="projects", permanent=False), name="home"),
|
||||||
|
path("login", auth_views.LoginView.as_view(), name="login"),
|
||||||
|
path("logout", views.logout_view, name="logout"),
|
||||||
|
path(
|
||||||
|
"forget/<resource>/<key>",
|
||||||
|
views.ForgetResourceView.as_view(),
|
||||||
|
name="forget_resource",
|
||||||
|
),
|
||||||
|
path("ensembles", views.EnsembleListView.as_view(), name="ensemble_list"),
|
||||||
|
path(
|
||||||
|
"ensembles/<slug:ensemble>",
|
||||||
|
views.EnsembleDetailView.as_view(),
|
||||||
|
name="ensemble_detail",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"ensembles/<slug:ensemble>/new-project",
|
||||||
|
views.ProjectCreateView.as_view(),
|
||||||
|
name="project_create",
|
||||||
|
),
|
||||||
|
path("projects", views.ProjectListView.as_view(), name="project_list"),
|
||||||
|
path(
|
||||||
|
"projects/<int:project>",
|
||||||
|
views.ProjectDetailView.as_view(),
|
||||||
|
name="project_detail",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"projects/<int:project>/edit",
|
||||||
|
views.ProjectUpdateView.as_view(),
|
||||||
|
name="project_edit",
|
||||||
|
),
|
||||||
|
# path('projects/<int:project>/submissions.mk', views.ProjectMakefileView.as_view(), name="project_makefile"),
|
||||||
|
path(
|
||||||
|
"projects/<int:project>/page/create",
|
||||||
|
views.WikiCreateView.as_view(),
|
||||||
|
name="wiki_create",
|
||||||
|
),
|
||||||
|
path("projects/<int:project>/page/<int:pk>", views.WikiView.as_view(), name="wiki"),
|
||||||
|
path(
|
||||||
|
"projects/<int:project>/page/<int:pk>/edit",
|
||||||
|
views.WikiEditView.as_view(),
|
||||||
|
name="wiki_edit",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"projects/<int:project>/resources",
|
||||||
|
views.ResourceListView.as_view(),
|
||||||
|
name="resource_list",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"projects/<int:project>/resources/add",
|
||||||
|
views.ResourceCreateView.as_view(),
|
||||||
|
name="resource_create",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"projects/<int:project>/resources/<int:pk>/upload",
|
||||||
|
views.ResourceUploadView.as_view(),
|
||||||
|
name="resource_upload",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"projects/<int:project>/resources/<int:pk>/edit",
|
||||||
|
views.ResourceEditView.as_view(),
|
||||||
|
name="resource_edit",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
from django.views.static import serve
|
||||||
|
|
||||||
|
urlpatterns.append(
|
||||||
|
path("local_storage/<path:path>", serve, {"document_root": "local_storage"})
|
||||||
|
)
|
||||||
@ -1,18 +1,20 @@
|
|||||||
from django.shortcuts import resolve_url
|
from django.shortcuts import resolve_url
|
||||||
from django.core.signing import Signer
|
from django.core.signing import Signer
|
||||||
from django.core.exceptions import SuspiciousOperation
|
from django.core.exceptions import SuspiciousOperation
|
||||||
|
import logging
|
||||||
|
|
||||||
signer = Signer()
|
signer = Signer()
|
||||||
|
|
||||||
import logging
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def sign_data(data, l=None):
|
|
||||||
|
def sign_data(data, length=None):
|
||||||
sig = signer.sign(data)
|
sig = signer.sign(data)
|
||||||
p = len(data) + 1
|
pos = len(data) + 1
|
||||||
if l:
|
if length:
|
||||||
l += p
|
length += pos
|
||||||
return sig[p:l]
|
return sig[pos:length]
|
||||||
|
|
||||||
|
|
||||||
def signed_url(name, **kwargs):
|
def signed_url(name, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -23,9 +25,10 @@ def signed_url(name, **kwargs):
|
|||||||
sep = "&" if "?" in url else "?"
|
sep = "&" if "?" in url else "?"
|
||||||
return sig.replace(":", f"{sep}auth=")
|
return sig.replace(":", f"{sep}auth=")
|
||||||
|
|
||||||
|
|
||||||
def check_signed_url(full_path):
|
def check_signed_url(full_path):
|
||||||
p = full_path.rfind('auth')
|
p = full_path.rfind("auth")
|
||||||
url = full_path[:p-1]
|
url = full_path[: p - 1]
|
||||||
logger.debug("check_signed_url: %s", url)
|
logger.debug("check_signed_url: %s", url)
|
||||||
signed = signed_url(url)
|
signed = signed_url(url)
|
||||||
if signed != full_path:
|
if signed != full_path:
|
||||||
@ -33,6 +36,8 @@ def check_signed_url(full_path):
|
|||||||
signed = "_HIDDEN_"
|
signed = "_HIDDEN_"
|
||||||
raise SuspiciousOperation("Bad auth code")
|
raise SuspiciousOperation("Bad auth code")
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
|
if __name__ == "__main__":
|
||||||
import doctest
|
import doctest
|
||||||
|
|
||||||
print(doctest.testmod())
|
print(doctest.testmod())
|
||||||
@ -1,26 +1,27 @@
|
|||||||
"""
|
""" """
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
# pyright: basic
|
||||||
|
|
||||||
from django.shortcuts import get_object_or_404, redirect, resolve_url
|
from django.shortcuts import get_object_or_404, redirect, resolve_url
|
||||||
from django.views.generic import RedirectView
|
from django.views.generic import RedirectView
|
||||||
from django.views.generic.detail import DetailView, SingleObjectMixin
|
from django.views.generic.detail import DetailView, SingleObjectMixin
|
||||||
from django.views.generic.list import ListView
|
from django.views.generic.list import ListView
|
||||||
from django.views.generic.edit import CreateView, UpdateView, FormMixin
|
from django.views.generic.edit import CreateView, UpdateView
|
||||||
from django.core.exceptions import SuspiciousOperation
|
from django.core.exceptions import SuspiciousOperation
|
||||||
from django.http import Http404, HttpResponseRedirect
|
from django.http import Http404, HttpResponseRedirect
|
||||||
from django.db.models import Q
|
from django.http.request import HttpRequest
|
||||||
from django.utils import timezone
|
from django.contrib.auth import logout
|
||||||
|
|
||||||
from markdown2 import markdown
|
from markdown2 import markdown
|
||||||
|
|
||||||
from . import models, forms
|
from . import models, forms
|
||||||
from interface.utils import check_signed_url
|
from .utils import check_signed_url
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AuthorizedResourceMixin(object):
|
class AuthorizedResourceMixin(object):
|
||||||
"""
|
"""
|
||||||
Handles these parts of the permission system:
|
Handles these parts of the permission system:
|
||||||
@ -29,16 +30,20 @@ class AuthorizedResourceMixin(object):
|
|||||||
* Admin enforcing
|
* Admin enforcing
|
||||||
"""
|
"""
|
||||||
|
|
||||||
SESSION_KEY = 'authorized'
|
request: HttpRequest
|
||||||
|
kwargs: dict
|
||||||
|
_authorized: dict
|
||||||
|
|
||||||
|
SESSION_KEY = "authorized"
|
||||||
admin_required = False
|
admin_required = False
|
||||||
|
|
||||||
def is_authorized(self):
|
def is_authorized(self):
|
||||||
"By default check if superuser or a signed request"
|
"By default check if superuser or a signed request"
|
||||||
if self.request.is_admin:
|
if self.request.is_admin: # type: ignore
|
||||||
#logger.debug("is_authorized: superuser")
|
# logger.debug("is_authorized: superuser")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if 'sig' in self.request.GET:
|
if "sig" in self.request.GET:
|
||||||
check_signed_url(self.request.get_full_path())
|
check_signed_url(self.request.get_full_path())
|
||||||
self.on_signed_request()
|
self.on_signed_request()
|
||||||
return True
|
return True
|
||||||
@ -51,25 +56,25 @@ class AuthorizedResourceMixin(object):
|
|||||||
def is_authorized_key(self, resource, key, auth):
|
def is_authorized_key(self, resource, key, auth):
|
||||||
current = self.get_authorized_keys(resource).get(str(key), None)
|
current = self.get_authorized_keys(resource).get(str(key), None)
|
||||||
if current is None:
|
if current is None:
|
||||||
#logger.debug("is_authorized_key: %s %s not in session", resource, key)
|
# logger.debug("is_authorized_key: %s %s not in session", resource, key)
|
||||||
return False
|
return False
|
||||||
if auth == current:
|
if auth == current:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
#logger.info("Authorisation revoked")
|
# logger.info("Authorisation revoked")
|
||||||
self.del_authorized_key(resource, key)
|
self.del_authorized_key(resource, key)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_authorized_keys(self, resource):
|
def get_authorized_keys(self, resource):
|
||||||
'Returns a set of authorized keys for this resource'
|
"Returns a set of authorized keys for this resource"
|
||||||
return self._authorized.get(resource, {})
|
return self._authorized.get(resource, {})
|
||||||
|
|
||||||
def add_authorized_key(self, resource, key, auth):
|
def add_authorized_key(self, resource, key, auth):
|
||||||
'Adds a key to the authorized list for this resource'
|
"Adds a key to the authorized list for this resource"
|
||||||
current = self.get_authorized_keys(resource)
|
current = self.get_authorized_keys(resource)
|
||||||
current[str(key)] = auth
|
current[str(key)] = auth
|
||||||
self._authorized[resource] = current
|
self._authorized[resource] = current
|
||||||
self.request.session[self.SESSION_KEY] = self._authorized
|
self.request.session[self.SESSION_KEY] = self._authorized # type: ignore
|
||||||
|
|
||||||
def del_authorized_key(self, resource, key):
|
def del_authorized_key(self, resource, key):
|
||||||
logger.info("Revoking authorization for %s %s", resource, key)
|
logger.info("Revoking authorization for %s %s", resource, key)
|
||||||
@ -80,15 +85,16 @@ class AuthorizedResourceMixin(object):
|
|||||||
self._authorized[resource] = current
|
self._authorized[resource] = current
|
||||||
else:
|
else:
|
||||||
self._authorized.pop(resource)
|
self._authorized.pop(resource)
|
||||||
self.request.session[self.SESSION_KEY] = self._authorized
|
self.request.session[self.SESSION_KEY] = self._authorized # type: ignore
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def request_denied(self):
|
def request_denied(self):
|
||||||
raise Http404("Either the given resource doesn't exist or you dont have access to it.")
|
raise Http404(
|
||||||
|
"Either the given resource doesn't exist or you dont have access to it."
|
||||||
|
)
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
self._authorized = request.session.get("authorized", {})
|
||||||
self._authorized = request.session.get('authorized', {})
|
|
||||||
request.is_admin = request.user.is_superuser
|
request.is_admin = request.user.is_superuser
|
||||||
|
|
||||||
if not self.is_authorized():
|
if not self.is_authorized():
|
||||||
@ -97,12 +103,18 @@ class AuthorizedResourceMixin(object):
|
|||||||
if self.admin_required and not request.is_admin:
|
if self.admin_required and not request.is_admin:
|
||||||
return self.request_denied()
|
return self.request_denied()
|
||||||
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
# TODO: RevokeResourceView - increment nonce
|
# TODO: RevokeResourceView - increment nonce
|
||||||
|
|
||||||
class ForgetResourceView(AuthorizedResourceMixin, RedirectView):
|
|
||||||
|
|
||||||
|
def logout_view(request):
|
||||||
|
logout(request)
|
||||||
|
return redirect("/")
|
||||||
|
|
||||||
|
|
||||||
|
class ForgetResourceView(AuthorizedResourceMixin, RedirectView):
|
||||||
def is_authorized(self):
|
def is_authorized(self):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -110,9 +122,9 @@ class ForgetResourceView(AuthorizedResourceMixin, RedirectView):
|
|||||||
self.del_authorized_key(resource, key)
|
self.del_authorized_key(resource, key)
|
||||||
return "/"
|
return "/"
|
||||||
|
|
||||||
class EnsembleMixin(AuthorizedResourceMixin):
|
|
||||||
|
|
||||||
ensemble_slug_kwarg = 'ensemble'
|
class EnsembleMixin(AuthorizedResourceMixin):
|
||||||
|
ensemble_slug_kwarg = "ensemble"
|
||||||
limited_project_access = False
|
limited_project_access = False
|
||||||
|
|
||||||
def is_authorized(self):
|
def is_authorized(self):
|
||||||
@ -122,15 +134,15 @@ class EnsembleMixin(AuthorizedResourceMixin):
|
|||||||
if super().is_authorized():
|
if super().is_authorized():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if self.ensemble.has_admin(self.request.user):
|
if self.ensemble.has_admin(self.request.user): # type: ignore
|
||||||
self.request.is_admin = True
|
self.request.is_admin = True # type: ignore
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if self.is_authorized_key('ensemble', ensemble_slug, self.ensemble.nonce):
|
if self.is_authorized_key("ensemble", ensemble_slug, self.ensemble.nonce):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
authorized = set([ int(x) for x in self.get_authorized_keys('project').keys() ])
|
authorized = set([int(x) for x in self.get_authorized_keys("project").keys()])
|
||||||
projects = set(self.ensemble.projects.values_list('pk', flat=True))
|
projects = set(self.ensemble.projects.values_list("pk", flat=True))
|
||||||
logger.debug("is_authorized: %r & %r", authorized, projects)
|
logger.debug("is_authorized: %r & %r", authorized, projects)
|
||||||
if authorized & projects:
|
if authorized & projects:
|
||||||
self.limited_project_access = True
|
self.limited_project_access = True
|
||||||
@ -138,131 +150,131 @@ class EnsembleMixin(AuthorizedResourceMixin):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_object(self):
|
def get_object(self, queryset=None):
|
||||||
return self.ensemble
|
return self.ensemble
|
||||||
|
|
||||||
|
|
||||||
class ProjectMixin(AuthorizedResourceMixin):
|
class ProjectMixin(AuthorizedResourceMixin):
|
||||||
|
project_kwarg = "project"
|
||||||
project_kwarg = 'project'
|
|
||||||
|
|
||||||
def is_authorized(self):
|
def is_authorized(self):
|
||||||
project_id = self.kwargs[self.project_kwarg]
|
project_id = self.kwargs[self.project_kwarg]
|
||||||
self.project = get_object_or_404(models.Project.objects.select_related('ensemble'), pk=project_id)
|
self.project = get_object_or_404(
|
||||||
|
models.Project.objects.select_related("ensemble"), pk=project_id
|
||||||
|
)
|
||||||
|
|
||||||
if super().is_authorized():
|
if super().is_authorized():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
# check if the current user is an admin on the ensemble
|
# check if the current user is an admin on the ensemble
|
||||||
if self.project.ensemble.has_admin(self.request.user):
|
if self.project.ensemble.has_admin(self.request.user): # type: ignore
|
||||||
logger.debug("is_authorized: ensemble admin for project")
|
logger.debug("is_authorized: ensemble admin for project")
|
||||||
self.request.is_admin = True
|
self.request.is_admin = True # type: ignore
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if self.is_authorized_key('ensemble', self.project.ensemble.pk, self.project.ensemble.nonce):
|
if self.is_authorized_key(
|
||||||
logger.debug('is_authorized: has ensemble link for project')
|
"ensemble", self.project.ensemble.pk, self.project.ensemble.nonce
|
||||||
|
):
|
||||||
|
logger.debug("is_authorized: has ensemble link for project")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if self.is_authorized_key('project', project_id, self.project.nonce):
|
if self.is_authorized_key("project", project_id, self.project.nonce):
|
||||||
logger.debug('is_authorized: has project link')
|
logger.debug("is_authorized: has project link")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# filter any generated querysets
|
# filter any generated querysets
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return super().get_queryset().filter(project=self.project)
|
return super().get_queryset().filter(project=self.project) # type: ignore
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs) # type: ignore
|
||||||
if 'project' in self.kwargs:
|
if "project" in self.kwargs:
|
||||||
context['project'] = self.project
|
context["project"] = self.project
|
||||||
context['modules'] = self.project.active_modules
|
context["modules"] = self.project.active_modules
|
||||||
return context
|
return context
|
||||||
|
|
||||||
class CrispyFormMixin(object):
|
|
||||||
|
|
||||||
|
class CrispyFormMixin(object):
|
||||||
cancel_url = None
|
cancel_url = None
|
||||||
|
|
||||||
def get_cancel_url(self):
|
def get_cancel_url(self):
|
||||||
return self.cancel_url
|
return self.cancel_url
|
||||||
|
|
||||||
|
|
||||||
""" ENSEMBLE VIEWS """
|
""" ENSEMBLE VIEWS """
|
||||||
|
|
||||||
class EnsembleListView(AuthorizedResourceMixin, ListView):
|
|
||||||
|
|
||||||
|
class EnsembleListView(AuthorizedResourceMixin, ListView):
|
||||||
def is_authorized(self):
|
def is_authorized(self):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return models.Ensemble.objects.for_user(self.request.user,
|
return models.Ensemble.objects.for_user(
|
||||||
self.get_authorized_keys('ensemble').keys(),
|
self.request.user, # type: ignore
|
||||||
self.get_authorized_keys('project').keys())
|
self.get_authorized_keys("ensemble").keys(),
|
||||||
#ensembles = models.Ensemble.objects.all()
|
self.get_authorized_keys("project").keys(),
|
||||||
|
)
|
||||||
|
|
||||||
#if self.request.is_admin:
|
|
||||||
# return ensembles
|
|
||||||
|
|
||||||
# limit to registered ensembles
|
|
||||||
#f = Q(slug__in=self.get_authorized_keys('ensemble').keys()) | Q(projects__in=self.get_authorized_keys('project').keys())
|
|
||||||
|
|
||||||
# or ensembles where the user is admin
|
|
||||||
#if self.request.user.is_authenticated:
|
|
||||||
# f |= Q(admins=self.request.user.pk)
|
|
||||||
|
|
||||||
#return ensembles.filter(f).distinct()
|
|
||||||
|
|
||||||
class EnsembleDetailView(EnsembleMixin, DetailView):
|
class EnsembleDetailView(EnsembleMixin, DetailView):
|
||||||
|
|
||||||
def request_denied(self):
|
def request_denied(self):
|
||||||
if 'auth' in self.request.GET:
|
if "auth" in self.request.GET:
|
||||||
if self.request.GET['auth'] != self.ensemble.auth():
|
if self.request.GET["auth"] != self.ensemble.auth():
|
||||||
raise SuspiciousOperation("Bad ensemble link")
|
raise SuspiciousOperation("Bad ensemble link")
|
||||||
self.add_authorized_key('ensemble', self.ensemble.slug, self.ensemble.nonce)
|
self.add_authorized_key("ensemble", self.ensemble.slug, self.ensemble.nonce)
|
||||||
return HttpResponseRedirect(resolve_url('ensemble_detail', self.ensemble.slug))
|
return HttpResponseRedirect(
|
||||||
|
resolve_url("ensemble_detail", self.ensemble.slug)
|
||||||
|
)
|
||||||
|
|
||||||
return super().request_denied()
|
return super().request_denied()
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
data = super().get_context_data(**kwargs)
|
data = super().get_context_data(**kwargs)
|
||||||
inactive = 'inactive' in self.request.GET and self.request.is_admin
|
inactive = "inactive" in self.request.GET and self.request.is_admin # type: ignore
|
||||||
|
|
||||||
projects = self.ensemble.projects.all()
|
projects = self.ensemble.projects.all()
|
||||||
if self.limited_project_access:
|
if self.limited_project_access:
|
||||||
projects = projects.filter(pk__in=self.get_authorized_keys('project').keys())
|
projects = projects.filter(
|
||||||
|
pk__in=self.get_authorized_keys("project").keys()
|
||||||
|
)
|
||||||
|
|
||||||
if inactive:
|
if inactive:
|
||||||
projects = projects.order_by('-pk')
|
projects = projects.order_by("-pk")
|
||||||
else:
|
else:
|
||||||
projects = projects.active().current()
|
projects = projects.active().current()
|
||||||
|
|
||||||
data['inactive'] = inactive
|
data["inactive"] = inactive
|
||||||
data['object_list'] = projects
|
data["object_list"] = projects
|
||||||
if self.request.is_admin:
|
if self.request.is_admin: # type: ignore
|
||||||
data['ensemble_link'] = self.request.path + "?auth=" + self.ensemble.auth()
|
data["ensemble_link"] = self.request.path + "?auth=" + self.ensemble.auth()
|
||||||
return data
|
return data
|
||||||
|
|
||||||
class EnsembleRevokeView(SingleObjectMixin, RedirectView):
|
|
||||||
|
|
||||||
|
class EnsembleRevokeView(SingleObjectMixin, RedirectView):
|
||||||
def get_redirect_url(self):
|
def get_redirect_url(self):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
""" PROJECT VIEWS """
|
""" PROJECT VIEWS """
|
||||||
|
|
||||||
class ProjectListView(ProjectMixin, ListView):
|
|
||||||
|
|
||||||
|
class ProjectListView(ProjectMixin, ListView):
|
||||||
def is_authorized(self):
|
def is_authorized(self):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_project_queryset(self):
|
def get_project_queryset(self):
|
||||||
return models.Project.objects.for_user(self.request.user,
|
return models.Project.objects.for_user(
|
||||||
self.get_authorized_keys('project'),
|
self.request.user, # type: ignore
|
||||||
self.get_authorized_keys('ensemble'))
|
self.get_authorized_keys("project"),
|
||||||
|
self.get_authorized_keys("ensemble"),
|
||||||
|
)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.get_project_queryset().current().active()
|
return self.get_project_queryset().current().active()
|
||||||
|
|
||||||
|
|
||||||
class ProjectCreateView(EnsembleMixin, CreateView):
|
class ProjectCreateView(EnsembleMixin, CreateView):
|
||||||
admin_required = True
|
admin_required = True
|
||||||
model = models.Project
|
model = models.Project
|
||||||
@ -270,26 +282,25 @@ class ProjectCreateView(EnsembleMixin, CreateView):
|
|||||||
title = "Add a new project"
|
title = "Add a new project"
|
||||||
form_class = forms.ProjectForm
|
form_class = forms.ProjectForm
|
||||||
|
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
self.object = form.save(commit=False)
|
self.object = form.save(commit=False)
|
||||||
self.object.ensemble = self.ensemble
|
self.object.ensemble = self.ensemble
|
||||||
self.object.owner = self.request.user
|
self.object.owner = self.request.user # type: ignore
|
||||||
self.object.save()
|
self.object.save()
|
||||||
|
|
||||||
for module in form.cleaned_data['modules']:
|
for module in form.cleaned_data["modules"]:
|
||||||
self.object.modules.create(name=module)
|
self.object.modules.create(name=module)
|
||||||
|
|
||||||
return redirect('project_detail', project=self.object.pk)
|
return redirect("project_detail", project=self.object.pk)
|
||||||
|
|
||||||
|
|
||||||
class ProjectDetailView(ProjectMixin, DetailView):
|
class ProjectDetailView(ProjectMixin, DetailView):
|
||||||
|
|
||||||
def request_denied(self):
|
def request_denied(self):
|
||||||
if 'auth' in self.request.GET:
|
if "auth" in self.request.GET:
|
||||||
if self.request.GET['auth'] != self.project.auth():
|
if self.request.GET["auth"] != self.project.auth():
|
||||||
raise SuspiciousOperation("Bad project link")
|
raise SuspiciousOperation("Bad project link")
|
||||||
self.add_authorized_key('project', self.project.pk, self.project.nonce)
|
self.add_authorized_key("project", self.project.pk, self.project.nonce)
|
||||||
return HttpResponseRedirect(resolve_url('project_detail', self.project.pk))
|
return HttpResponseRedirect(resolve_url("project_detail", self.project.pk))
|
||||||
return super().request_denied()
|
return super().request_denied()
|
||||||
|
|
||||||
def get_object(self, queryset=None):
|
def get_object(self, queryset=None):
|
||||||
@ -297,15 +308,16 @@ class ProjectDetailView(ProjectMixin, DetailView):
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
data = super().get_context_data(**kwargs)
|
data = super().get_context_data(**kwargs)
|
||||||
if self.request.is_admin:
|
if self.request.is_admin: # type: ignore
|
||||||
#data['project_link'] = signed_url(f'{self.request.path}?nonce={self.project.nonce}')
|
# data['project_link'] = signed_url(f'{self.request.path}?nonce={self.project.nonce}')
|
||||||
data['project_link'] = self.request.path + "?auth=" + self.project.auth()
|
data["project_link"] = self.request.path + "?auth=" + self.project.auth()
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdateView(ProjectMixin, UpdateView):
|
class ProjectUpdateView(ProjectMixin, UpdateView):
|
||||||
admin_required = True
|
admin_required = True
|
||||||
template_name = "interface/default_form.html"
|
template_name = "interface/default_form.html"
|
||||||
pk_url_kwarg = 'project'
|
pk_url_kwarg = "project"
|
||||||
form_class = forms.ProjectForm
|
form_class = forms.ProjectForm
|
||||||
|
|
||||||
def get_object(self):
|
def get_object(self):
|
||||||
@ -313,26 +325,26 @@ class ProjectUpdateView(ProjectMixin, UpdateView):
|
|||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
data = super().get_initial()
|
data = super().get_initial()
|
||||||
data['modules'] = self.object.active_modules
|
data["modules"] = self.object.active_modules
|
||||||
print(data)
|
print(data)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
self.object = form.save()
|
self.object = form.save()
|
||||||
current = set(self.object.active_modules)
|
current = set(self.object.active_modules)
|
||||||
desired = set(form.cleaned_data['modules'])
|
desired = set(form.cleaned_data["modules"])
|
||||||
self.object.modules.exclude(name__in=desired).delete()
|
self.object.modules.exclude(name__in=desired).delete()
|
||||||
for module in desired-current:
|
for module in desired - current:
|
||||||
self.object.modules.create(name=module)
|
self.object.modules.create(name=module)
|
||||||
return redirect('project_detail', self.kwargs['project'])
|
return redirect("project_detail", self.kwargs["project"])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cancel_url(self):
|
def cancel_url(self):
|
||||||
return resolve_url('project_detail', self.kwargs['project'])
|
return resolve_url("project_detail", self.kwargs["project"])
|
||||||
|
|
||||||
|
|
||||||
# Old Makefile from submission module
|
# Old Makefile from submission module
|
||||||
#class ProjectMakefileView(EnsembleMixin, DetailView):
|
# class ProjectMakefileView(EnsembleMixin, DetailView):
|
||||||
# template_name = 'interface/project_submissions.mk'
|
# template_name = 'interface/project_submissions.mk'
|
||||||
# content_type = 'text/plain'
|
# content_type = 'text/plain'
|
||||||
#
|
#
|
||||||
@ -359,29 +371,32 @@ class ProjectUpdateView(ProjectMixin, UpdateView):
|
|||||||
|
|
||||||
""" WIKI VIEWS """
|
""" WIKI VIEWS """
|
||||||
|
|
||||||
|
|
||||||
class WikiView(ProjectMixin, DetailView):
|
class WikiView(ProjectMixin, DetailView):
|
||||||
template_name = 'interface/wiki.html'
|
template_name = "interface/wiki.html"
|
||||||
model = models.WikiPage
|
model = models.WikiPage
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
data = super().get_context_data(**kwargs)
|
data = super().get_context_data(**kwargs)
|
||||||
data['wiki_html'] = markdown(self.object.markdown)
|
data["wiki_html"] = markdown(self.object.markdown)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
class WikiCreateView(ProjectMixin, CreateView):
|
class WikiCreateView(ProjectMixin, CreateView):
|
||||||
admin_required = True
|
admin_required = True
|
||||||
model = models.WikiPage
|
model = models.WikiPage
|
||||||
template_name = 'interface/default_form.html'
|
template_name = "interface/default_form.html"
|
||||||
form_class = forms.WikiForm
|
form_class = forms.WikiForm
|
||||||
|
|
||||||
def cancel_url(self):
|
def cancel_url(self):
|
||||||
return resolve_url('project_detail', self.kwargs['project'])
|
return resolve_url("project_detail", self.kwargs["project"])
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
self.object = form.save(commit=False)
|
self.object = form.save(commit=False)
|
||||||
self.object.project = self.project
|
self.object.project = self.project
|
||||||
self.object.save()
|
self.object.save()
|
||||||
return redirect('wiki', project=self.object.project_id, pk=self.object.pk)
|
return redirect("wiki", project=self.object.project_id, pk=self.object.pk)
|
||||||
|
|
||||||
|
|
||||||
class WikiEditView(ProjectMixin, UpdateView):
|
class WikiEditView(ProjectMixin, UpdateView):
|
||||||
admin_required = True
|
admin_required = True
|
||||||
@ -389,46 +404,53 @@ class WikiEditView(ProjectMixin, UpdateView):
|
|||||||
form_class = forms.WikiForm
|
form_class = forms.WikiForm
|
||||||
|
|
||||||
def cancel_url(self):
|
def cancel_url(self):
|
||||||
return resolve_url('wiki', self.kwargs['project'], self.kwargs['pk'])
|
return resolve_url("wiki", self.kwargs["project"], self.kwargs["pk"])
|
||||||
|
|
||||||
|
|
||||||
""" RESOURCE VIEWS """
|
""" RESOURCE VIEWS """
|
||||||
|
|
||||||
|
|
||||||
class ResourceCreateView(ProjectMixin, CreateView):
|
class ResourceCreateView(ProjectMixin, CreateView):
|
||||||
admin_required = True
|
admin_required = True
|
||||||
model = models.Resource
|
model = models.Resource
|
||||||
form_class = forms.ResourceForm
|
form_class = forms.ResourceForm
|
||||||
template_name = 'interface/default_form.html'
|
template_name = "interface/default_form.html"
|
||||||
title = "Add a new resource"
|
title = "Add a new resource"
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
self.object = form.save(commit=False)
|
self.object = form.save(commit=False)
|
||||||
self.object.project = self.project
|
self.object.project = self.project
|
||||||
self.object.save()
|
self.object.save()
|
||||||
return redirect('resource_upload', project=self.object.project_id, pk=self.object.pk)
|
return redirect(
|
||||||
|
"resource_upload", project=self.object.project_id, pk=self.object.pk
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ResourceUploadView(ProjectMixin, UpdateView):
|
class ResourceUploadView(ProjectMixin, UpdateView):
|
||||||
admin_required = True
|
admin_required = True
|
||||||
model = models.Resource
|
model = models.Resource
|
||||||
fields = ['file']
|
fields = ["file"]
|
||||||
template_name = 'interface/default_form.html'
|
template_name = "interface/default_form.html"
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
return resolve_url('resource_list', project=self.kwargs['project'])
|
return resolve_url("resource_list", project=self.kwargs["project"])
|
||||||
|
|
||||||
|
|
||||||
class ResourceListView(ProjectMixin, ListView):
|
class ResourceListView(ProjectMixin, ListView):
|
||||||
model = models.Resource
|
model = models.Resource
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = super().get_queryset()
|
qs = super().get_queryset()
|
||||||
if not self.request.is_admin:
|
if not self.request.is_admin: # type: ignore
|
||||||
qs = qs.filter(visible=True)
|
qs = qs.filter(visible=True)
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
|
|
||||||
class ResourceEditView(ProjectMixin, UpdateView):
|
class ResourceEditView(ProjectMixin, UpdateView):
|
||||||
admin_required = True
|
admin_required = True
|
||||||
model = models.Resource
|
model = models.Resource
|
||||||
fields = ['name', 'description', 'visible']
|
fields = ["name", "description", "visible"]
|
||||||
template_name = 'interface/default_form.html'
|
template_name = "interface/default_form.html"
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
return resolve_url('resource_list', project=self.kwargs['project'])
|
return resolve_url("resource_list", project=self.kwargs["project"])
|
||||||
@ -2,60 +2,77 @@ from django.contrib import admin
|
|||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
class EnsembleAccessInline(admin.StackedInline):
|
class EnsembleAccessInline(admin.StackedInline):
|
||||||
model = models.EnsembleAccess
|
model = models.EnsembleAccess
|
||||||
extra = 0
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
class CollectionAdmin(admin.ModelAdmin):
|
class CollectionAdmin(admin.ModelAdmin):
|
||||||
list_display = ['name', 'location', 'storage', 'prefix']
|
list_display = ["name", "location", "storage", "prefix"]
|
||||||
inlines = [EnsembleAccessInline]
|
inlines = [EnsembleAccessInline]
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(models.Collection, CollectionAdmin)
|
admin.site.register(models.Collection, CollectionAdmin)
|
||||||
|
|
||||||
|
|
||||||
class ItemInline(admin.TabularInline):
|
class ItemInline(admin.TabularInline):
|
||||||
model = models.ProjectItem
|
model = models.ProjectItem
|
||||||
extra = 0
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
class DocInline(admin.TabularInline):
|
class DocInline(admin.TabularInline):
|
||||||
model = models.Document
|
model = models.Document
|
||||||
extra = 0
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
class MetaInline(admin.TabularInline):
|
class MetaInline(admin.TabularInline):
|
||||||
model = models.WorkMeta
|
model = models.WorkMeta
|
||||||
extra = 0
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
class WorkAdmin(admin.ModelAdmin):
|
class WorkAdmin(admin.ModelAdmin):
|
||||||
list_display = ['name', 'composer', 'edition', 'identifier', 'running_time']
|
list_display = ["name", "composer", "edition", "identifier", "running_time"]
|
||||||
list_filter = ['collection']
|
list_filter = ["collection"]
|
||||||
search_fields = ['name', 'composer']
|
search_fields = ["name", "composer"]
|
||||||
inlines = [MetaInline, DocInline, ItemInline]
|
inlines = [MetaInline, DocInline, ItemInline]
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(models.Work, WorkAdmin)
|
admin.site.register(models.Work, WorkAdmin)
|
||||||
|
|
||||||
|
|
||||||
class SectionInline(admin.TabularInline):
|
class SectionInline(admin.TabularInline):
|
||||||
model = models.Section
|
model = models.Section
|
||||||
fields = ['tag', 'start', 'end', 'page']
|
fields = ["tag", "start", "end", "page"]
|
||||||
|
|
||||||
|
|
||||||
class DocumentAdmin(admin.ModelAdmin):
|
class DocumentAdmin(admin.ModelAdmin):
|
||||||
list_display = ['work', '__str__']
|
list_display = ["work", "__str__"]
|
||||||
list_filter = ['work__collection']
|
list_filter = ["work__collection"]
|
||||||
inlines = [SectionInline]
|
inlines = [SectionInline]
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(models.Document, DocumentAdmin)
|
admin.site.register(models.Document, DocumentAdmin)
|
||||||
|
|
||||||
|
|
||||||
class ItemAdmin(admin.ModelAdmin):
|
class ItemAdmin(admin.ModelAdmin):
|
||||||
list_display = ['project', 'work', 'order']
|
list_display = ["project", "work", "order"]
|
||||||
list_filter = ['project']
|
list_filter = ["project"]
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(models.ProjectItem, ItemAdmin)
|
admin.site.register(models.ProjectItem, ItemAdmin)
|
||||||
|
|
||||||
|
|
||||||
class EnsembleAccessAdmin(admin.ModelAdmin):
|
class EnsembleAccessAdmin(admin.ModelAdmin):
|
||||||
list_display = ['ensemble', 'collection', 'access_type']
|
list_display = ["ensemble", "collection", "access_type"]
|
||||||
list_filter = ['ensemble']
|
list_filter = ["ensemble"]
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(models.EnsembleAccess, EnsembleAccessAdmin)
|
admin.site.register(models.EnsembleAccess, EnsembleAccessAdmin)
|
||||||
|
|
||||||
|
|
||||||
class OrchestrationAdmin(admin.ModelAdmin):
|
class OrchestrationAdmin(admin.ModelAdmin):
|
||||||
list_display = ['name', 'instruments']
|
list_display = ["name", "instruments"]
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(models.Orchestration, OrchestrationAdmin)
|
admin.site.register(models.Orchestration, OrchestrationAdmin)
|
||||||
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
|||||||
|
|
||||||
|
|
||||||
class LibraryConfig(AppConfig):
|
class LibraryConfig(AppConfig):
|
||||||
name = 'library'
|
name = "polyphonic.library"
|
||||||
54
polyphonic/library/forms.py
Normal file
54
polyphonic/library/forms.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
from django import forms
|
||||||
|
from .models import Work
|
||||||
|
from polyphonic.interface.models import Project
|
||||||
|
from polyphonic.interface.forms import BaseForm
|
||||||
|
|
||||||
|
|
||||||
|
class WorkCreateForm(forms.ModelForm, BaseForm):
|
||||||
|
class Meta:
|
||||||
|
model = Work
|
||||||
|
fields = [
|
||||||
|
"name",
|
||||||
|
"composer",
|
||||||
|
"edition",
|
||||||
|
"code",
|
||||||
|
"orchestration",
|
||||||
|
"licence",
|
||||||
|
"running_time",
|
||||||
|
"notes",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistAddForm(forms.Form):
|
||||||
|
work = forms.ModelChoiceField(queryset=Work.objects.all())
|
||||||
|
|
||||||
|
def __init__(self, instance, *args, **kwargs):
|
||||||
|
super(PlaylistAddForm, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
existing = [x[0] for x in instance.works.values_list("pk")]
|
||||||
|
|
||||||
|
qs = Work.objects.filter(ensemble_id=instance.ensemble_id).exclude(
|
||||||
|
id__in=existing
|
||||||
|
)
|
||||||
|
self.fields["work"].queryset = qs
|
||||||
|
self.instance = instance
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
self.instance.works.add(self.cleaned_data["work"])
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectEnsembleChoiceField(forms.ModelChoiceField):
|
||||||
|
def label_from_instance(self, obj):
|
||||||
|
return f"{obj.ensemble.name} - {obj.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectSelectForm(BaseForm):
|
||||||
|
project = ProjectEnsembleChoiceField(queryset=Project.objects.all())
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentLinkForm(BaseForm):
|
||||||
|
link = forms.URLField(help_text="Paste the direct link relevant to this storage")
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentBulkForm(BaseForm):
|
||||||
|
folder_link = forms.URLField(help_text="Paste the folder link for this storage")
|
||||||
90
polyphonic/library/gdrive/__init__.py
Normal file
90
polyphonic/library/gdrive/__init__.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
from polyphonic.library.models import Collection, Work, WorkMeta, Document
|
||||||
|
from byostorage.models import UserStorage
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_work(work: Work):
|
||||||
|
try:
|
||||||
|
folder_id = work.meta_info.get(name="folderid").value
|
||||||
|
except WorkMeta.DoesNotExist as err:
|
||||||
|
raise IndexError("Work not currently linked to a gdrive folder") from err
|
||||||
|
|
||||||
|
logger.info("Syncing '%s' from %r", work.name, folder_id)
|
||||||
|
|
||||||
|
storage = UserStorage.objects.get(name="gdrive").instance()
|
||||||
|
|
||||||
|
existing = set(
|
||||||
|
[
|
||||||
|
storage.parse_resource(x.partition(":")[2]).id
|
||||||
|
for x in work.docs.values_list("upload", flat=True)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger.debug("%d existing documents", len(existing))
|
||||||
|
|
||||||
|
_, files = storage.listdir(folder_id)
|
||||||
|
logger.debug("Remote files: %r", files)
|
||||||
|
|
||||||
|
for file in files:
|
||||||
|
if file.id in existing:
|
||||||
|
logger.debug("%30s: Skipping existing (%s)", file.name, file.id)
|
||||||
|
existing.discard(file.id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not file.name.lower().endswith(".pdf"):
|
||||||
|
logger.debug("%40s: Not a PDF", file.name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info("%40s: Adding", file.name)
|
||||||
|
doc = work.docs.create(upload=f"gdrive:{file}", doctype=Document.DOCTYPE_PDF)
|
||||||
|
doc.auto_tag()
|
||||||
|
|
||||||
|
for uri in existing:
|
||||||
|
logger.warning("Local entry not in folder: %s", uri)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_partial_collection(collection: Collection, sync_existing: bool = True):
|
||||||
|
|
||||||
|
works = Work.objects.filter(collection=collection, meta_info__name="folderid")
|
||||||
|
|
||||||
|
for work in works:
|
||||||
|
sync_work(work)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_collection(collection: Collection, sync_existing: bool = False):
|
||||||
|
logger.info("Syncing '%s'", collection)
|
||||||
|
|
||||||
|
if not collection.storage.storage.endswith("GDriveLinkStorage"):
|
||||||
|
return sync_partial_collection(collection, sync_existing)
|
||||||
|
|
||||||
|
existing = dict(
|
||||||
|
WorkMeta.objects.filter(
|
||||||
|
work__collection=collection, name="folderid"
|
||||||
|
).values_list("value", "work_id")
|
||||||
|
)
|
||||||
|
|
||||||
|
folder = collection.prefix
|
||||||
|
storage = collection.storage.instance()
|
||||||
|
folders, _ = storage.listdir(folder)
|
||||||
|
|
||||||
|
for folder in folders:
|
||||||
|
if folder[0] == "_":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if folder.id in existing:
|
||||||
|
if sync_existing:
|
||||||
|
logger.info("%40s: Syncing (%s)", folder.name, folder.id[:12])
|
||||||
|
sync_work(Work.objects.get(pk=existing[folder.id]))
|
||||||
|
del existing[folder.id]
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info("%40s: Adding", folder.name)
|
||||||
|
work = Work(name=folder.name, collection=collection)
|
||||||
|
work.save()
|
||||||
|
work.meta_info.create(name="folderid", value=folder.id)
|
||||||
|
sync_work(work)
|
||||||
|
|
||||||
|
for folderid, work in existing:
|
||||||
|
logger.warning("Folder for work %d no longer in drive (%s)", work, folderid)
|
||||||
141
polyphonic/library/gdrive/storage.py
Normal file
141
polyphonic/library/gdrive/storage.py
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
from django.core.files.storage import Storage
|
||||||
|
from collections import namedtuple
|
||||||
|
import requests
|
||||||
|
import re
|
||||||
|
from gzip import GzipFile
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SHARED_FOLDER = re.compile(
|
||||||
|
r"https://drive.google.com/drive[u0-9\/]+folders/([\w\-]+)(\?resourcekey=([\w\-]+))?"
|
||||||
|
)
|
||||||
|
SHARED_FILE = re.compile(
|
||||||
|
r"https://drive.google.com/file/d/([\w\-]+)(\?resourcekey=([\w\-]+))?"
|
||||||
|
)
|
||||||
|
|
||||||
|
FILES_API = "https://www.googleapis.com/drive/v3/files"
|
||||||
|
|
||||||
|
|
||||||
|
class DriveObject(namedtuple("DriveObject", ("id", "key", "name"))):
|
||||||
|
@classmethod
|
||||||
|
def from_string(cls, s: str):
|
||||||
|
resource, _, name = s.partition("/")
|
||||||
|
id, _, key = resource.partition("#")
|
||||||
|
return cls(id, key, name)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
resource = f"{self.id}#{self.key}" if self.key else self.id
|
||||||
|
return f"{resource}/{self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class GDriveLinkStorage(Storage):
|
||||||
|
is_writable = False
|
||||||
|
|
||||||
|
def __init__(self, api_key):
|
||||||
|
self.api_key = api_key
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def parse_resource(self, name) -> DriveObject:
|
||||||
|
return DriveObject.from_string(name)
|
||||||
|
|
||||||
|
def extract_resource(self, url, *patterns) -> DriveObject:
|
||||||
|
logger.debug("EXTRACT_RESOURCE: %r", url)
|
||||||
|
for pattern in patterns:
|
||||||
|
match = pattern.match(url)
|
||||||
|
if match:
|
||||||
|
groups = match.groups()
|
||||||
|
logger.debug(groups)
|
||||||
|
if len(groups) == 3:
|
||||||
|
return DriveObject(groups[0], groups[2], "")
|
||||||
|
return DriveObject(groups[0], None, "")
|
||||||
|
raise FileNotFoundError(f"Not a valid url: {url}")
|
||||||
|
|
||||||
|
def get_json(self, url, resource: DriveObject):
|
||||||
|
headers = {}
|
||||||
|
if resource.key:
|
||||||
|
headers["X-Goog-Drive-Resource-Keys"] = f"{resource.id}/{resource.key}"
|
||||||
|
|
||||||
|
logger.debug("GET_JSON: %s %r", url, headers)
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
data = response.json()
|
||||||
|
logger.debug("Data: %r", data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def listdir(self, path) -> tuple[list[str], list[str]]:
|
||||||
|
# used to test for valid connection parameters - should do something to validate API key here
|
||||||
|
logger.debug("listdir: %s", path)
|
||||||
|
if path == "":
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
folder = self.parse_resource(path)
|
||||||
|
url = f"{FILES_API}?q='{folder.id}'+in+parents&key={self.api_key}"
|
||||||
|
data = self.get_json(url, folder)
|
||||||
|
files = []
|
||||||
|
folders = []
|
||||||
|
for x in data["files"]:
|
||||||
|
if x["mimeType"] == "application/vnd.google-apps.folder":
|
||||||
|
# folders.append(f"{x['id']}/{x['name']}")
|
||||||
|
folders.append(DriveObject(x["id"], x.get("resourceKey"), x["name"]))
|
||||||
|
else:
|
||||||
|
# files.append(f"{x['id']}/{x['name']}")
|
||||||
|
files.append(DriveObject(x["id"], x.get("resourceKey"), x["name"]))
|
||||||
|
|
||||||
|
return folders, files
|
||||||
|
|
||||||
|
def get_meta(self, name):
|
||||||
|
file_resource = self.parse_resource(name)
|
||||||
|
url = f"{FILES_API}/{file_resource.id}?key={self.api_key}"
|
||||||
|
return self.get_json(url, file_resource)
|
||||||
|
|
||||||
|
def open(self, name, mode="rb"):
|
||||||
|
resource = self.parse_resource(name)
|
||||||
|
url = f"{FILES_API}/{resource.id}?alt=media&key={self.api_key}"
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
if resource.key:
|
||||||
|
headers["X-Goog-Drive-Resource-Keys"] = f"{resource.id}/{resource.key}"
|
||||||
|
logger.info("URL: %s [%r]", url, headers)
|
||||||
|
|
||||||
|
response = requests.get(url, headers=headers, stream=True)
|
||||||
|
return GzipFile(name, "rb", 9, response.raw)
|
||||||
|
|
||||||
|
def size(self, name):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def delete(self, name):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def url(self, name):
|
||||||
|
logger.debug("URL: %r", name)
|
||||||
|
resource = self.parse_resource(name)
|
||||||
|
uri = f"https://drive.usercontent.google.com/download?export=download&id={resource.id}&confirm=yes"
|
||||||
|
if resource.key:
|
||||||
|
uri += f"&resourcekey=${resource.key}"
|
||||||
|
return uri
|
||||||
|
|
||||||
|
def get_folder_id(self, url):
|
||||||
|
try:
|
||||||
|
return self.extract_resource(url, SHARED_FOLDER)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_file_id(self, url):
|
||||||
|
try:
|
||||||
|
return self.extract_resource(url, SHARED_FILE)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def import_link(self, url) -> str:
|
||||||
|
file_resource = self.extract_resource(url, SHARED_FILE)
|
||||||
|
meta = self.get_meta(file_resource)
|
||||||
|
return f"{file_resource}/{meta['name']}"
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
def folder_import(self, url) -> list[str]:
|
||||||
|
folder_id = self.extract_id(url, SHARED_FOLDER)
|
||||||
|
_, files = self.listdir(folder_id)
|
||||||
|
return files
|
||||||
|
"""
|
||||||
62
polyphonic/library/gdrive/views.py
Normal file
62
polyphonic/library/gdrive/views.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
from django.shortcuts import resolve_url, redirect
|
||||||
|
from django.views.generic import FormView
|
||||||
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
|
from polyphonic.library.views import CollectionMixin
|
||||||
|
from polyphonic.library.models import Work, Document
|
||||||
|
from polyphonic.library import forms
|
||||||
|
from byostorage.models import UserStorage
|
||||||
|
|
||||||
|
from . import sync_work
|
||||||
|
|
||||||
|
|
||||||
|
class WorkGDriveView(CollectionMixin, SingleObjectMixin, FormView):
|
||||||
|
model = Work
|
||||||
|
template_name = "library/gdrive.html"
|
||||||
|
form_class = forms.DocumentLinkForm
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cancel_url(self):
|
||||||
|
return resolve_url("work_detail", self.collection.pk, self.kwargs["pk"])
|
||||||
|
|
||||||
|
def get_context_data(self, *args, **kwargs):
|
||||||
|
self.object = self.get_object()
|
||||||
|
data = super().get_context_data(*args, **kwargs)
|
||||||
|
|
||||||
|
data["meta"] = dict(self.object.meta_info.values_list("name", "value"))
|
||||||
|
print(data["meta"])
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
link = form.cleaned_data["link"]
|
||||||
|
|
||||||
|
# storage = self.collection.storage.instance()
|
||||||
|
storage = UserStorage.objects.get(name="gdrive").instance()
|
||||||
|
self.object = self.get_object()
|
||||||
|
|
||||||
|
folderid = storage.get_folder_id(link)
|
||||||
|
if folderid:
|
||||||
|
self.object.meta_info.update_or_create(
|
||||||
|
name="folderid", defaults={"value": folderid}
|
||||||
|
)
|
||||||
|
sync_work(self.object)
|
||||||
|
|
||||||
|
return redirect("work_detail", self.collection.pk, self.kwargs["pk"])
|
||||||
|
|
||||||
|
link = storage.import_link(link)
|
||||||
|
if link is None:
|
||||||
|
form.add_error("link", "Not a valid link")
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
work = self.collection.works.get(pk=self.kwargs["pk"])
|
||||||
|
|
||||||
|
doc = Document(
|
||||||
|
work=work,
|
||||||
|
upload=f"gdrive:{link}",
|
||||||
|
# upload=f"{self.collection.storage.name}:{link}",
|
||||||
|
doctype=Document.DOCTYPE_PDF,
|
||||||
|
)
|
||||||
|
doc.save()
|
||||||
|
doc.auto_tag()
|
||||||
|
|
||||||
|
return redirect("work_detail", self.collection.pk, self.kwargs["pk"])
|
||||||
103
polyphonic/library/indexer/__init__.py
Normal file
103
polyphonic/library/indexer/__init__.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
from typing import Protocol, Any, Iterable, Generator
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.module_loading import import_module
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
|
||||||
|
from polyphonic.library.models import Work, Collection
|
||||||
|
|
||||||
|
instance = getattr(settings, "INDEXER", "polyphonic.library.indexer.whoosh")
|
||||||
|
|
||||||
|
|
||||||
|
class Indexer(Protocol):
|
||||||
|
def create_index(self) -> None: ...
|
||||||
|
|
||||||
|
def get_index(self) -> None: ...
|
||||||
|
|
||||||
|
def reset_index(self) -> None: ...
|
||||||
|
|
||||||
|
def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
collections: list[int],
|
||||||
|
page: int = 1,
|
||||||
|
pagesize: int = 20,
|
||||||
|
) -> tuple[list[dict], dict[str, Any]]: ...
|
||||||
|
|
||||||
|
def index_docs(self, docs: Iterable[dict]): ...
|
||||||
|
|
||||||
|
|
||||||
|
class PartialResultSet(object):
|
||||||
|
"""
|
||||||
|
Implements enough of QuerySet to fool the Paginator!
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, works, meta):
|
||||||
|
self.works = works
|
||||||
|
self.meta = meta
|
||||||
|
|
||||||
|
self.start = (meta["page"] - 1) * meta["pagesize"]
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
if isinstance(key, slice):
|
||||||
|
if key.start != self.start:
|
||||||
|
raise KeyError(f"Expected {self.start}, got {key.start}")
|
||||||
|
|
||||||
|
return self.works
|
||||||
|
|
||||||
|
return self.works[key]
|
||||||
|
|
||||||
|
def count(self):
|
||||||
|
return self.meta["total"]
|
||||||
|
|
||||||
|
|
||||||
|
# make the given module available as indexer
|
||||||
|
indexer: Indexer = import_module(instance) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
def work_to_doc(work: Work) -> dict[str, str]:
|
||||||
|
tags = ",".join(work.meta_info.filter(name="tag").values_list("value", flat=True)) # type: ignore
|
||||||
|
meta = ", ".join(work.meta_info.values_list("value", flat=True)) # type: ignore
|
||||||
|
|
||||||
|
description = f"""{work.name}
|
||||||
|
{work.composer}
|
||||||
|
{work.edition}
|
||||||
|
{work.notes}
|
||||||
|
{meta}
|
||||||
|
"""
|
||||||
|
|
||||||
|
return dict(
|
||||||
|
work=str(work.pk),
|
||||||
|
collection=str(work.collection_id), # type: ignore
|
||||||
|
name=work.name,
|
||||||
|
composer=work.composer,
|
||||||
|
edition=work.edition,
|
||||||
|
tag=tags.lower(),
|
||||||
|
text=description,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def doc_set(works: QuerySet) -> Generator:
|
||||||
|
for work in works:
|
||||||
|
yield work_to_doc(work)
|
||||||
|
|
||||||
|
|
||||||
|
def index_works(works: QuerySet):
|
||||||
|
indexer.index_docs(doc_set(works))
|
||||||
|
|
||||||
|
|
||||||
|
def model_search(
|
||||||
|
query: str, collections: list[int], page: int = 1, pagesize: int = 20
|
||||||
|
) -> PartialResultSet:
|
||||||
|
hits, meta = indexer.search(query.lower(), collections, page, pagesize)
|
||||||
|
meta["pagesize"] = pagesize
|
||||||
|
meta["page"] = page
|
||||||
|
|
||||||
|
works = [Work(**hit) for hit in hits]
|
||||||
|
|
||||||
|
collection_names = dict(Collection.objects.values_list("pk", "name"))
|
||||||
|
for work in works:
|
||||||
|
work.collection = Collection( # type: ignore
|
||||||
|
pk=work.collection_id, name=collection_names[work.collection_id]
|
||||||
|
)
|
||||||
|
|
||||||
|
return PartialResultSet(works, meta)
|
||||||
97
polyphonic/library/indexer/whoosh.py
Normal file
97
polyphonic/library/indexer/whoosh.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
from whoosh.index import create_in, open_dir, EmptyIndexError, Index
|
||||||
|
from whoosh.analysis import StemmingAnalyzer, CharsetFilter
|
||||||
|
from whoosh.support.charset import accent_map
|
||||||
|
from whoosh.fields import Schema, TEXT, KEYWORD, NUMERIC
|
||||||
|
from whoosh.qparser import QueryParser
|
||||||
|
from whoosh.query import Term, NullQuery, FuzzyTerm
|
||||||
|
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from django.conf import settings
|
||||||
|
import os.path
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
stemming_analyzer = StemmingAnalyzer() | CharsetFilter(accent_map)
|
||||||
|
|
||||||
|
schema = Schema(
|
||||||
|
work=NUMERIC(stored=True, unique=True),
|
||||||
|
collection=NUMERIC(stored=True),
|
||||||
|
name=TEXT(stored=True),
|
||||||
|
composer=TEXT(stored=True),
|
||||||
|
edition=TEXT(stored=True),
|
||||||
|
tag=KEYWORD(commas=True),
|
||||||
|
text=TEXT(analyzer=stemming_analyzer),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
index_path = settings.WHOOSH_INDEX
|
||||||
|
|
||||||
|
|
||||||
|
def create_index() -> Index:
|
||||||
|
if not os.path.exists(index_path):
|
||||||
|
os.mkdir(index_path)
|
||||||
|
|
||||||
|
ix = create_in(index_path, schema)
|
||||||
|
return ix
|
||||||
|
|
||||||
|
|
||||||
|
def get_index() -> Index:
|
||||||
|
try:
|
||||||
|
return open_dir(index_path)
|
||||||
|
except EmptyIndexError:
|
||||||
|
return create_index()
|
||||||
|
|
||||||
|
|
||||||
|
def reset_index() -> Index:
|
||||||
|
shutil.rmtree(index_path)
|
||||||
|
return create_index()
|
||||||
|
|
||||||
|
|
||||||
|
def index_docs(works: list[dict]):
|
||||||
|
ix = get_index()
|
||||||
|
with ix.writer() as writer:
|
||||||
|
for work in works:
|
||||||
|
writer.update_document(**work)
|
||||||
|
|
||||||
|
|
||||||
|
def search(
|
||||||
|
query: str,
|
||||||
|
collections: list[int] = [],
|
||||||
|
page: int = 1,
|
||||||
|
pagesize: int = 20,
|
||||||
|
) -> tuple[list[dict], dict[str, Any]]:
|
||||||
|
meta = {}
|
||||||
|
query = query.lower()
|
||||||
|
|
||||||
|
qp = QueryParser("text", schema=schema)
|
||||||
|
q = qp.parse(query)
|
||||||
|
|
||||||
|
limit = NullQuery
|
||||||
|
for c in collections:
|
||||||
|
limit |= Term("collection", c)
|
||||||
|
|
||||||
|
hits = []
|
||||||
|
ix = get_index()
|
||||||
|
with ix.searcher() as searcher:
|
||||||
|
results = searcher.search_page(q & limit, page, pagesize)
|
||||||
|
|
||||||
|
# if no results, do a prefix search
|
||||||
|
if results.results.is_empty():
|
||||||
|
qp.termclass = FuzzyTerm
|
||||||
|
q = qp.parse(query)
|
||||||
|
results = searcher.search_page(q & limit, page, pagesize)
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
hits.append(
|
||||||
|
dict(
|
||||||
|
pk=result["work"],
|
||||||
|
name=result["name"],
|
||||||
|
composer=result["composer"],
|
||||||
|
edition=result["edition"],
|
||||||
|
collection_id=int(result["collection"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
meta["query"] = str(q & limit)
|
||||||
|
meta["total"] = len(results)
|
||||||
|
|
||||||
|
return hits, meta
|
||||||
0
polyphonic/library/management/commands/__init__.py
Normal file
0
polyphonic/library/management/commands/__init__.py
Normal file
23
polyphonic/library/management/commands/import_works.py
Normal file
23
polyphonic/library/management/commands/import_works.py
Normal file
@ -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"]
|
||||||
|
)
|
||||||
36
polyphonic/library/management/commands/index.py
Normal file
36
polyphonic/library/management/commands/index.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from polyphonic.library import models
|
||||||
|
from polyphonic.library.indexer import model_search, index_works, indexer
|
||||||
|
|
||||||
|
FORMATTER = "{w.name:50s} {w.edition:15s} {w.collection.name:15s}"
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Imports works from a csv file"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("action", choices=("run", "search", "terms"))
|
||||||
|
parser.add_argument("query", nargs="*")
|
||||||
|
parser.add_argument("--collection", "-c", nargs="*", type=int)
|
||||||
|
# parser.add_argument("collection", type=int, help="Collection ID")
|
||||||
|
# parser.add_argument("source", type=argparse.FileType("r"), help="Source CSV")
|
||||||
|
|
||||||
|
def handle(self, action, query, *args, **options):
|
||||||
|
try:
|
||||||
|
method = getattr(self, f"handle_{action}")
|
||||||
|
except AttributeError:
|
||||||
|
raise RuntimeError(f"Unknown handler: {action}")
|
||||||
|
return method("".join(query), options["collection"] or [])
|
||||||
|
|
||||||
|
def handle_run(self, query, collections=[]):
|
||||||
|
index_works(models.Work.objects.all())
|
||||||
|
|
||||||
|
def handle_search(self, query, collections=[]):
|
||||||
|
for result in model_search(query, collections):
|
||||||
|
print(FORMATTER.format(w=result))
|
||||||
|
|
||||||
|
def handle_terms(self, query, collections=[]):
|
||||||
|
ix = indexer.get_index()
|
||||||
|
with ix.searcher() as searcher:
|
||||||
|
print(b", ".join(searcher.lexicon(query or "text")))
|
||||||
25
polyphonic/library/management/commands/sync.py
Normal file
25
polyphonic/library/management/commands/sync.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from polyphonic.library.models import Work, Collection
|
||||||
|
from polyphonic.library.gdrive import sync_work, sync_collection
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Synchronizes folders and works"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("type", type=str, choices=["work", "collection"])
|
||||||
|
parser.add_argument("pk", nargs="?", type=int)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
|
||||||
|
if options["type"] == "work":
|
||||||
|
work = Work.objects.get(pk=options["pk"])
|
||||||
|
sync_work(work)
|
||||||
|
return
|
||||||
|
|
||||||
|
if options["type"] == "collection":
|
||||||
|
collection = Collection.objects.get(pk=options["pk"])
|
||||||
|
sync_collection(collection)
|
||||||
|
return
|
||||||
|
|
||||||
|
raise CommandError("Unknown object type")
|
||||||
@ -4,7 +4,7 @@ import byostorage.user
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import library.models
|
import polyphonic.library.models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
@ -35,7 +35,7 @@ class Migration(migrations.Migration):
|
|||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('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)),
|
('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)),
|
('upload', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=polyphonic.library.models.doc_upload_filename)),
|
||||||
('created', models.DateTimeField(auto_now_add=True)),
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
('version', models.CharField(blank=True, max_length=30)),
|
('version', models.CharField(blank=True, max_length=30)),
|
||||||
],
|
],
|
||||||
@ -3,7 +3,7 @@
|
|||||||
import byostorage.cached
|
import byostorage.cached
|
||||||
import byostorage.user
|
import byostorage.user
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
import library.models
|
import polyphonic.library.models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
@ -26,7 +26,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='document',
|
model_name='document',
|
||||||
name='upload',
|
name='upload',
|
||||||
field=models.FileField(storage=byostorage.cached.CachedStorage(byostorage.user.BYOStorage()), upload_to=library.models.doc_upload_filename),
|
field=models.FileField(storage=byostorage.cached.CachedStorage(byostorage.user.BYOStorage()), upload_to=polyphonic.library.models.doc_upload_filename),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='work',
|
model_name='work',
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user