Compare commits

...

15 Commits

Author SHA1 Message Date
Tris Forster
bc9f292a2e Major changes to permission system 2023-02-03 10:10:54 +11:00
Tris Forster
8a249de51c Tweeking section handling 2023-01-04 09:52:22 +11:00
Tris Forster
7e47eec4ae Orchestration cleanup 2022-12-10 10:18:45 +11:00
Tris Forster
59deeffefe Improved upload 2022-12-02 13:24:31 +11:00
Tris Forster
53ec846f98 Refactored into /app 2022-11-28 14:11:39 +11:00
Tris Forster
94bba3769a Pre refactor 2022-11-27 10:20:13 +11:00
Tris Forster
2726a8fe04 Adding font 2022-11-19 12:46:58 +11:00
Tris Forster
4731d18131 Add static folder 2022-11-19 12:32:17 +11:00
Tris Forster
c639020ac9 Fixing migrations 2022-11-19 12:24:40 +11:00
Tris Forster
7f6875f3c4 Updated byostorage 2022-11-19 12:10:35 +11:00
Tris Forster
025e1344f0 Recreating migrations 2022-11-19 22:43:15 +11:00
Tris Forster
bbc74a77f9 Removed old deps 2022-11-19 22:37:37 +11:00
Tris Forster
dfe4a925c7 Added secret key! 2022-11-19 22:35:06 +11:00
Tris Forster
18e5893cc2 Updated requirements 2022-11-19 22:33:21 +11:00
Tris Forster
988161b599 Added default settings 2022-11-19 22:31:06 +11:00
90 changed files with 1475 additions and 782 deletions

2
.dockerignore Normal file
View File

@ -0,0 +1,2 @@
local_settings.py
db.sqlite3

5
.gitignore vendored
View File

@ -2,11 +2,12 @@ __pycache__
*.pyc
db.sqlite3
credentials
polyphonic/settings.py
local_settings.py
env
old
test.*
static
teststore
cache
local_storage
media
media

21
Dockerfile Normal file
View File

@ -0,0 +1,21 @@
FROM alpine:3.14
RUN apk add --no-cache python3 git ghostscript sqlite
WORKDIR /root
RUN python3 -m ensurepip
RUN pip3 install -U pip --no-cache-dir
COPY app/requirements.txt .
RUN pip3 install -r requirements.txt --no-cache-dir
COPY app /opt/polyphonic
WORKDIR /opt/polyphonic
COPY docker_settings.py polyphonic/local_settings.py
RUN mkdir /var/polyphonic
RUN SECRET_KEY=_ python3 manage.py collectstatic --noinput
ENTRYPOINT ["python3", "manage.py"]
CMD ["runserver", "0.0.0.0:8000", "--insecure"]

View File

@ -1,29 +1,37 @@
from django import forms
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit
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.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', 'event_date']
widgets = {
'event_date': forms.DateTimeInput(attrs={'type': 'date'})
}
#widgets = {
# 'event_date': forms.DateTimeInput(attrs={'type': 'date'})
#}
class ResourceForm(forms.ModelForm, BaseForm):
@ -36,6 +44,12 @@ class ResourceForm(forms.ModelForm, BaseForm):
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'}))

View File

@ -1,4 +1,4 @@
# Generated by Django 3.2.7 on 2022-11-19 10:25
# Generated by Django 3.2.7 on 2022-11-19 01:24
import byostorage.user
from django.conf import settings
@ -9,13 +9,11 @@ import interface.models
class Migration(migrations.Migration):
replaces = [('interface', '0001_initial'), ('interface', '0002_alter_module_name'), ('interface', '0003_alter_ensemble_slug'), ('interface', '0004_alter_project_event_date')]
initial = True
dependencies = [
('byostorage', '0004_alter_userstorage_storage'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('byostorage', '0006_alter_userstorage_settings_data'),
]
operations = [
@ -75,7 +73,7 @@ class Migration(migrations.Migration):
name='Module',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.SlugField(choices=[('library', 'Library'), ('submissions', 'Submissions')], max_length=20)),
('name', models.SlugField(choices=[('library', 'Library')], max_length=20)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modules', to='interface.project')),
],
),

View File

@ -0,0 +1,25 @@
# Generated by Django 3.2.7 on 2023-02-01 21:04
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('interface', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='project',
options={'ordering': ['active', 'event_date']},
),
migrations.RemoveField(
model_name='ensemble',
name='code',
),
migrations.RemoveField(
model_name='ensemble',
name='passphrase',
),
]

View File

@ -8,8 +8,6 @@ from byostorage.user import BYOStorage
import random
from datetime import datetime
from urllib.parse import urlparse
import os.path
@ -21,12 +19,19 @@ MEDIA_TYPES = [
]
def rough_date(d):
if not d:
return False, "sometime..."
days = (d - timezone.now()).days
in_past = days < 0
if in_past:
days = abs(days)
if days ==0:
return "today!"
if days == 0:
m = int((d-timezone.now()).seconds/60)
if m > 60:
return in_past, "{0:d} hours".format(int(m / 60))
return in_past, "{0:d} minutes!".format(int(m % 60))
if days >= 14:
return in_past, "{0:d} weeks".format(int(days/7))
if days >= 7:
return in_past, "{0:d} weeks, {1:d} days".format(int(days / 7), int(days % 7))
return in_past, f"{days} days"
@ -43,23 +48,33 @@ class Ensemble(models.Model):
help_text="Display name")
slug = models.SlugField(max_length=100, editable=False, unique=True,
help_text="Short name for the ensemble - used for folders")
code = models.CharField(max_length=9, default=generate_code,
help_text="Ensemble registration code")
passphrase = models.CharField(max_length=100,
help_text="Used to register ensembles")
#code = models.CharField(max_length=9, default=generate_code,
# help_text="Ensemble registration code")
#passphrase = models.CharField(max_length=100,
# help_text="Used to register ensembles")
admins = models.ManyToManyField('auth.User', related_name='ensembles')
details = models.TextField(blank=True,
help_text="Description of the ensemble (markdown)")
storage = models.ForeignKey('byostorage.UserStorage', null=True, on_delete=models.SET_NULL,
help_text="Default storage for this ensemble")
class Meta:
ordering = ('slug', )
def active_projects(self):
return self.projects.filter(active=True).order_by('event_date')
return self.projects.filter(active=True, event_date__gte=timezone.now())
def ensemble_code(self):
code = str(self.code)
return "{}-{}-{}".format(code[:3], code[3:6], code[6:])
def has_admin(self, user):
if not user.is_authenticated:
return False
if user.is_superuser:
return True
return user.pk in self.admins.values_list('pk', flat=True)
def save(self, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
@ -81,11 +96,7 @@ class Project(models.Model):
owner = models.CharField(max_length=255, blank=True)
class Meta:
ordering = ['active', '-pk']
@property
def submissions(self):
return self.all_submissions.order_by('-pk')
ordering = ['active', 'event_date']
@property
def days(self):
@ -93,6 +104,8 @@ class Project(models.Model):
@property
def has_happened(self):
if not self.event_date:
return False
return self.event_date < timezone.now()
@property
@ -104,8 +117,13 @@ class Project(models.Model):
@property
def folder(self):
print(f"{self.ensemble.storage_id}:{self.ensemble.slug}/{self.slug}")
return f"{self.ensemble.storage_id}:{self.ensemble.slug}/{self.slug}"
project = slugify(self.name)
print(f"{self.ensemble.storage_id}:{self.ensemble.slug}/{project}")
return f"{self.ensemble.storage_id}:{self.ensemble.slug}/{project}"
@property
def active_modules(self):
return self.modules.values_list('name', flat=True)
def __str__(self):
return self.name

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 426 KiB

After

Width:  |  Height:  |  Size: 426 KiB

View File

@ -0,0 +1,8 @@
{% extends "interface/project_base.html" %}
{% block page %}
<div class="hero">
<h3 class="is-size-3">Sorry, you do not have permission to do that!</h3>
<p>{{ exception }}</p>
</div>
{% endblock %}

View File

@ -0,0 +1,8 @@
{% extends "interface/project_base.html" %}
{% block page %}
<div class="hero">
<h3 class="is-size-3">Sorry, that resource is not found.</h3>
<p>{{ exception }}</p>
</div>
{% endblock %}

View File

@ -8,11 +8,11 @@
{% block page %}
<h3 class="subtitle">{% firstof title view.title %}</h3>
<div class="columns is-centered">
<div class="column is-form-group">
<div class="column is-two-thirds">
{% if instructions %}
<p>{{ instructions }}</p>
{% endif %}
{% crispy form %}
{% crispy form %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,49 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% block admin %}
<a href="{% url 'project_create' object.slug %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add project</span>
</a>
{% if inactive %}
<a href="?" class="button is-link">
<span class="icon"><i class="fas fa-archive"></i></span>
<span>Hide old</span>
</a>
{% else %}
<a href="?inactive" class="button is-link">
<span class="icon"><i class="fas fa-archive"></i></span>
<span>Show all</span>
</a>
{% endif %}
{% endblock %}
{% block page %}
<h3 class="title">Projects for {{ensemble.name }}</h3>
<p>{{ ensemble.details|markdown }}</p>
<h4>Administrators</h4>
<ul>
{% for admin in ensemble.admins.all %}
<li><a href="mailto:{{ admin.email }}">{% firstof admin.get_full_name admin.get_username %}</a></li>
{% endfor %}
</ul>
{% include 'interface/project_items.html' %}
<div class="">
<div class="card">
<header class="card header">
<p class="card-header-title">Admin Details</p>
</header>
<div class="card-content">
<p>
Joining instructions for participants<br/><br/>
URL: <a href="{{ ensemble_link }}">{{ ensemble_link }}</a><br/>
</p>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,52 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% block page %}
{% comment %}
<div class="admin-tools is-pulled-right">
<a class="button is-link" href="{% url 'register' %}">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Register another</span>
</a>
</div>
{% endcomment %}
<h3 class="title">My Ensembles</h3>
<div class="columns is-multiline">
{% for ensemble in object_list %}
<div class="column is-half-tablet is-one-third-widescreen">
<div class="card">
<div class="card-content">
<div class="media">
<div class="media-left">
<figure class="image is-48x48">
<img src="https://bulma.io/images/placeholders/96x96.png" alt="Placeholder image">
</figure>
</div>
<div class="media-content">
<a href="{% url 'ensemble_detail' ensemble.slug %}">
<p class="title is-4">{{ ensemble.name }}</p>
</a>
</div>
</div>
{% if ensemble.details %}
<div class="content">
{{ ensemble.details | markdown }}
</div>
{% endif %}
</div>
<div class="card-footer">
{% with projects=ensemble.active_projects.count %}
<a class="card-footer-item" href="{% url 'ensemble_detail' ensemble.slug %}">{{ projects }} active project{{ projects|pluralize }}</a>
{% endwith %}
</div>
</div>
</div>
{% empty %}
<div class="hero">
You don't currently have access to any ensembles - ask your administrator for a link.
</div>
{% endfor %}
</div>
{% endblock %}

View File

@ -12,7 +12,7 @@
<p class="menu-label">My Things</p>
<ul class="menu-list">
<li><a href="{% url 'ensemble_list' %}">Ensembles</a></li>
<li><a href="{% url 'ensemble_detail' %}">Projects</a></li>
<li><a href="{% url 'project_list' %}">Projects</a></li>
<li><a href="{% url 'work_list' %}">Library</a></li>
</ul>
@ -42,14 +42,14 @@
{% endif %}
<p class="menu-label">Admin</p>
{% if request.is_admin %}
<ul class="menu-list">
{% if request.is_admin or request.user.is_superuser %}
<li><a class="admin-link" href="{% url 'collection_list' %}">Collections</a></li>
{% endif %}
{% if request.user.is_superuser %}
<li><a class="admin-link" href="/admin" target="polyphonic_admin" rel="noopener noreferrer">Django Admin</a></li>
{% endif %}
</ul>
{% endif %}
<ul class="menu-list">
{% if request.user.is_authenticated %}
@ -76,10 +76,10 @@
{% if project.resources.count %}
<li><a href="{% url 'resource_list' project=project.pk %}">Resources</a></li>
{% endif %}
{% if project.enable_library %}
{% if 'library' in modules %}
<li><a href="{% url 'item_list' project=project.pk %}">My Music</a></li>
{% endif %}
{% if project.enable_submissions %}
{% if 'submission' in modules %}
<li><a href="{% url 'submission_create' project=project.pk %}">Send File</a></li>
{% endif %}
</ul>
@ -97,14 +97,8 @@
{% block page %}
No content
{% endblock %}
</section>
</div>
</div>
{% if ensemble %}
<div class="project-footer">
{{ ensemble.name }}
</div>
{% endif %}
{% endblock %}

View File

@ -58,5 +58,11 @@
</div>
{% endif %}
{% if request.is_admin %}
<div class="block">
<a href="{{ project_link }}">Project Link</a>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -8,7 +8,10 @@
{% block page %}
<h3 class="subtitle">{% firstof title view.title %}</h3>
<div class="columns is-centered">
<div class="column is-form-group">
<div class="column">
{% if instructions %}
<p>{{ instructions }}</p>
{% endif %}
{% crispy form %}
</div>
</div>

View File

@ -7,7 +7,6 @@
<div class="box is-half">
<p class="block">
Login is only required to administer a project.<br/>
If you have an ensemble code <a href="{% url 'register' %}">enter it here</a> instead.
</p>
<form method="POST" class="vertical">
{% csrf_token %}
@ -15,7 +14,7 @@
<div class="field is-grouped">
<div class="control">
<button class="button is-link">Login</button>
<a href="{% url 'ensemble_detail' %}" class="button is-light">Cancel</a>
<a href="{% url 'home' %}" class="button is-light">Cancel</a>
</div>
</div>
</form>

View File

@ -0,0 +1,237 @@
from django.test import TestCase, Client
from interface import models, utils
from django.contrib.auth.models import User
from django.utils import timezone
from datetime import timedelta
class AccessTestCase(TestCase):
@classmethod
def setUpTestData(cls):
admin = User.objects.create_user(username='admin', password='foobar', is_superuser=True)
homer = User.objects.create_user(username='homer', password='maggie')
now = timezone.now()
b_sharps = models.Ensemble.objects.create(name='The Be Sharps')
b_sharps.admins.add(homer)
bleeding_gums = models.Ensemble.objects.create(name='Lisa and the Bleeding Gums', slug='bleeding-gums')
party_posse = models.Ensemble.objects.create(name="Party Posse", slug='party-posse')
bleeding_gums.projects.create(name='Baker St', event_date=now-timedelta(days=12))
party_posse.projects.create(name='Navy Recruitment Day', event_date=now+timedelta(days=6))
b_sharps.projects.create(name='Baby on Board', event_date=now+timedelta(days=28))
bleeding_gums.projects.create(name='Open Mic Night', event_date=now+timedelta(hours=1))
def test_admin_ensembles(self):
self.client.post('/login', {'username': 'admin', 'password': 'foobar'})
response = self.client.get('/ensembles')
self.assertObjectList(response, ['Lisa and the Bleeding Gums', 'Party Posse', 'The Be Sharps'])
self.assertContains(response, 'Django Admin')
def test_admin_ensemble_permissions(self):
self.client.post('/login', {'username': 'admin', 'password': 'foobar'})
response = self.client.get('/ensembles/party-posse')
self.assertTrue(response.context['request'].is_admin)
self.assertContains(response, "Add project")
self.assertAccess({
'/ensembles/the-be-sharps': True,
'/ensembles/bleeding-gums': True,
'/ensembles/party-posse': True,
'/ensembles/unknown': False,
'/ensembles/the-be-sharps/new-project': True,
})
def test_admin_projects(self):
self.client.post('/login', {'username': 'admin', 'password': 'foobar'})
response = self.client.get('/projects')
self.assertObjectList(response, ['Open Mic Night', 'Navy Recruitment Day', 'Baby on Board'])
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.client.post('/login', {'username': 'homer', 'password': 'maggie'})
response = self.client.get('/ensembles')
self.assertObjectList(response, ['The Be Sharps'])
self.assertNotContains(response, 'Django Admin')
def test_user_ensemble_permissions(self):
self.client.post('/login', {'username': 'homer', 'password': 'maggie'})
response = self.client.get('/ensembles/the-be-sharps')
self.assertTrue(response.context['request'].is_admin)
self.assertContains(response, "Add project")
self.assertContains(response, 'Show all')
self.assertAccess({
'/ensembles/the-be-sharps': True,
'/ensembles/bleeding-gums': False,
'/ensembles/party-posse': False,
'/ensembles/the-be-sharps/new-project': True,
'/ensembles/party-posse/new-project': False,
})
self.authorize('ensemble_detail', ensemble='bleeding-gums')
self.assertAccess({
'/ensembles/the-be-sharps': True,
'/ensembles/bleeding-gums': True,
'/ensembles/party-posse': False,
'/ensembles/the-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.client.post('/login', {'username': 'homer', 'password': 'maggie'})
response = self.client.get('/projects')
self.assertObjectList(response, ['Baby on Board'])
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.client.get(utils.signed_url('project_detail', project=4))
response = self.client.get('/projects')
self.assertObjectList(response, ['Open Mic Night', 'Baby on Board'])
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):
response = self.client.get(utils.signed_url('ensemble_detail', ensemble='party-posse'))
self.assertContains(response, 'Party Posse')
response = self.client.get('/ensembles')
self.assertObjectList(response, ['Party Posse'])
self.assertAccess({
'/ensembles/the-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('project_detail', project=4)
self.assertObjectList(self.client.get('/projects'), ['Open Mic Night'])
self.assertObjectList(self.client.get('/ensembles'), [])
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/the-be-sharps': False,
'/ensembles/party-posse': False,
'/ensembles/bleeding-gums': False,
'/ensembles/unknown': False,
})
def authorize(self, url, **kwargs):
response = self.client.get(utils.signed_url(url, **kwargs))
self.assertEqual(response.status_code, 200)
def assertAccess(self, urls):
for url, expected in urls.items():
response = self.client.get(url)
self.assertEqual(response.status_code == 200, expected, f"Expected {expected} for {url} (status: {response.status_code})")
def assertObjectList(self, response, expected, element='name'):
self.assertEqual(response.status_code, 200, "No result returned")
objects = response.context['object_list'].values_list(element, flat=True)
self.assertEqual(list(objects), expected)
"""
def test_redirect(self):
self.skipTest("No redirect")
response = self.client.get('/')
self.assertRedirects(response, '/register?')
def test_redirect_project(self):
response = self.client.get('/projects/1')
self.assertEqual(response.status_code, 404)
#def test_redirect_with_code(self):
# response = self.client.get('/?code=123-456-789')
# self.assertRedirects(response, '/register?code=123-456-789')
def test_register(self):
response = self.client.get('/ensembles/1')
self.assertEqual(response.status_code, 404)
url = utils.signed_url('register', group='ensemble', pk=1)
response = self.client.get(url + "i")
self.assertEqual(response.status_code, 400)
response = self.client.get(url)
self.assertRedirects(response, '/ensembles/1')
response = self.client.get('/ensembles/1')
self.assertEqual(response.context['object'].pk, 1)
response = self.client.get('/projects/1', )
def old_test_register(self):
response = self.client.post('/register', {'code': '123-456-789', })
self.assertFormError(response, 'form', 'passphrase', 'This field is required.')
response = self.client.post('/register', {'code': '123-456-789', 'passphrase': 'Foo'})
self.assertFormError(response, 'form', None, 'Incorrect code or passphrase')
response = self.client.post('/register', {'code': '12-34', 'passphrase': 'Homer'})
self.assertRedirects(response, '/')
response = self.client.get(response.url)
self.assertEqual(response.context['object'].pk, 1)
# revisting original url get redirected back to homepage
response = self.client.get('/?code=12-34')
response = self.client.get(response.url)
response = self.client.get(response.url)
self.assertEqual(response.context['object'].pk, 1)
# providing a new code
response = self.client.get('/?code=23-45')
self.assertRedirects(response, '/register?code=23-45')
response = self.client.get(response.url)
#self.assertQuerysetEqual(response.context['current'], ['<Ensemble: The Be Sharps>'])
#self.assertEqual(response.context['form'].code.initial, 'foo')
response = self.client.post('/register', {'code': '23-45', 'passphrase': 'maggie'})
self.assertRedirects(response, '/')
response = self.client.get('/')
self.assertEqual(response.context['object'].pk, 2)
# can use previous link to switch back without passphrase
response = self.client.get('/?code=12-34')
response = self.client.get(response.url)
response = self.client.get(response.url)
self.assertEqual(response.context['object'].pk, 1)
"""

View File

@ -1,20 +1,24 @@
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', views.logout, name='logout'),
path('register', views.register, name="register"),
path('logout', auth_views.LogoutView.as_view(), name='logout'),
#path('register/<group>/<int:pk>', views.register, name='register'),
#path('deregister/<group>/<int:pk>', views.deregister, name='deregister'),
path('', views.EnsembleProjectListView.as_view(), name='ensemble_detail'),
path('ensembles', views.EnsembleListView.as_view(), name="ensemble_list"),
path('ensembles/<int:pk>', views.EnsembleDetailView.as_view(), name='ensemble_detail'),
path('ensembles/<int:pk>/forget', views.EnsembleForgetView.as_view(), name='ensemble_forget'),
path('ensembles/<slug:ensemble>', views.EnsembleDetailView.as_view(), name='ensemble_detail'),
path('ensembles/<slug:ensemble>/new-project', views.ProjectCreateView.as_view(), name="project_create"),
#path('ensembles/<int:pk>/forget', views.EnsembleForgetView.as_view(), name='ensemble_forget'),
path('projects/create', 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"),
@ -27,4 +31,9 @@ urlpatterns = [
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'}))

23
app/interface/utils.py Normal file
View File

@ -0,0 +1,23 @@
from django.shortcuts import resolve_url
from django.core.signing import Signer
from django.core.exceptions import SuspiciousOperation
signer = Signer()
def signed_url(name, **kwargs):
"""
>>> signed_url('foo/bar')
"""
url = resolve_url(name, **kwargs)
sig = signer.sign(url)
return sig.replace(":", "?auth=")
def check_signed_url(url, auth):
sig = signer.sign(url)
if sig[len(url)+1:] != auth:
sig = "_HIDDEN_"
raise SuspiciousOperation("Bad auth code")
if __name__ == '__main__':
import doctest
print(doctest.testmod())

344
app/interface/views.py Normal file
View File

@ -0,0 +1,344 @@
"""
"""
from django.shortcuts import render, get_object_or_404, redirect, resolve_url
from django.views.generic import TemplateView, RedirectView
from django.views.generic.detail import DetailView
from django.views.generic.list import ListView
from django.views.generic.edit import CreateView, UpdateView
from django.core.exceptions import SuspiciousOperation, PermissionDenied
from django.http import Http404, HttpResponseForbidden
from django.contrib import auth
from django.db.models import Q
from django.utils import timezone
from markdown2 import markdown
from functools import cached_property
from . import models, forms
from interface.utils import signed_url, check_signed_url
import logging
logger = logging.getLogger(__name__)
class AuthorizedResourceMixin(object):
"""
Handles two parts of the permission system, signed urls and persistent authenticated resources
"""
SESSION_KEY = 'authorized'
admin_required = False
def is_authorized(self):
"By default check if superuser or a signed request"
if self.request.is_admin:
return True
if 'auth' in self.request.GET:
check_signed_url(self.request.path, self.request.GET['auth'])
self.on_signed_request()
return True
return False
def on_signed_request(self):
pass
def get_authorized_keys(self, resource):
'Returns a set of authorized keys for this resource'
return set(self._authorized.get(resource, []))
def add_authorized_key(self, resource, key):
'Adds a key to the authorized list for this resource'
current = self.get_authorized_keys(resource)
current.add(key)
self._authorized[resource] = list(current)
self.request.session[self.SESSION_KEY] = self._authorized
def del_authorized_key(self, resource, key):
current = self.get_authorized_keys(resource)
current.discard(key)
if current:
self._authorized[resource] = list(current)
else:
self._authorized.pop(current)
self.request.session[self.SESSION_KEY] = self._authorized
def dispatch(self, request, *args, **kwargs):
self._authorized = request.session.get('authorized', {})
request.is_admin = request.user.is_superuser
if not self.is_authorized():
raise Http404("Either the given resource doesn't exist or you dont have access to it.")
if self.admin_required and not request.is_admin:
raise PermissionDenied("You must be an ensemble admin.")
return super().dispatch(request, *args, **kwargs)
class EnsembleMixin(AuthorizedResourceMixin):
def is_authorized(self):
if 'forget' in self.request.GET:
self.del_authorized_key('ensemble', self.kwargs['ensemble'])
raise Http404("Access removed")
super().is_authorized()
try:
self.ensemble = self.get_ensemble()
return True
except models.Ensemble.DoesNotExist:
return False
def get_ensemble(self):
ensemble = self.get_queryset().get(slug=self.kwargs['ensemble'])
self.request.is_admin = ensemble.has_admin(self.request.user)
return ensemble
def get_object(self):
return self.ensemble
def get_queryset(self):
ensembles = models.Ensemble.objects.all()
if self.request.is_admin:
return ensembles
# limit to registered ensembles
f = Q(slug__in=self.get_authorized_keys('ensemble'))
# or ensembles where the user is admin
if self.request.user.is_authenticated:
f |= Q(admins=self.request.user.pk)
return ensembles.filter(f)
class ProjectMixin(AuthorizedResourceMixin):
def is_authorized(self):
super().is_authorized()
try:
self.project = self.get_project()
return True
except models.Project.DoesNotExist:
return False
def get_project(self):
project = self.get_project_queryset().get(pk=self.kwargs['project'])
self.request.is_admin = project.ensemble.has_admin(self.request.user)
return project
def get_project_queryset(self):
projects = models.Project.objects.select_related('ensemble')
if self.request.is_admin:
return projects
f = Q(pk__in=self.get_authorized_keys('project')) | Q(ensemble__slug__in=self.get_authorized_keys('ensemble'))
if self.request.user.is_authenticated:
f |= Q(ensemble__admins=self.request.user.pk)
else:
f &= Q(active=True)
return projects.filter(f)
def get_queryset(self):
return super().get_queryset().filter(project=self.project)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if 'project' in self.kwargs:
context['project'] = self.project
context['modules'] = self.project.active_modules
return context
class CrispyFormMixin(object):
cancel_url = None
def get_cancel_url(self):
return self.cancel_url
""" ENSEMBLE VIEWS """
class EnsembleListView(EnsembleMixin, ListView):
model = models.Ensemble
def is_authorized(self):
return True
class EnsembleDetailView(EnsembleMixin, DetailView):
def on_signed_request(self):
self.add_authorized_key('ensemble', self.kwargs['ensemble'])
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
data['inactive'] = 'inactive' in self.request.GET
if data['inactive']:
data['object_list'] = self.object.projects.all().order_by('-pk')
else:
data['object_list'] = self.object.active_projects()
if self.request.is_admin:
data['ensemble_link'] = signed_url(self.request.path)
return data
""" PROJECT VIEWS """
class ProjectListView(ProjectMixin, ListView):
def is_authorized(self):
return True
def get_queryset(self):
return self.get_project_queryset().filter(active=True, event_date__gte=timezone.now()-timezone.timedelta(7))
class ProjectCreateView(EnsembleMixin, CreateView):
admin_required = True
model = models.Project
template_name = "interface/default_form.html"
title = "Add a new project"
form_class = forms.ProjectForm
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.ensemble_id = self.kwargs['pk']
self.object.owner = self.request.user
self.object.save()
return redirect('project_detail', project=self.object.pk)
class ProjectDetailView(ProjectMixin, DetailView):
def on_signed_request(self):
self.add_authorized_key('project', self.kwargs['project'])
def get_object(self, queryset=None):
return self.project
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
data['project_link'] = signed_url(self.request.path)
return data
class ProjectUpdateView(ProjectMixin, UpdateView):
admin_required = True
template_name = "interface/default_form.html"
pk_url_kwarg = 'project'
form_class = forms.ProjectForm
def get_object(self):
return self.project
@property
def cancel_url(self):
return self.get_success_url()
def get_success_url(self):
return resolve_url('project_detail', project=self.kwargs['project'])
# Old Makefile from submission module
#class ProjectMakefileView(EnsembleMixin, DetailView):
# template_name = 'interface/project_submissions.mk'
# content_type = 'text/plain'
#
# def get_queryset(self):
# if self.request.is_admin:
# return models.Project.objects.all()
#
# return models.Project.objects.filter(ensemble=self.request.ensemble_id)
#
# def get_context_data(self, **kwargs):
# data = super().get_context_data(**kwargs)
#
# data['submissions'] = []
# data['targets'] = []
# for s in self.object.submissions:
# name = s.short_name
# data['targets'].append(name)
# data['submissions'].append({
# 'url': self.request.build_absolute_uri(signed_url('submission_download', project=self.kwargs['pk'], pk=s.pk)),
# 'name': name,
# })
#
# return data
""" WIKI VIEWS """
class WikiView(ProjectMixin, DetailView):
template_name = 'interface/wiki.html'
model = models.WikiPage
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
data['wiki_html'] = markdown(self.object.markdown)
return data
class WikiCreateView(ProjectMixin, CreateView):
admin_required = True
model = models.WikiPage
template_name = 'interface/default_form.html'
form_class = forms.WikiForm
def cancel_url(self):
return resolve_url('project_detail', self.kwargs['project'])
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.project = self.project
self.object.save()
return redirect('wiki', project=self.object.project_id, pk=self.object.pk)
class WikiEditView(ProjectMixin, UpdateView):
admin_required = True
model = models.WikiPage
form_class = forms.WikiForm
def cancel_url(self):
return resolve_url('wiki', self.kwargs['project'], self.kwargs['pk'])
""" RESOURCE VIEWS """
class ResourceCreateView(ProjectMixin, CreateView):
admin_required = True
model = models.Resource
form_class = forms.ResourceForm
template_name = 'interface/default_form.html'
title = "Add a new resource"
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.project = self.project
self.object.save()
return redirect('resource_upload', project=self.object.project_id, pk=self.object.pk)
class ResourceUploadView(ProjectMixin, UpdateView):
admin_required = True
model = models.Resource
fields = ['file']
template_name = 'interface/default_form.html'
def get_success_url(self):
return resolve_url('resource_list', project=self.kwargs['project'])
class ResourceListView(ProjectMixin, ListView):
model = models.Resource
def get_queryset(self):
qs = super().get_queryset()
if not self.request.is_admin:
qs = qs.filter(visible=True)
return qs
class ResourceEditView(ProjectMixin, UpdateView):
admin_required = True
model = models.Resource
fields = ['name', 'description', 'visible']
template_name = 'interface/default_form.html'
def get_success_url(self):
return resolve_url('resource_list', project=self.kwargs['project'])

View File

@ -33,7 +33,7 @@ admin.site.register(models.Work, WorkAdmin)
class SectionInline(admin.TabularInline):
model = models.Section
fields = ['tag', 'start', 'end']
fields = ['type', 'tag', 'ordinal', 'start', 'end']
class DocumentAdmin(admin.ModelAdmin):
list_display = ['work', '__str__']

View File

@ -1,8 +1,6 @@
from django import forms
from .models import Work
from interface.models import Project
from interface.widgets import DatePickerInput
from django.db.models import Q
from interface.forms import BaseForm
@ -11,7 +9,7 @@ class WorkCreateForm(forms.ModelForm, BaseForm):
class Meta:
model = Work
fields = ['name', 'code', 'running_time', 'notes']
fields = ['name', 'composer', 'edition', 'code', 'running_time', 'notes']
class PlaylistAddForm(forms.Form):
work = forms.ModelChoiceField(queryset=Work.objects.all())

View File

@ -2,9 +2,12 @@
from collections import namedtuple
# taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_Instruments
# Place any extra abbreviations at the top
ABBREVIATIONS = """
score Score
cb Double bass
acc Accordion
afl Alto flute
alt Alto (voice) (contralto)
@ -42,6 +45,7 @@ crt Cornet
crtt Cornett (Zink)
cv Child's voice
db Double Bass
drum Drumset
dlcn Dulcian
dom Domra
dulc Dulcimer
@ -139,6 +143,18 @@ xyl Xylophone
zith Zither
"""
ORCHESTRATIONS = {
'SATB': ('S', 'A', 'T', 'B'),
'String Quartet': ('Vln1', 'Vln2', 'Vla', 'Vc'),
'String Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb'),
'Chamber Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb',
'Fl1', 'Fl2', 'Cl1', 'Cl2', 'Hn1', 'Hn2',
'Tpt1', 'Tpt2', 'Tbn1', 'Tbn2', 'Tuba',
'Timp', 'Drum', 'Perc'),
'Custom': (),
}
INSTRUMENTS = []
for line in ABBREVIATIONS.split('\n'):
parts = line.strip().split(maxsplit=1)
@ -146,8 +162,8 @@ for line in ABBREVIATIONS.split('\n'):
name, _, _ = parts[1].partition('(')
INSTRUMENTS.append((parts[0], name))
INSTRUMENT_LOOKUP = dict(INSTRUMENTS)
TAG_LOOKUP = dict( ( (x[1].lower(), x[0]) for x in INSTRUMENTS ) )
INSTRUMENT_NAMES = dict(INSTRUMENTS)
INSTRUMENT_TAGS = dict( ( (x[1].lower(), x[0]) for x in INSTRUMENTS ) )
class Instrument(namedtuple('Instrument', ('name', 'variant'), defaults=[None])):
@ -162,7 +178,7 @@ class Instrument(namedtuple('Instrument', ('name', 'variant'), defaults=[None]))
Instrument(name='Jaws Harp', variant=None)
"""
abbr, _, variant = tag.partition('-')
name = INSTRUMENT_LOOKUP.get(abbr.lower(), abbr)
name = INSTRUMENT_NAMES.get(abbr.lower(), abbr)
if variant:
return cls(name, variant)
@ -176,7 +192,7 @@ class Instrument(namedtuple('Instrument', ('name', 'variant'), defaults=[None]))
>>> Instrument('Double Bass').abbreviate()
'db'
"""
tag = TAG_LOOKUP.get(self.name.lower())
tag = INSTRUMENT_TAGS.get(self.name.lower())
if self.variant:
tag = f"{tag}-{self.variant}"
return tag

View File

@ -1,4 +1,4 @@
# Generated by Django 3.2.7 on 2022-11-19 10:24
# Generated by Django 3.2.7 on 2022-11-19 01:24
import byostorage.user
from django.conf import settings
@ -9,14 +9,12 @@ import library.models
class Migration(migrations.Migration):
replaces = [('library', '0001_initial'), ('library', '0002_auto_20221118_2208'), ('library', '0003_work_composer'), ('library', '0004_auto_20221118_2223'), ('library', '0005_auto_20221118_2253'), ('library', '0006_auto_20221119_2121')]
initial = True
dependencies = [
('byostorage', '0004_alter_userstorage_storage'),
('interface', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('byostorage', '0006_alter_userstorage_settings_data'),
('interface', '0001_initial'),
]
operations = [
@ -32,6 +30,16 @@ class Migration(migrations.Migration):
('storage', models.ForeignKey(blank=True, help_text='User storage for documents', null=True, on_delete=django.db.models.deletion.CASCADE, to='byostorage.userstorage')),
],
),
migrations.CreateModel(
name='Document',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('doctype', models.PositiveSmallIntegerField(choices=[(1, 'PDF'), (2, 'Audio'), (3, 'Video'), (4, 'Source')], default=1)),
('upload', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=library.models.doc_upload_filename)),
('created', models.DateTimeField(auto_now_add=True)),
('version', models.CharField(blank=True, max_length=30)),
],
),
migrations.CreateModel(
name='ProjectItem',
fields=[
@ -40,6 +48,7 @@ class Migration(migrations.Migration):
('due', models.DateTimeField(blank=True, null=True)),
('returned', models.DateTimeField(blank=True, null=True)),
('order', models.SmallIntegerField(default=0)),
('section', models.CharField(blank=True, max_length=100)),
('approved_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='interface.project')),
],
@ -53,6 +62,8 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(editable=False, help_text='Used as folder name', max_length=100)),
('name', models.CharField(help_text='Original name of the work', max_length=255)),
('edition', models.CharField(blank=True, help_text='Edition details to distinguish multiple versions', max_length=255)),
('composer', models.CharField(blank=True, help_text='Surname, First Name/Initials', max_length=255)),
('original_parts', models.JSONField(blank=True, default=dict, help_text='Original printed parts (IMSLP format)')),
('code', models.CharField(blank=True, help_text='Collection specific code or number', max_length=100)),
('licence', models.PositiveSmallIntegerField(choices=[(2, 'Public Domain'), (4, 'Copyright Expired'), (6, 'Copyrighted'), (10, 'Internal use only')], default=6, help_text='Copyright status')),
@ -62,36 +73,6 @@ class Migration(migrations.Migration):
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='works', to='library.collection')),
('parent', models.ForeignKey(blank=True, help_text='Arrangement of another work or part of an anthology', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_works', to='library.work')),
('projects', models.ManyToManyField(help_text='Current usage', related_name='works', through='library.ProjectItem', to='interface.Project')),
('composer', models.CharField(blank=True, help_text='Surname, First Name/Initials', max_length=255)),
('edition', models.CharField(blank=True, help_text='Edition details to distinguish multiple versions', max_length=255)),
],
),
migrations.AddField(
model_name='projectitem',
name='work',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_items', to='library.work'),
),
migrations.CreateModel(
name='EnsembleAccess',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('access_type', models.PositiveSmallIntegerField(choices=[(1, 'Unlimited'), (2, 'Approval required')], default=2)),
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_ensembles', to='library.collection')),
('ensemble', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_collections', to='interface.ensemble')),
],
options={
'verbose_name_plural': 'Ensemble access',
},
),
migrations.CreateModel(
name='Document',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('doctype', models.PositiveSmallIntegerField(choices=[(1, 'PDF'), (2, 'Audio'), (3, 'Video'), (4, 'Source')], default=1)),
('upload', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=library.models.doc_upload_filename)),
('created', models.DateTimeField(auto_now_add=True)),
('version', models.CharField(blank=True, max_length=30)),
('work', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='docs', to='library.work')),
],
),
migrations.CreateModel(
@ -116,4 +97,26 @@ class Migration(migrations.Migration):
'ordering': ['doc', 'start', 'pk'],
},
),
migrations.AddField(
model_name='projectitem',
name='work',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_items', to='library.work'),
),
migrations.CreateModel(
name='EnsembleAccess',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('access_type', models.PositiveSmallIntegerField(choices=[(1, 'Unlimited'), (2, 'Approval required')], default=2)),
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_ensembles', to='library.collection')),
('ensemble', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_collections', to='interface.ensemble')),
],
options={
'verbose_name_plural': 'Ensemble access',
},
),
migrations.AddField(
model_name='document',
name='work',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='docs', to='library.work'),
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 3.2.7 on 2022-11-30 22:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='work',
name='slug',
),
migrations.AddField(
model_name='work',
name='path',
field=models.CharField(default='', editable=False, help_text='Used as folder name', max_length=255),
preserve_default=False,
),
migrations.AlterField(
model_name='work',
name='composer',
field=models.CharField(default='Anon', help_text='Surname, Initials', max_length=255),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 3.2.7 on 2022-12-01 04:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('library', '0002_auto_20221201_0934'),
]
operations = [
migrations.AlterUniqueTogether(
name='work',
unique_together={('collection', 'composer', 'name', 'edition')},
),
migrations.RemoveField(
model_name='work',
name='path',
),
]

View File

@ -0,0 +1,40 @@
# Generated by Django 3.2.7 on 2023-01-01 04:35
import byostorage.cached
import byostorage.user
from django.db import migrations, models
import library.models
class Migration(migrations.Migration):
dependencies = [
('library', '0003_auto_20221201_1540'),
]
operations = [
migrations.AlterModelOptions(
name='section',
options={'ordering': ['doc', 'type', 'start', 'pk']},
),
migrations.AddField(
model_name='section',
name='type',
field=models.SmallIntegerField(choices=[(1, 'Instrument'), (2, 'Movement')], default=1),
preserve_default=False,
),
migrations.AlterField(
model_name='document',
name='upload',
field=models.FileField(storage=byostorage.cached.CachedStorage(byostorage.user.BYOStorage()), upload_to=library.models.doc_upload_filename),
),
migrations.AlterField(
model_name='work',
name='original_parts',
field=models.JSONField(default=dict, help_text='Original printed parts (IMSLP format)'),
),
migrations.AlterUniqueTogether(
name='work',
unique_together=set(),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 3.2.7 on 2023-01-01 04:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0004_auto_20230101_1535'),
]
operations = [
migrations.AlterModelOptions(
name='section',
options={'ordering': ['type', 'ordinal', 'doc', 'start', 'pk']},
),
migrations.AddField(
model_name='section',
name='ordinal',
field=models.IntegerField(default=0),
),
]

View File

@ -8,6 +8,7 @@ from django.core.files.storage import get_storage_class
from django.db.models import Q, Count, Min, Max
from byostorage.user import BYOStorage
from byostorage.cached import CachedStorage
from .imslp import Instrument
import logging
@ -22,38 +23,10 @@ logger = logging.getLogger(__name__)
# library_storage = get_storage_class()()
#logger.info("Library storage: %s", library_storage.__class__.__name__)
# FIXME: move back to settings
library_storage = CachedStorage(BYOStorage())
'''
ORCHESTRATIONS = {
'SATB': ('S', 'A', 'T', 'B'),
'String Quartet': ('Vln1', 'Vln2', 'Vla', 'Vc'),
'String Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb'),
'Chamber Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb',
'Fl1', 'Fl2', 'Cl1', 'Cl2', 'Hn1', 'Hn2',
'Tpt1', 'Tpt2', 'Tbn1', 'Tbn2', 'Tuba',
'Timp', 'Drum', 'Perc'),
'Custom': (),
}
'''
DOCTYPES = [
(1, 'PDF'),
(2, 'Audio'),
(3, 'Video'),
(4, 'Source'),
]
LICENCE_TYPES = [
(2, 'Public Domain'),
(4, 'Copyright Expired'),
(6, 'Copyrighted'),
(10, 'Internal use only'),
]
ACCESS_TYPES = [
(1, 'Unlimited'),
(2, 'Approval required'),
]
'''
@ -97,7 +70,7 @@ class ProjectItem(models.Model):
ordering = ['order', 'work']
def __str__(self):
return f"<{self.project_id}:{self.work.slug}>"
return f"<{self.project_id}:{slugify(self.work.name)}>"
class Collection(models.Model):
"""
@ -115,7 +88,20 @@ class Collection(models.Model):
help_text="User storage for documents")
notes = models.TextField(blank=True,
help_text="Publicly visible notes about collection and loans policy (markdown format)")
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 __str__(self):
return self.name
@ -123,6 +109,15 @@ 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)
@ -130,30 +125,36 @@ class EnsembleAccess(models.Model):
class Meta:
verbose_name_plural = "Ensemble access"
META_TAGS = (
('tag', 'Tag'),
('arr', 'Arranger'),
('lyrics', 'Lyracist'),
('genre', 'Genre'),
('style', 'Style'),
('orchestration', 'Orchestration'),
)
class Work(models.Model):
"""
A musical work 'owned' by a collection from a licencing perspective.
"""
slug = models.SlugField(max_length=100, editable=False,
help_text="Used as folder name")
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, blank=True,
help_text="Surname, First Name/Initials")
composer = models.CharField(max_length=255, default='Anon',
help_text="Surname, Initials")
original_parts = models.JSONField(default=dict, blank=True, help_text="Original printed parts (IMSLP format)")
original_parts = models.JSONField(default=dict, help_text="Original printed parts (IMSLP format)")
# Collection details
collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name="works")
@ -168,6 +169,10 @@ class Work(models.Model):
# 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 extract(self, *tags):
qs = self.docs.filter(sections__tag__in=tags)
@ -194,11 +199,6 @@ class Work(models.Model):
def meta(self):
return self.meta_info.exclude(name='tag')
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super(Work, self).save(*args, **kwargs)
@property
def active_projects(self):
return self.projects.filter(active=True)
@ -235,10 +235,11 @@ class Work(models.Model):
composer = self.composer or "Anon"
words = self.name.split()
if len(words) > 2:
work = ''.join([ x[0] for x in self.name.split() ])
else:
work = words[0][:3]
#if len(words) > 2:
# work = ''.join([ x[0] for x in self.name.split() ])
#else:
# work = words[0][:3]
work = words[0][:3]
return f"{composer[:4]}-{work}-{self.pk:03d}".upper()
@ -247,8 +248,18 @@ class Work(models.Model):
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_TAGS)
name = models.SlugField(max_length=20, choices=META_CHOICES)
value = models.CharField(max_length=255)
def doc_upload_filename(doc, filename):
@ -256,18 +267,35 @@ def doc_upload_filename(doc, filename):
storage = collection.storage
if not storage:
raise RuntimeError("Collection has no storage attached")
return f'{storage}:library/{collection.prefix}/{doc.work.slug}-{doc.work.pk}/{filename}'
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_SOURCE = 4
DOCTYPES = (
(DOCTYPE_PDF, 'PDF'),
(DOCTYPE_AUDIO, 'Audio'),
(DOCTYPE_VIDEO, 'Video'),
(DOCTYPE_SOURCE, 'Source'),
)
work = models.ForeignKey('Work', on_delete=models.CASCADE, related_name="docs")
doctype = models.PositiveSmallIntegerField(choices=DOCTYPES, default=1)
upload = models.FileField(upload_to=doc_upload_filename, storage=BYOStorage())
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 __str__(self):
return self.upload.name
@ -275,21 +303,54 @@ class Section(models.Model):
"""
Section is a tagged portion of a Document
"""
TYPE_INSTRUMENT = 1
TYPE_MOVEMENT = 2
TYPE_EXCERPT = 3
SECTION_TYPES = (
(TYPE_INSTRUMENT, "Instrument"),
(TYPE_MOVEMENT, "Movement"),
(TYPE_EXCERPT, "Excerpt"),
)
SECTION_CLASSES = {
TYPE_INSTRUMENT: 'info',
TYPE_MOVEMENT: 'success',
TYPE_EXCERPT: 'warning',
}
type = models.SmallIntegerField(choices=SECTION_TYPES)
doc = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="sections")
tag = models.CharField(max_length=50)
tag = models.CharField(max_length=50, blank=True)
ordinal = models.IntegerField(default=0)
start = models.SmallIntegerField(null=True, blank=True)
end = models.SmallIntegerField(null=True, blank=True)
class Meta:
ordering = ['doc', 'start', 'pk']
ordering = ['type', 'ordinal', 'doc', 'start', 'pk']
@property
def instrument(self):
return Instrument.from_tag(self.tag)
def name(self):
if self.type == self.TYPE_INSTRUMENT:
instr = Instrument.from_tag(self.tag)
if self.ordinal:
return f'{instr} {self.ordinal}'
return str(instr)
return f"{self.ordinal} - {self.tag}"
#@property
#def instrument(self):
# return Instrument.from_tag(self.tag)
@property
def bulma_class(self):
return self.SECTION_CLASSES[self.type]
@property
def filename(self):
return slugify(f'{self.doc.work.name}_{self.instrument}') + '.pdf'
return slugify(f'{self.doc.work.name} - {self.name}') + '.pdf'
@property
def pagerange(self):
@ -300,4 +361,4 @@ class Section(models.Model):
return "all"
def __str__(self):
return f'{self.doc.upload} [{self.pagerange}]'
return self.name

View File

@ -0,0 +1,37 @@
{% extends "interface/project_base.html" %}
{% block page %}
<h3 class="title">Library collections for {% firstof request.user.first_name request.user.username %}</h3>
<div class="columns is-multiline">
{% for collection in object_list %}
<div class="column is-half">
<div class="card">
<header class="card-header">
<a class="" href="{% url 'collection_work_list' pk=collection.id %}">
<p class="card-header-title">{{ collection.name }}</p>
</a>
</header>
<div class="card-content">
<p>
{% if collection.location %}{{ collection.location }},{% endif %}
{{ collection.works.count }} items.
</p>
<p>
{% for tag in collection.tags %}
<a href="{% url 'collection_work_list' collection.pk %}?filter=tag:{{ tag }}" class="tag is-success">{{ tag }}</a>
{% endfor %}
{% for genre in collection.genres %}
<a href="{% url 'collection_work_list' collection.pk %}?filter=genre:{{ genre }}" class="tag is-warning">{{ genre }}</a>
{% endfor %}
</p>
</div>
</div>
</div>
{% endfor %}
</div>
<div>
<small>{{ ensemble.ensemble_code }}</small>
</div>
{% endblock %}

View File

@ -0,0 +1,22 @@
{% extends "interface/project_base.html" %}
{% block page %}
<div class="columns">
<div class="column is-half is-centered">
<div class="block">
<p>Are you sure you want to delete<br>
<b>"{{ object.upload.name }}"</b>?</p>
</div>
<form method="post">{% csrf_token %}
<div class="field is-grouped">
<div class="control">
<button class="button is-link">Yes</button>
</div>
<div class="control">
<a class="button is-link is-light" href="{% url 'work_detail' object.work.pk %}">No</a>
</div>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,17 @@
{% load path_filters %}
<tr>
<td><a href="{{ doc.upload.url }}" target="_blank">
{{ doc.upload.name|basename }}</a></td>
<td>
{% for section in doc.sections.all %}
<a class="tag is-{{ section.bulma_class }}" target="_blank" href="{% url 'part_download' pk=section.pk filename=section.filename %}">{{ section.name }}</a>
{% endfor %}
</td>
<td class="has-text-right" style="white-space: nowrap;">
{% if request.is_admin %}
<a href="{% url 'document_annotate' pk=doc.pk %}"><i class="fas fa-tags"
title="Manage Tags"></i></a>
<a href="{% url 'document_delete' pk=doc.pk %}"><i class="fas fa-trash-alt" title="Delete Document"></i></a>
{% endif %}
</td>
</tr>

View File

@ -21,7 +21,7 @@
<h3 class="title">
{{ work.name }}
{% for tag in work.tags %}
<span class="tag is-success">{{ tag }}</span>
<a href="{% url 'collection_work_list' work.collection.pk %}?filter=tag:{{ tag }}" class="tag is-success">{{ tag }}</a>
{% endfor %}
</h3>
<p class="subtitle">{% firstof work.composer "Unattributed" %}{% if work.edition %} - {{ work.edition }}{% endif %}</p>
@ -30,10 +30,10 @@
<p class="block">
Location: <a href="{% url 'collection_work_list' work.collection.pk %}">{{ work.collection }}</a> [{{ work.identifier }}]<br/>
Running time: {{ work.duration }}<br/>
Running time: {% firstof work.duration 'Unknown' %}<br/>
Licence: {{ work.get_licence_display }}<br/>
{% for meta in work.meta %}
{{ meta.get_name_display }}: {{ meta.value }}<br/>
{{ meta.get_name_display }}: <a href="{% url 'collection_work_list' work.collection.pk %}?filter={{ meta.name}}:{{ meta.value }}">{{ meta.value }}</a><br/>
{% endfor %}
</p>
@ -79,9 +79,9 @@
{% if work.digital_parts %}
<a class="tag is-danger" href="{% url 'work_partset' pk=work.pk %}">Full Set</a>
{% endif %}
{% for part in work.digital_parts %}
<a class="tag is-info" href="{% url 'part_download' pk=part.pk filename=part.filename %}"
target="part_{{ part.pk }}" rel="">{{ part.instrument }}</a>
{% for section in work.digital_parts %}
<a class="tag is-info" href="{% url 'part_download' pk=section.pk filename=section.filename %}"
target="section_{{ section.pk }}" rel="">{{ section.name }}</a>
{% empty %}
<p class="is-italic">No digital parts available</p>
{% endfor %}
@ -110,22 +110,7 @@
</thead>
<tbody id="doc-list">
{% for doc in work.docs.all %}
<tr>
<td><a href="{% url 'document_download' pk=doc.pk %}" target="_blank">
{{ doc.upload.name|basename }}</a></td>
<td>
{% for part in doc.sections.all %}
<a class="tag is-info" href="{% url 'part_download' pk=part.pk filename=part.filename %}">{{ part.instrument }}</a>
{% endfor %}
</td>
<td class="has-text-right">
{% if request.is_admin %}
<a href="{% url 'document_annotate' pk=doc.pk %}"><i class="fas fa-tags"
title="Manage Tags"></i></a>
<a href=""><i class="fas fa-trash-alt" title="Delete Document"></i></a>
{% endif %}
</td>
</tr>
{% include 'library/document_entry.html' %}
{% endfor %}
</tbody>
</table>
@ -188,7 +173,7 @@
<script>
Dropzone.options.docUpload = { // camelized version of the `id`
paramName: "upload", // The name that will be used to transfer the file
maxFilesize: 12, // MB
maxFilesize: 50, // MB
createImageThumbnails: false,
thumbnailWidth: 60,
thumbnailHeight: 60,

View File

@ -1,8 +1,15 @@
from django.urls import path
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"),
@ -11,7 +18,7 @@ urlpatterns = [
path('library/collections', views.CollectionListView.as_view(), name="collection_list"),
path('library/collections/<int:pk>', views.CollectionWorkListView.as_view(), name="collection_work_list"),
path('library/collection/<int:pk>/create', views.WorkAddView.as_view(), name="work_add"),
path('library/collections/<int:pk>/create', views.WorkAddView.as_view(), name="work_add"),
path('library/works', views.WorkListView.as_view(), name="work_list"),
path('library/works/<int:pk>', views.WorkDetailView.as_view(), name="work_detail"),
@ -20,10 +27,13 @@ urlpatterns = [
path('library/works/<int:pk>/add_to_project', views.WorkAddToProject.as_view(), name="work_add_to_project"),
path('library/works/<int:pk>/upload', views.WorkAddDocumentView.as_view(), name="document_add"),
path('library/documents/<int:pk>/delete', views.DocumentDeleteView.as_view(), name="document_delete"),
path('library/documents/<int:pk>/download', views.DocumentDownloadView.as_view(), name="document_download"),
path('library/documents/<int:pk>/annotate', views.DocumentAnnotateView.as_view(), name="document_annotate"),
path('library/parts/<int:pk>/<str:filename>', views.PartDownloadView.as_view(), name="part_download"),
]
from django.views.static import serve
urlpatterns.append(path('docs/<path:path>', serve, {'document_root': 'local_storage'}))
#path('api/', include(router.urls))
path('api/library/collections/<int:pk>/export', api.CollectionExportView.as_view(), name="collection_export"),
path('api/library/works/<int:pk>/export', api.WorkExportView.as_view(), name="work_export"),
path('api/library/collections/<int:pk>/import', api.WorkImportView.as_view(), name="work_import"),
]

View File

@ -1,22 +1,24 @@
from django.shortcuts import render, redirect, resolve_url
from django.views.generic.detail import DetailView, SingleObjectMixin, View
from django.views.generic.list import ListView, MultipleObjectMixin
from django.views.generic.edit import CreateView, FormView, UpdateView
from django.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.utils.timezone import now
from django.urls import reverse
from django.template.loader import render_to_string
import json
import os.path
import re
from interface.views import EnsembleMixin, ProjectMixin
from interface.models import Project
from .models import Collection, Work, Document, Section
from .imslp import INSTRUMENTS
from . import forms, models
from .pdf_utils import extract_pages, extract_and_concat
from library.models import Collection, Work, Document, Section
from library.imslp import INSTRUMENT_TAGS, INSTRUMENTS
from library import forms, models
from library.pdf_utils import extract_pages, extract_and_concat
class ProjectItemListView(ProjectMixin, ListView):
template_name = "library/item_list.html"
@ -193,6 +195,8 @@ class WorkUpdateView(EnsembleMixin, WorkMixin, UpdateView):
def get_success_url(self):
return resolve_url('work_detail', self.kwargs['pk'])
class WorkAddToProject(EnsembleMixin, FormView):
admin_required = True
form_class = forms.ProjectSelectForm
@ -269,20 +273,27 @@ class WorkAddDocumentView(EnsembleMixin, CreateView):
doc = form.save(commit=False)
doc.work_id = self.kwargs['pk']
doc.save()
# auto tag the document
name, _ = os.path.splitext(os.path.basename(doc.upload.name))
parts = re.split(r'[^A-Za-z]+', name)
parts.reverse()
for word in parts:
try:
tag = INSTRUMENT_TAGS[word.lower()]
doc.sections.create(tag=tag)
break
except KeyError:
pass
if self.request.headers['Accept'] == 'application/json':
filename = os.path.basename(doc.upload.name)
return JsonResponse({
"message": "created",
"id": doc.pk,
"entry": f"""
<td><a href="{reverse('document_download', args=[doc.pk])}">{filename}</a></td>
<td/>
<td class="has-text-right">
<a href="{reverse('document_annotate', args=[doc.pk])}"><i class="fas fa-tags"
title="Manage Tags"></i></a>
<a href=""><i class="fas fa-trash-alt" title="Delete Document"></i></a>
</td>
"""
"entry": render_to_string('library/document_entry.html', {'doc': doc, 'request': self.request})
}, status=201)
return redirect('document_annotate', doc.pk)
@ -336,6 +347,13 @@ class DocumentAnnotateView(EnsembleMixin, DocumentMixin, DetailView):
data['json_data'] = {'pageTags': pages, 'instruments': dict(INSTRUMENTS)}
return data
class DocumentDeleteView(EnsembleMixin, DocumentMixin, DeleteView):
#def get_template_names(self):
# return ["interface/default_form.html"]
def get_success_url(self):
return resolve_url('work_detail', self.object.work.pk)
class PartDownloadView(EnsembleMixin, SingleObjectMixin, View):
@ -346,10 +364,10 @@ class PartDownloadView(EnsembleMixin, SingleObjectMixin, View):
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'
#download_name = f'{self.object.doc.work.name}_{self.object.instrument}.pdf'
response = FileResponse(result, content_type="application/pdf")
response['Content-Disposition'] = f'inline; filename="foo.pdf"'
response['Content-Disposition'] = f'inline; filename="{self.args["filename"]}"'
return response
def get_queryset(self):

161
app/library/views/api.py Normal file
View File

@ -0,0 +1,161 @@
"""
Views relating to importing and exporting collection items
"""
"""
from interface.views import EnsembleMixin
from library.views import WorkMixin
from django.views.generic import View
from django.http import JsonResponse
from djantic import ModelSchema
from library.models import Work, Document, Section
class DocumentSchema(ModelSchema):
class Config:
model = Document
class WorkSchema(ModelSchema):
docs: DocumentSchema
class Config:
model = Work
exclude = ['licence']
class WorkExportView(EnsembleMixin, WorkMixin, View):
def get(self, request, *args, **kwargs):
obj = self.get_queryset().get(pk=kwargs['pk'])
schema = WorkSchema.from_orm(obj)
return JsonResponse(schema.dict())
"""
from library.views import WorkMixin
from interface.views import EnsembleMixin
from rest_framework import routers, serializers, viewsets
from library.models import Collection, Work, Document, Section
import requests
from io import BytesIO
import tempfile
import shutil
from django.db import transaction
from django.core.files.uploadedfile import TemporaryUploadedFile
class SectionSerializer(serializers.ModelSerializer):
class Meta:
model = Section
exclude = ['id', 'doc']
def to_representation(self, instance):
start = instance.start or 0
end = instance.end or 0
return f"{instance.tag}:{instance.type}:{start}:{end}"
def to_internal_value(self, data):
tag, section_type, start, end = data.split(":")
try:
start = int(start)
except:
start = 0
try:
end = int(end)
except:
end = 0
return super().to_internal_value({'tag': tag, 'type': int(section_type), 'start': start, 'end': end})
class DocumentSerializer(serializers.ModelSerializer):
upload = serializers.URLField()
sections = SectionSerializer(many=True)
#doctype = serializers.CharField(source='get_doctype_display')
#def to_internal_value(self, data):
# r = requests.get(data['upload'], stream=True)
# with tempfile.NamedTemporaryFile('wb') as f:
# shutil.copyfileobj(r.raw, f)
# data['upload'] = f.name
# print(repr(data))
# return super().to_internal_value(data)
def to_representation(self, instance):
data = super().to_representation(instance)
if data['upload'][0] == '/':
data['upload'] = 'http://localhost:8000' + (data['upload'])
return data
def create(self, validated_data):
print("CREATE", validated_data)
return super().create(validated_data)
def validate(self, data):
print("VALIDATE", data)
return super().validate(data)
def validate_upload(self, value):
print("VALIDATE", value)
return value
class Meta:
model = Document
exclude = ["id", "work", "version", "created"]
# Serializers define the API representation.
class WorkSerializer(serializers.ModelSerializer):
docs = DocumentSerializer(many=True)
class Meta:
model = Work
exclude = ['id', 'collection', 'projects', 'parent']
def create(self, validated):
with transaction.atomic():
docs = validated.pop('docs', [])
work = Work.objects.create(**validated)
for d in docs:
sections = d.pop('sections', [])
r = requests.get(d['upload'], stream=True)
f = TemporaryUploadedFile(d['upload'], r.headers['content-type'], r.headers['content-length'], r.encoding)
shutil.copyfileobj(r.raw, f.file)
r.close()
d['upload'] = f
doc = Document.objects.create(work_id=work.pk, **d)
for s in sections:
Section.objects.create(doc_id=doc.pk, **s)
return work
class CollectionSerializer(serializers.Serializer):
works = WorkSerializer(many=True)
from rest_framework import generics
class CollectionExportView(generics.RetrieveAPIView):
serializer_class = CollectionSerializer
def get_queryset(self):
return Collection.objects.filter(administrators=self.request.user)
class WorkExportView(generics.RetrieveAPIView):
serializer_class = WorkSerializer
def get_queryset(self):
return Work.objects.filter(collection__administrators=self.request.user)
class WorkImportView(generics.CreateAPIView):
serializer_class = WorkSerializer
def perform_create(self, serializer):
serializer.save(collection_id=self.kwargs['pk'])

View File

@ -1,4 +1,4 @@
#!/usr/bin/env python
#!/usr/bin/env python3
"""Django's command-line utility for administrative tasks."""
import os
import sys

View File

@ -11,23 +11,22 @@ 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: keep the secret key used in production secret!
SECRET_KEY = None
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False
ALLOWED_HOSTS = ['localhost']
# Application definition
POLYPHONIC_MODULES = [
@ -42,11 +41,12 @@ INSTALLED_APPS = [
'django.contrib.messages',
'django.contrib.staticfiles',
'django_markdown2',
'rest_framework',
'crispy_forms',
'crispy_bulma',
'byostorage',
'interface',
] + POLYPHONIC_MODULES
]
CRISPY_ALLOWED_TEMPLATE_PACKS = ("bulma",)
CRISPY_TEMPLATE_PACK = "bulma"
@ -131,8 +131,8 @@ USE_TZ = True
# https://docs.djangoproject.com/en/3.1/howto/static-files/
STATIC_URL = '/static/'
STATIC_ROOT = 'static'
# Need to set this
AWS_BUCKET = ''
MEDIA_ROOT = 'media'

View File

@ -0,0 +1,7 @@
try:
from .local_settings import *
except ImportError:
from .default_settings import *
INSTALLED_APPS += POLYPHONIC_MODULES

View File

@ -1,14 +1,14 @@
asgiref==3.4.1
boto3==1.18.34
botocore==1.21.34
crispy-bulma==0.8.0
Django==3.2.7
django-byostorage @ git+https://gitea.tfconsulting.com.au/tris/django-byostorage.git@c67d636d2457faa57644cd812ca1b5a916e23766
django-byostorage @ git+https://gitea.tfconsulting.com.au/tris/django-byostorage.git@4d87edd995a2b0c154298bd00b3fe0abb78ba72c
django-crispy-forms==1.14.0
django-markdown2==0.3.1
jmespath==0.10.0
markdown2==2.4.1
python-dateutil==2.8.2
pytz==2021.1
s3transfer==0.5.0
six==1.16.0
sqlparse==0.4.1
urllib3==1.26.6

8
docker_settings.py Normal file
View File

@ -0,0 +1,8 @@
from .default_settings import *
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': '/var/polyphonic/db.sqlite3',
}
}

View File

@ -1,12 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h2>{{ ensemble.name }}</h2>
<p>{{ ensemble.details }}</p>
<h4>Administrators</h4>
<ul>
{% for admin in ensemble.admins.all %}
<li><a href="mailto:{{ admin.email }}">{% firstof admin.get_full_name admin.get_username %}</a></li>
{% endfor %}
</ul>
{% endblock %}

View File

@ -1,41 +0,0 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% block page %}
<div class="admin-tools is-pulled-right">
<a class="button is-link" href="{% url 'register' %}">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Register another</span>
</a>
</div>
<h3 class="title">My Ensembles</h3>
<div class="columns is-multiline">
{% for ensemble in object_list %}
<div class="column is-half">
<div class="card">
<header class="card-header{% if ensemble.pk == ensemble_id %} has-background-link-light{% endif %}">
<a class="card-header-title" href="{% url 'register' %}?code={{ ensemble.code }}">
{{ ensemble.name }}
</a>
<a class="card-header-icon" href="{% url 'ensemble_forget' pk=ensemble.id %}">
<span class="delete"></span>
</a>
</header>
<div class="card-content">
{% if ensemble.details %}
<div class="content">
{{ ensemble.details | markdown }}
</div>
{% endif %}
<div>
{% with projects=ensemble.active_projects.count %}
<p>{{ projects }} active project{{ projects|pluralize }}</p>
{% endwith %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@ -1,73 +0,0 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% block admin %}
<a href="{% url 'project_create' %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add new</span>
</a>
{% endblock %}
{% block page %}
<h3 class="title">Projects for {{ ensemble.name }}</h3>
<div class="columns is-multiline">
{% for project in ensemble.active_projects %}
<div class="column is-half">
<div class="card">
<a class="" href="{% url 'project_detail' project=project.id %}">
<header class="card-header">
<p class="card-header-title">{{ project.name }}</p>
<p class="card-header-icon" style="color: black;">{{ project.rough_date }}</p>
</header>
</a>
<div class="card-content">
<div class="content">
{{ project.description | markdown }}
</div>
<p><small>
{% if project.deadline %}In {{ project.deadline|timeuntil }}<br/>{% endif %}
{% if project.works.count %}
<a href="{% url 'item_list' project=project.pk %}">
{{ project.works.count }} works
</a>
<br/>
{% endif %}
{% if project.submissions.count %}{{ project.submissions.count }} submissions<br/>{% endif %}
</small></p>
</div>
</div>
</div>
{% empty %}
<div class="hero">
<div class="hero-body">
<p class="title">No projects currently planned</p>
<p class="subtitle">Go put your feet up!</p>
</div>
</div>
{% endfor %}
</div>
{% if request.is_admin %}
<div class="">
<div class="card">
<header class="card header">
<p class="card-header-title">Admin Details</p>
</header>
<div class="card-content">
<p>
Joining instructions for participants<br/><br/>
URL: <a href="{{ ensemble_url }}">{{ ensemble_url }}</a><br/>
Code: {{ ensemble.ensemble_code }}<br/>
Passphrase: {{ ensemble.passphrase }}
</p>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -1,21 +0,0 @@
{% extends "interface/project_base.html" %}
{% load crispy_forms_tags %}
{% block page %}
<style>
TEXTAREA {
height: 200px;
}
FORM.vertical {
max-width: 90%;
}
</style>
<div>
<h3>{{ title }}</h3>
<p>{{ instructions }}</p>
<form class="vertical" method="POST">
{% csrf_token %}
{{ form | crispy }}
</form>
</div>
{% endblock %}

View File

@ -1,63 +0,0 @@
from django.test import TestCase, Client
from interface import models
class RegisterTestCase(TestCase):
def setUp(self):
self.client = Client()
@staticmethod
def setUpTestData():
e1 = models.Ensemble.objects.create(name='The Be Sharps', code="1234", passphrase='Homer')
e1.projects.create(name='Baby on Board')
e2 = models.Ensemble.objects.create(name='Lisa and the Bleeding Gums', code="2345", passphrase="Maggie")
e2.projects.create(name='Baker St')
def test_redirect(self):
response = self.client.get('/')
self.assertRedirects(response, '/register?')
def test_redirect_project(self):
response = self.client.get('/projects/1')
self.assertRedirects(response, '/register?')
def test_redirect_with_code(self):
response = self.client.get('/?code=123-456-789')
self.assertRedirects(response, '/register?code=123-456-789')
def test_register(self):
response = self.client.post('/register', {'code': '123-456-789', })
self.assertFormError(response, 'form', 'passphrase', 'This field is required.')
response = self.client.post('/register', {'code': '123-456-789', 'passphrase': 'Foo'})
self.assertFormError(response, 'form', None, 'Incorrect code or passphrase')
response = self.client.post('/register', {'code': '12-34', 'passphrase': 'Homer'})
self.assertRedirects(response, '/')
response = self.client.get(response.url)
self.assertEqual(response.context['object'].pk, 1)
# revisting original url get redirected back to homepage
response = self.client.get('/?code=12-34')
response = self.client.get(response.url)
response = self.client.get(response.url)
self.assertEqual(response.context['object'].pk, 1)
# providing a new code
response = self.client.get('/?code=23-45')
self.assertRedirects(response, '/register?code=23-45')
response = self.client.get(response.url)
self.assertQuerysetEqual(response.context['current'], ['<Ensemble: The Be Sharps>'])
#self.assertEqual(response.context['form'].code.initial, 'foo')
response = self.client.post('/register', {'code': '23-45', 'passphrase': 'maggie'})
self.assertRedirects(response, '/')
response = self.client.get('/')
self.assertEqual(response.context['object'].pk, 2)
# can use previous link to switch back without passphrase
response = self.client.get('/?code=12-34')
response = self.client.get(response.url)
response = self.client.get(response.url)
self.assertEqual(response.context['object'].pk, 1)

View File

@ -1,336 +0,0 @@
from django.shortcuts import render, get_object_or_404, redirect, resolve_url
from django.views.generic import TemplateView, RedirectView
from django.views.generic.detail import DetailView
from django.views.generic.list import ListView
from django.views.generic.edit import CreateView, UpdateView
from django.core.exceptions import SuspiciousOperation
from django.core.signing import Signer
from django.contrib import auth
from markdown2 import markdown
from . import models, forms
import logging
logger = logging.getLogger(__name__)
signer = Signer()
def signed_url(name, **kwargs):
url = resolve_url(name, **kwargs)
sig = signer.sign(url)
return sig.replace(":", "?auth=")
class EnsembleMixin(object):
admin_required = False
def dispatch(self, request, *args, **kwargs):
request.ensemble_id = request.session.get('ensemble')
request.is_admin = request.user.is_superuser
if request.is_admin:
if request.ensemble_id is None:
return redirect('ensemble_list')
return super().dispatch(request, *args, **kwargs)
if 'auth' in request.GET:
sig = signer.sign(request.path)
if sig[len(request.path)+1:] == request.GET['auth']:
logger.info("Allowing auth key")
request.is_admin = True
return super().dispatch(request, *args, **kwargs)
else:
raise SuspiciousOperation("Bad auth code")
if request.user.is_authenticated:
try:
request.user.ensembles.get(pk=request.ensemble_id)
request.is_admin = True
except models.Ensemble.DoesNotExist:
pass
if not request.ensemble_id:
return redirect('register')
if self.admin_required and not request.is_admin:
return redirect('login')
return super().dispatch(request, *args, **kwargs)
@property
def ensemble(self):
return models.Ensemble.objects.get(pk=self.request.ensemble_id)
#def get_context_data(self, **kwargs):
# context = super().get_context_data(**kwargs)
# context['ensemble'] = self.ensemble
# return context
class ProjectMixin(EnsembleMixin):
def get_project(self):
if not hasattr(self, '_project'):
if self.request.is_admin: # can access any ensemble
self._project = get_object_or_404(models.Project, pk=self.kwargs['project'])
else:
self._project = get_object_or_404(models.Project,
pk=self.kwargs['project'], ensemble=self.request.ensemble_id)
return self._project
def get_queryset(self):
return super().get_queryset().filter(project=self.get_project())
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['project'] = self.get_project()
context['modules'] = context['project'].modules.values_list('name', flat=True)
return context
def register(request):
if 'clear' in request.GET:
request.session.clear()
request.ensemble_id = request.session.get('ensemble')
registered = request.session.setdefault('registered', [])
code = request.GET.get('code', '').replace('-', '')
print("Registering with code %s", code)
# check if already joined
if code in registered or request.user.is_superuser:
request.session['ensemble'] = models.Ensemble.objects.get(code=code).pk
return redirect('ensemble_detail')
if request.method == "POST":
form = forms.CodeForm(request.POST)
if form.is_valid():
data = form.cleaned_data
try:
ensemble = models.Ensemble.objects.get(code=data['code'].replace('-', ''))
if ensemble.passphrase.lower() == data['passphrase'].lower():
request.session['ensemble'] = ensemble.pk
registered.append(ensemble.code)
return redirect('ensemble_detail')
except models.Ensemble.DoesNotExist:
form.add_error(None, "Incorrect code or passphrase")
else:
form = forms.CodeForm(initial=request.GET)
if request.user.is_superuser:
current = models.Ensemble.objects.all()
else:
current = models.Ensemble.objects.filter(pk__in=registered)
return render(request, 'interface/register.html', {'form': form, 'current': current})
def on_login(sender, **kwargs):
user = kwargs['user']
request = kwargs['request']
registered = request.session.get('registered', [])
for e in user.ensembles.all():
if not e.code in registered:
registered.append(e.code)
request.session['registered'] = registered
auth.signals.user_logged_in.connect(on_login)
def logout(request):
ensemble = request.session.get('ensemble')
registered = request.session.get('registered', {})
auth.logout(request)
request.session['ensemble'] = ensemble
request.session['registered'] = registered
return redirect('/')
class EnsembleForgetView(EnsembleMixin, RedirectView):
def get_redirect_url(self, *args, **kwargs):
registered = self.request.session.setdefault('registered', [])
ensemble = models.Ensemble.objects.get(pk=self.kwargs['pk'])
try:
registered.remove(ensemble.code)
self.request.session['registered'] = registered
except KeyError:
pass
if self.request.ensemble_id == ensemble.pk:
del(self.request.session['ensemble'])
return resolve_url('ensemble_detail')
class EnsembleListView(ListView):
def get_queryset(self):
registered = self.request.session.get('registered', [])
if self.request.user.is_superuser:
current = models.Ensemble.objects.all()
else:
current = models.Ensemble.objects.filter(code__in=registered)
return current
#def get_context_data(self, **kwargs):
# context = super().get_context_data(**kwargs)
# context['ensemble_id'] = self.request.session.get('ensemble')
# return context
class EnsembleProjectListView(EnsembleMixin, DetailView):
template_name = 'interface/ensemble_project_list.html'
def dispatch(self, request, *args, **kwargs):
# capture provided urls
if 'code' in request.GET:
return redirect('/register?code={0}'.format(request.GET['code']))
return super().dispatch(request, *args, **kwargs)
def get_object(self):
return models.Ensemble.objects.get(pk=self.request.ensemble_id)
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
if self.request.is_admin:
data['ensemble_url'] = self.request.build_absolute_uri('/?code={0}'.format(self.ensemble.ensemble_code()))
return data
class EnsembleDetailView(DetailView):
model = models.Ensemble
class ProjectDetailView(ProjectMixin, DetailView):
def get_object(self):
return self.get_project()
class ProjectCreateView(EnsembleMixin, CreateView):
model = models.Project
template_name = "interface/default_form.html"
title = "Add a new project"
form_class = forms.ProjectForm
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.ensemble_id = self.request.ensemble_id
self.object.owner = self.request.user
self.object.save()
return redirect('project_detail', project=self.object.pk)
class ProjectUpdateView(EnsembleMixin, UpdateView):
model = models.Project
template_name = "interface/default_form.html"
#fields = ['name', 'description', 'event_date', 'enable_library', 'enable_submissions', 'active']
pk_url_kwarg = 'project'
form_class = forms.ProjectForm
def get_success_url(self):
return resolve_url('project_detail', project=self.kwargs['project'])
#class ProjectMakefileView(EnsembleMixin, DetailView):
# template_name = 'interface/project_submissions.mk'
# content_type = 'text/plain'
#
# def get_queryset(self):
# if self.request.is_admin:
# return models.Project.objects.all()
#
# return models.Project.objects.filter(ensemble=self.request.ensemble_id)
#
# def get_context_data(self, **kwargs):
# data = super().get_context_data(**kwargs)
#
# data['submissions'] = []
# data['targets'] = []
# for s in self.object.submissions:
# name = s.short_name
# data['targets'].append(name)
# data['submissions'].append({
# 'url': self.request.build_absolute_uri(signed_url('submission_download', project=self.kwargs['pk'], pk=s.pk)),
# 'name': name,
# })
#
# return data
class WikiView(ProjectMixin, DetailView):
template_name = 'interface/wiki.html'
model = models.WikiPage
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
data['wiki_html'] = markdown(self.object.markdown)
return data
class WikiCreateView(ProjectMixin, CreateView):
admin_required = True
model = models.WikiPage
fields = ['title', 'markdown']
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.project = self.get_project()
self.object.save()
return redirect('wiki', project=self.object.project_id, pk=self.object.pk)
class WikiEditView(ProjectMixin, UpdateView):
admin_required = True
model = models.WikiPage
fields = ['title', 'markdown']
class ResourceCreateView(ProjectMixin, CreateView):
model = models.Resource
form_class = forms.ResourceForm
template_name = 'interface/project_form.html'
title = "Add a new resource"
admin_required = True
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.project = self.get_project()
self.object.save()
return redirect('resource_upload', project=self.object.project_id, pk=self.object.pk)
class ResourceUploadView(ProjectMixin, UpdateView):
admin_required = True
model = models.Resource
fields = ['file']
template_name = 'interface/default_form.html'
def get_success_url(self):
return resolve_url('resource_list', project=self.kwargs['project'])
class ResourceListView(ProjectMixin, ListView):
model = models.Resource
def get_queryset(self):
qs = super().get_queryset()
if not self.request.is_admin:
qs = qs.filter(visible=True)
return qs
class ResourceEditView(ProjectMixin, UpdateView):
admin_required = True
model = models.Resource
fields = ['name', 'description', 'visible']
template_name = 'interface/default_form.html'
def get_success_url(self):
return resolve_url('resource_list', project=self.kwargs['project'])
class ManageView(EnsembleMixin, TemplateView):
template_name = 'interface/manage.html'
admin_required = True
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['ensemble'] = models.Ensemble.objects.get(pk=self.request.ensemble_id)
context['ensemble_url'] = self.request.build_absolute_uri('/?code={0}'.format(context['ensemble'].ensemble_code()))
return context

View File

@ -1,26 +0,0 @@
{% extends "interface/project_base.html" %}
{% block page %}
<h3 class="title">Library collections for {{ request.user }}</h3>
<div class="columns is-multiline">
{% for collection in object_list %}
<div class="column is-half">
<div class="card">
<header class="card-header">
<a class="" href="{% url 'collection_work_list' pk=collection.id %}">
<p class="card-header-title">{{ collection.name }}</p>
</a>
</header>
<div class="card-content">
<p>{{ collection.location }}, {{ collection.works.count }} items.</p>
</div>
</div>
</div>
{% endfor %}
</div>
<div>
<small>{{ ensemble.ensemble_code }}</small>
</div>
{% endblock %}