Author SHA1 Message Date
Tris Forster 04bf1ab1f3 Improved auto tagging 2023-03-27 10:34:20 +11:00
Tris Forster fb700b15d6 Added search bar to collection list 2023-03-04 17:12:58 +11:00
Tris Forster 0bd2596ebc Added search bar to collection list 2023-03-04 17:06:28 +11:00
Tris Forster 1f0a336ed4 Added search bar to collection list 2023-03-04 17:02:18 +11:00
Tris Forster 220163bca2 Added Number 2023-03-03 19:56:22 +11:00
Tris Forster d1328ae1b1 Many interface changes 2023-03-03 16:07:30 +11:00
Tris Forster 69f747d5b5 Fixed part selection dropdown 2023-03-02 16:25:38 +11:00
Tris Forster 2a6554471d List works on project page 2023-03-02 13:28:24 +11:00
Tris Forster 6edc612ed6 Fixed gitignore 2023-03-02 12:30:39 +11:00
Tris Forster f441940e8c Layout tweeks 2023-03-02 12:15:19 +11:00
Tris Forster b1eaf9c7bc Added document extractor 2023-03-02 10:05:26 +11:00
Tris Forster a892b0bc41 Got api work import working 2023-03-02 07:09:38 +11:00
Tris Forster f1757be96e Dont tag files that aren't PDFs 2023-03-01 14:42:05 +11:00
Tris Forster 95ee92cef6 Fixed related work links 2023-03-01 14:13:52 +11:00
Tris Forster b11cfcd6ea Added primitive doc type checking 2023-03-01 13:56:52 +11:00
Tris Forster 6e1b26440f Added coverage testing 2023-03-01 13:39:56 +11:00
Tris Forster 4268b66b27 Added module management 2023-03-01 13:15:09 +11:00
Tris Forster f22961ad44 Couple of little fixes 2023-03-01 12:41:42 +11:00
Tris Forster caeee16657 Extended PROTETED_URLS tests 2023-03-01 12:41:10 +11:00
Tris Forster 4344cb978b Fixed reference to section type 2023-03-01 11:25:38 +11:00
Tris Forster a3d8cf0a21 Fixed reference to section type 2023-03-01 11:21:06 +11:00
Tris Forster 70aa7ae3a3 Fixed reference to section type 2023-03-01 11:18:35 +11:00
Tris Forster f840ee3d8b Couple of interface tweeks 2023-02-28 16:56:22 +11:00
Tris Forster 7d4f959146 Fixing work edit 2023-02-23 20:33:38 +11:00
Tris Forster 1cb00ebc0b Fixing bulk importer 2023-02-23 20:29:04 +11:00
Tris Forster 928173976b Got my music working again 2023-02-23 19:29:20 +11:00
Tris Forster d7892d355d Moved project filtering to the right place - the queryset 2023-02-23 19:18:23 +11:00
Tris Forster dbbfa79f10 No date for perpetual projects 2023-02-23 19:03:01 +11:00
Tris Forster f49ff0fd0e Moved project list to model 2023-02-23 18:37:21 +11:00
Tris Forster 6ec5808275 Refactored tests 2023-02-23 18:36:39 +11:00
Tris Forster 948e9deb54 Got collection links working and tested 2023-02-23 14:27:50 +11:00
Tris Forster 75de40f2bd Cleaned up access tests 2023-02-23 10:22:58 +11:00
Tris Forster 295999eaef Refactored test access (eventually) 2023-02-22 18:46:12 +11:00
Tris Forster f9fa9d5e05 Stupid typo 2023-02-21 09:11:14 +11:00
Tris Forster c720cb773e Getting partset working 2023-02-20 14:21:34 +11:00
Tris Forster da15eebb0a Silly fix 2023-02-20 10:31:56 +11:00
Tris Forster e74043ae67 Fixed work create and some security issues 2023-02-20 10:28:38 +11:00
Tris Forster 5a201cef85 Fixed add work 2023-02-20 08:03:32 +11:00
Tris Forster 5fb5138383 Fix ensemble reference 2023-02-20 07:56:53 +11:00
Tris Forster 039b440a57 Tweak! 2023-02-20 03:38:23 +11:00
Tris Forster 7d394e2f34 Updated requirements 2023-02-16 20:47:16 +11:00
Tris Forster 13f466228b Got library working again 2023-02-16 17:22:18 +11:00
Tris Forster b85440d25c Finally got authentication to somewhere I'm happy 2023-02-10 12:01:32 +11:00
Tris Forster bc9f292a2e Major changes to permission system 2023-02-03 10:10:54 +11:00
Tris Forster 8a249de51c Tweeking section handling 2023-01-04 09:52:22 +11:00
Tris Forster 7e47eec4ae Orchestration cleanup 2022-12-10 10:18:45 +11:00
Tris Forster 59deeffefe Improved upload 2022-12-02 13:24:31 +11:00
Tris Forster 53ec846f98 Refactored into /app 2022-11-28 14:11:39 +11:00
Tris Forster 94bba3769a Pre refactor 2022-11-27 10:20:13 +11:00
Tris Forster 2726a8fe04 Adding font 2022-11-19 12:46:58 +11:00
Tris Forster 4731d18131 Add static folder 2022-11-19 12:32:17 +11:00
Tris Forster c639020ac9 Fixing migrations 2022-11-19 12:24:40 +11:00
Tris Forster 7f6875f3c4 Updated byostorage 2022-11-19 12:10:35 +11:00
Tris Forster 025e1344f0 Recreating migrations 2022-11-19 22:43:15 +11:00
Tris Forster bbc74a77f9 Removed old deps 2022-11-19 22:37:37 +11:00
Tris Forster dfe4a925c7 Added secret key! 2022-11-19 22:35:06 +11:00
Tris Forster 18e5893cc2 Updated requirements 2022-11-19 22:33:21 +11:00
Tris Forster 988161b599 Added default settings 2022-11-19 22:31:06 +11:00
116 changed files with 3517 additions and 1719 deletions
+2
View File
@@ -0,0 +1,2 @@
local_settings.py
db.sqlite3
+12 -9
View File
@@ -1,12 +1,15 @@
__pycache__
*.pyc
db.sqlite3
*.sqlite3
credentials.json
credentials
polyphonic/settings.py
env
test.*
static
teststore
cache
local_storage
media
local_settings.py
.coverage
/env
/data
/old
/static
/teststore
/cache
/local_storage
/media
+21
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 SECRET_KEY=_ python3 manage.py collectstatic --noinput
VOLUME ["/var/polyphonic"]
ENTRYPOINT ["python3", "manage.py"]
CMD ["runserver", "0.0.0.0:8000", "--insecure"]
+5 -1
View File
@@ -1,6 +1,11 @@
PYTHON=env/bin/python
DROPZONE=5.7.0
test:
coverage run --include "app/*" --omit "*/migrations/*" app/manage.py test app
coverage html
coverage report
dev-setup:
env/bin/pip install -r requirements.txt
env/bin/pip install -r dev-requirements.txt
@@ -35,4 +40,3 @@ start_s3_storage:
stop_s3_storage:
kill `cat teststore/pid` | true
rm teststore/pid
@@ -3,7 +3,7 @@ from django.contrib import admin
from . import models
class EnsembleAdmin(admin.ModelAdmin):
list_display = ['name', 'ensemble_code', 'slug']
list_display = ['name', 'slug']
class ModuleInline(admin.StackedInline):
model = models.Module
+62
View File
@@ -0,0 +1,62 @@
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,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')),
],
),
@@ -0,0 +1,25 @@
# Generated by Django 3.2.7 on 2023-02-01 21:04
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('interface', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='project',
options={'ordering': ['active', 'event_date']},
),
migrations.RemoveField(
model_name='ensemble',
name='code',
),
migrations.RemoveField(
model_name='ensemble',
name='passphrase',
),
]
@@ -0,0 +1,27 @@
# Generated by Django 3.2.7 on 2023-02-08 22:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('interface', '0002_auto_20230202_0804'),
]
operations = [
migrations.AlterModelOptions(
name='ensemble',
options={'ordering': ('slug',)},
),
migrations.AddField(
model_name='ensemble',
name='auth',
field=models.SmallIntegerField(default=1, help_text='Increment this to reset the authentication links'),
),
migrations.AddField(
model_name='project',
name='auth',
field=models.SmallIntegerField(default=1, help_text='Increment this to reset the authentication links'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 3.2.7 on 2023-02-09 22:38
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('interface', '0003_auto_20230209_0910'),
]
operations = [
migrations.RenameField(
model_name='ensemble',
old_name='auth',
new_name='nonce',
),
migrations.RenameField(
model_name='project',
old_name='auth',
new_name='nonce',
),
]
+85 -19
View File
@@ -8,11 +8,10 @@ from byostorage.user import BYOStorage
import random
from datetime import datetime
from urllib.parse import urlparse
import os.path
from .utils import sign_data
MEDIA_TYPES = [
('audio', "Audio"),
@@ -21,12 +20,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"
@@ -35,6 +41,19 @@ def rough_date(d):
def generate_code(length=9):
return "".join([ random.choice('0123456789') for _ in range(length) ])
class EnsembleQuerySet(models.QuerySet):
def for_user(self, user, ensemble_keys=[], project_keys=[]):
if user.is_superuser:
return self
f = models.Q(slug__in=ensemble_keys) | models.Q(projects__in=project_keys)
if user.is_authenticated:
f |= models.Q(admins=user.pk)
return self.filter(f).distinct()
class Ensemble(models.Model):
''' A group that plays together
@@ -43,31 +62,60 @@ 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")
admins = models.ManyToManyField('auth.User', related_name='ensembles')
details = models.TextField(blank=True,
help_text="Description of the ensemble (markdown)")
storage = models.ForeignKey('byostorage.UserStorage', null=True, on_delete=models.SET_NULL,
help_text="Default storage for this ensemble")
nonce = models.SmallIntegerField(default=1,
help_text="Increment this to reset the authentication links")
objects = EnsembleQuerySet.as_manager()
class Meta:
ordering = ('slug', )
def active_projects(self):
return self.projects.filter(active=True).order_by('event_date')
return self.projects.active().current()
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)
super(Ensemble, self).save(**kwargs)
def get_absolute_url(self):
return resolve_url('ensemble_detail', ensemble=self.slug)
def auth(self):
return sign_data(f'{self.pk}-{self.nonce}', 12)
def __str__(self):
return self.name
class ProjectQuerySet(models.QuerySet):
def current(self):
return self.filter(models.Q(event_date__gte=(timezone.now()-timezone.timedelta(7))) | models.Q(event_date=None))
def active(self):
return self.filter(active=True)
def for_user(self, user, project_keys=[], ensemble_keys=[]):
if user.is_superuser:
return self
f = models.Q(pk__in=project_keys) | models.Q(ensemble__slug__in=ensemble_keys)
if user.is_authenticated:
f |= models.Q(ensemble__admins=user.pk)
return self.filter(f)
class Project(models.Model):
''' A Project linked to an ensemble
@@ -79,13 +127,13 @@ class Project(models.Model):
active = models.BooleanField(default=True)
event_date =models.DateTimeField(null=True, blank=True)
owner = models.CharField(max_length=255, blank=True)
nonce = models.SmallIntegerField(default=1,
help_text="Increment this to reset the authentication links")
objects = ProjectQuerySet.as_manager()
class Meta:
ordering = ['active', '-pk']
@property
def submissions(self):
return self.all_submissions.order_by('-pk')
ordering = ['active', 'event_date']
@property
def days(self):
@@ -93,10 +141,14 @@ class Project(models.Model):
@property
def has_happened(self):
if not self.event_date:
return False
return self.event_date < timezone.now()
@property
def rough_date(self):
if not self.event_date:
return "No timescale"
in_past, s = rough_date(self.event_date)
if in_past:
return f"{s} ago"
@@ -104,8 +156,19 @@ 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 get_absolute_url(self):
return resolve_url('project_detail', project=self.pk)
def auth(self):
return sign_data(f'{self.pk}-{self.nonce}', 12)
def __str__(self):
return self.name
@@ -116,6 +179,9 @@ class Module(models.Model):
name = models.SlugField(max_length=20, choices=[ (x, x.title()) for x in settings.POLYPHONIC_MODULES ])
project = models.ForeignKey(Project, related_name="modules", on_delete=models.CASCADE)
def __str__(self):
return self.name
def resource_key(resource, filename):
return f'{resource.project.folder}/resources/{filename}'
Binary file not shown.

Before

Width:  |  Height:  |  Size: 426 KiB

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

@@ -0,0 +1,29 @@
document.addEventListener('DOMContentLoaded', () => {
// Get all "navbar-burger" elements
const navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);
// Add a click event on each of them
navbarBurgers.forEach( el => {
el.addEventListener('click', () => {
// Get the target from the "data-target" attribute
const target = document.getElementById(el.dataset.target);
// Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu"
el.classList.toggle('is-active');
target.classList.toggle('is-hidden-touch');
});
});
// set all active links
const here = location.toString();
for (const el of document.getElementsByTagName('a')) {
if (el.href == here) {
el.classList.add('is-active');
}
}
});
+8
View File
@@ -0,0 +1,8 @@
{% extends "interface/project_base.html" %}
{% block page %}
<div class="hero">
<h3 class="is-size-3">Sorry, you do not have permission to do that!</h3>
<p>{{ exception }}</p>
</div>
{% endblock %}
+8
View File
@@ -0,0 +1,8 @@
{% extends "interface/project_base.html" %}
{% block page %}
<div class="hero">
<h3 class="is-size-3">Sorry, that resource is not found.</h3>
<p>{{ exception }}</p>
</div>
{% endblock %}
@@ -9,19 +9,24 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css">
<link rel="stylesheet" href="{% static 'interface/css/polyphonic.css' %}"></link>
<script src="{% static 'interface/js/interface.js' %}"></script>
<script src="//unpkg.com/alpinejs" defer></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js" defer></script>
<script src="//kit.fontawesome.com/c837098e5b.js" crossorigin="anonymous" defer></script>
<title>{% block title %}Polyphonic{% endblock %}</title>
{% block media %}{% endblock %}
<style>{% block style %}{% endblock %}</style>
</head>
<body>
{% block navigation %}
<nav class="navbar" role="navigation">
<div class="navbar-brand has-text-primary">
<a class="navbar-item" href="/">
<span class="icon fancy is-size-2 mx-4"><i class="fas fa-random"></i></span>
<span class="fancy is-size-2">Polyphonic</span>
<span class="icon fancy is-size-2 is-size-4-mobile mx-4"><i class="fas fa-random"></i></span>
<span class="fancy is-size-2 is-size-4-mobile">Polyphonic</span>
</a>
<span class="navbar-item is-hidden-mobile fancy is-size-5">Music Ensemble Manager</span>
<span class="navbar-item is-hidden-mobile fancy is-size-5">Musical Ensemble Manager</span>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="projectMenu">
@@ -34,7 +39,7 @@
<div id="navbarMain" class="navbar-menu">
<div class="navbar-end">
<span class="navbar-item is-size-4">{% firstof ensemble project.ensemble %}</span>
<span class="navbar-item is-size-5-touch is-size-4-tablet">{% firstof ensemble project.ensemble %}</span>
</div>
</div>
@@ -47,8 +52,6 @@
{% endblock %}
<!-- late load scripts -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="https://kit.fontawesome.com/c837098e5b.js" crossorigin="anonymous"></script>
{% block scripts %}
{% endblock %}
</body>
@@ -8,11 +8,11 @@
{% block page %}
<h3 class="subtitle">{% firstof title view.title %}</h3>
<div class="columns is-centered">
<div class="column is-form-group">
<div class="column is-two-thirds">
{% if instructions %}
<p>{{ instructions }}</p>
{% endif %}
{% crispy form %}
{% crispy form %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,57 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% block admin %}
<a href="{% url 'project_create' object.slug %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add project</span>
</a>
{% if inactive %}
<a href="?" class="button is-link">
<span class="icon"><i class="fas fa-archive"></i></span>
<span>Hide old</span>
</a>
{% else %}
<a href="?inactive" class="button is-link">
<span class="icon"><i class="fas fa-archive"></i></span>
<span>Show all</span>
</a>
{% endif %}
{% endblock %}
{% block page %}
<h3 class="title">Projects for {{ensemble.name }}</h3>
<div class="content">
{{ ensemble.details|markdown }}
</div>
<div class="block">
Contacts:
{% for admin in ensemble.admins.all %}
<a href="mailto:{{ admin.email }}" class="tag">{% firstof admin.get_full_name admin.get_username %}</a>
{% endfor %}
</div>
{% include 'interface/project_items.html' %}
{% if request.is_admin %}
<div class="">
<div class="card">
<header class="card header">
<p class="card-header-title">Admin Details</p>
</header>
<div class="card-content">
<ul>
<li><a href="{{ ensemble_link }}">Ensemble Sharing Link</a></li>
<li><a href="{% url 'project_create' ensemble.pk %}">Add a new project</a></li>
</ul>
</div>
</div>
</div>
{% endif %}
<div>
<a href="{% url 'forget_resource' 'ensemble' ensemble.slug %}">Forget this ensemble</a>
</div>
{% endblock %}
@@ -0,0 +1,48 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% block page %}
{% comment %}
<div class="admin-tools is-pulled-right">
<a class="button is-link" href="{% url 'register' %}">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Register another</span>
</a>
</div>
{% endcomment %}
<h3 class="title">My Ensembles</h3>
<div class="columns is-multiline">
{% for ensemble in object_list %}
<div class="column is-half-tablet is-one-third-widescreen">
<div class="card">
<div class="card-content">
<div class="media">
<div class="media-left">
<figure class="image is-48x48">
<img src="https://www.gravatar.com/avatar/{{ ensemble.email }}?d=mp" alt="Placeholder image">
</figure>
</div>
<div class="media-content" style="min-height: 60px">
<a href="{% url 'ensemble_detail' ensemble.slug %}">
<p class="title is-4">{{ ensemble.name }}</p>
</a>
</div>
</div>
</div>
<div class="card-footer">
{% with projects=ensemble.active_projects.count %}
<a class="card-footer-item" href="{% url 'ensemble_detail' ensemble.slug %}">{{ projects }} active project{{ projects|pluralize }}</a>
{% endwith %}
</div>
</div>
</div>
{% empty %}
<div class="hero">
You don't currently have access to any ensembles - ask your administrator for a link.
</div>
{% endfor %}
</div>
{% endblock %}
@@ -2,9 +2,7 @@
{% block content %}
<div class="columns">
<div class="columns mx-2">
<div class="column is-narrow is-hidden-touch" id="projectMenu">
<div style="margin: auto 2em;">
<aside class="menu">
@@ -12,44 +10,41 @@
<p class="menu-label">My Things</p>
<ul class="menu-list">
<li><a href="{% url 'ensemble_list' %}">Ensembles</a></li>
<li><a href="{% url 'ensemble_detail' %}">Projects</a></li>
<li><a href="{% url 'work_list' %}">Library</a></li>
<li><a href="{% url 'project_list' %}">Projects</a></li>
{% if request.user.is_authenticated %}
<!--li><a href="{% url 'work_list' %}">Library</a></li-->
<li><a href="{% url 'collection_list' %}">Collections</a></li>
{% endif %}
</ul>
{% if project %}
<p class="menu-label">This Project</p>
<ul class="menu-list">
<li><a role="tab" href="{% url 'project_detail' project=project.id %}">Project Info</a></li>
{% for page in project.wiki_pages.all %}
<li><a class="nav-link {% if page.id == wiki_id %}active{% endif %}"
href="{% url 'wiki' project=project.id pk=page.id %}">{{ page.title }}</a></li>
{% endfor %}
<li><a role="tab" href="{% url 'resource_list' project=project.pk %}">Resources</a></li>
{% if 'library' in modules %}
<li><a href="{% url 'item_list' project=project.id %}">My Music</a></li>
{% endif %}
{% if 'submission' in modules %}
<!--a role="tab" href="">Record a submission</a-->
{% if request.is_admin %}
<li><a role="tab" class="admin-link" href="{% url 'submission_list' project=project.id %}">Submissions</a></li>
<ul class="menu-list">
<li><a role="tab" href="{% url 'project_detail' project=project.id %}">Project Info</a></li>
{% if 'library' in modules %}
<li><a class="nav-link" href="{% url 'item_list' project=project.pk %}">My Music</a></li>
{% endif %}
{% for page in project.wiki_pages.all %}
<li><a class="nav-link"
href="{% url 'wiki' project=project.id pk=page.id %}">{{ page.title }}</a></li>
{% endfor %}
<li><a role="tab" href="{% url 'resource_list' project=project.pk %}">Resources</a></li>
</ul>
{% endif %}
<li><a role="tab" href="{% url 'submission_create' project=project.id %}">Send a File</a></li>
{% endif %}
{% if collection %}
<p class="menu-label">Collection</p>
<ul class="menu-list">
<li><a role="tab" href="{% url 'collection_work_list' collection.pk %}">Work List</a></li>
</ul>
{% endif %}
<p class="menu-label">Admin</p>
{% if request.is_admin %}
<ul class="menu-list">
<li><a class="admin-link" href="{% url 'collection_list' %}">Collections</a></li>
{% if request.user.is_superuser %}
<li><a class="admin-link" href="/admin" target="polyphonic_admin" rel="noopener noreferrer">Django Admin</a></li>
{% if request.user.is_staff %}
<li><a href="/admin" target="polyphonic_admin" rel="noopener noreferrer">Django Admin</a></li>
{% endif %}
</ul>
{% endif %}
<ul class="menu-list">
{% if request.user.is_authenticated %}
@@ -76,19 +71,19 @@
{% if project.resources.count %}
<li><a href="{% url 'resource_list' project=project.pk %}">Resources</a></li>
{% endif %}
{% if project.enable_library %}
{% if 'library' in modules %}
<li><a href="{% url 'item_list' project=project.pk %}">My Music</a></li>
{% endif %}
{% if project.enable_submissions %}
{% if 'submission' in modules %}
<li><a href="{% url 'submission_create' project=project.pk %}">Send File</a></li>
{% endif %}
</ul>
</div>
{% endif %}
<section class="section">
<div class="block">
{% if request.is_admin %}
<div class="admin-tools is-pulled-right">
<div class="admin-tools is-pulled-right is-hidden-mobile">
{% block admin %}
{% endblock %}
</div>
@@ -97,14 +92,8 @@
{% block page %}
No content
{% endblock %}
</section>
</div>
</div>
</div>
{% if ensemble %}
<div class="project-footer">
{{ ensemble.name }}
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,84 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% load polyphonic %}
{% block admin %}
<a href="{% url 'wiki_create' project=project.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-file"></i></span>
<span>Add Page</span>
</a>
<a href="{% url 'project_edit' project=project.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-edit"></i></span>
<span>Edit</span>
</a>
{% endblock %}
{% block page %}
<div class="columns is-multiline">
<div class="column ">
<div class="box">
{% if project.event_date %}
<h3 class="subtitle is-centered">
<b>{{ project.event_date|date:"l jS F Y, g:i A" }}</b>
{% if project.has_happened %}
({{ project.event_date|roughtimesince }} ago)
{% else %}
(in {{ project.event_date|roughtimeuntil }})
{% endif %}
</h3>
{% endif %}
<div class="block">
{% if project.description %}
<p class="content">{{ project.description|markdown }}</p>
{% else %}
<p>No description</p>
{% endif %}
</div>
{% if project.owner %}
<div class="block">
{% if project.owner.email %}
The project owner is <a href="mailto:{{ project.owner.email }}">{{ project.owner }}</a>
{% else %}
The project owner is {{ project.owner }}.
{% endif %}
</div>
{% endif %}
</div>
</div>
{% if 'library' in modules %}
<div class="column is-one-third">
{% include 'library/project_detail.html' %}
</div>
{% endif %}
{% if 'submission' in modules %}
<div class="column">
{% include 'submissions/project_detail.html' %}
</div>
{% endif %}
</div>
{% if request.is_admin %}
<div class="box">
<h3 class="subtitle">Admin Actions</h3>
<ul>
<li><a href="{{ project_link }}">Project Link</a></li>
{% if 'library' in modules %}
<li><a href="{% url 'item_list_manage' project=project.pk %}">Manage items</a></li>
{% endif %}
</ul>
</div>
{% endif %}
<!--
<div>
<a href="{% url 'forget_resource' 'project' project.pk %}">Forget this project</a>
</div>
-->
{% endblock %}
@@ -0,0 +1,36 @@
{% load md2 %}
<div class="columns is-multiline">
{% for project in object_list %}
<div class="column is-half-tablet is-one-third-widescreen">
<div class="card">
<a class="" href="{% url 'project_detail' project=project.id %}">
<header class="card-header{% if not project.active %} has-background-light{% endif %}">
<p class="card-header-title">
{{ project.name }}
</p>
<p class="card-header-icon" style="color: black;">{{ project.rough_date }}</p>
</header>
</a>
<div class="card-content" style="height: 100px; overflow: hidden">
<div class="content">
{{ project.description | markdown }}
</div>
</div>
{% if not ensemble %}
<div class="card-footer">
<a class="card-footer-item" href="{% url 'ensemble_detail' project.ensemble.slug %}">{{ project.ensemble }}</a>
</div>
{% endif %}
</div>
</div>
{% empty %}
<div class="hero">
<div class="hero-body">
<p class="title">No projects currently planned</p>
<p class="subtitle">Go put your feet up!</p>
</div>
</div>
{% endfor %}
</div>
@@ -0,0 +1,8 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% block page %}
<h3 class="title">My Projects</h3>
{% include 'interface/project_items.html' %}
{% endblock %}
@@ -9,6 +9,7 @@
{% endblock %}
{% block page %}
<h3 class="subtitle">Resources</h3>
<div class="columns is-multiline">
{% for resource in object_list %}
{% with download=resource.file.url %}
@@ -51,7 +52,7 @@
{% endwith %}
{% empty %}
<div class="column">
<p>There are resources for this project</p>
<p>There are no resources for this project</p>
</div>
{% endfor %}
</div>
@@ -8,7 +8,10 @@
{% block page %}
<h3 class="subtitle">{% firstof title view.title %}</h3>
<div class="columns is-centered">
<div class="column is-form-group">
<div class="column">
{% if instructions %}
<p>{{ instructions }}</p>
{% endif %}
{% crispy form %}
</div>
</div>
@@ -7,7 +7,6 @@
<div class="box is-half">
<p class="block">
Login is only required to administer a project.<br/>
If you have an ensemble code <a href="{% url 'register' %}">enter it here</a> instead.
</p>
<form method="POST" class="vertical">
{% csrf_token %}
@@ -15,7 +14,7 @@
<div class="field is-grouped">
<div class="control">
<button class="button is-link">Login</button>
<a href="{% url 'ensemble_detail' %}" class="button is-light">Cancel</a>
<a href="{% url 'home' %}" class="button is-light">Cancel</a>
</div>
</div>
</form>
+69
View File
@@ -0,0 +1,69 @@
from django.test import TestCase
from interface import models, utils
from django.contrib.auth.models import User
from django.utils import timezone
from datetime import timedelta
class AccessTestCase(TestCase):
USERS = ()
ENSEMBLES = ()
PROJECTS = ()
PROTECTED_URLS = ()
@classmethod
def setUpTestData(cls):
cls.users = {}
for details in cls.USERS:
cls.users[details['username']] = User.objects.create_user(**details)
now = timezone.now()
cls.ensembles = {}
for details in cls.ENSEMBLES:
admins = details.pop('admins', [])
obj = models.Ensemble.objects.create(**details)
for admin in admins:
obj.admins.add(cls.users[admin])
cls.ensembles[obj.slug] = obj
cls.projects = {}
for details in cls.PROJECTS:
when = details.pop('when', 0)
ensemble = details.pop('ensemble')
details['event_date'] = now + timedelta(days=when) if when else None
obj = cls.ensembles[ensemble].projects.create(**details)
cls.projects[details['name']] = obj
return
def test_protected_views(self):
self.assertAccess({ x: False for x in self.PROTECTED_URLS })
if 'admin' in self.users:
self.client.force_login(self.users['admin'])
self.assertAccess({ x: True for x in self.PROTECTED_URLS })
def login(self, user, passwd):
response = self.client.post('/login', {'username': user, 'password': passwd})
self.assertEqual(response.status_code, 302, f"Failed to login as {user}")
def authorize(self, model, **kwargs):
object = model.objects.get(**kwargs)
response = self.client.get(f'{object.get_absolute_url()}?auth={object.auth()}')
self.assertEqual(response.status_code, 302)
def assertAccess(self, urls):
for url, expected in urls.items():
response = self.client.get(url)
self.assertEqual(response.status_code == 200, expected, f"Expected {expected} for {url} (status: {response.status_code})")
def assertObjectList(self, response, expected, element='name'):
self.assertEqual(response.status_code, 200, "No result returned")
objects = response.context['object_list'].values_list(element, flat=True)
self.assertEqual(list(objects), expected)
+183
View File
@@ -0,0 +1,183 @@
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
})
+14 -7
View File
@@ -1,20 +1,22 @@
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('forget/<resource>/<key>', views.ForgetResourceView.as_view(), name="forget_resource"),
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('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 +29,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'}))
+38
View File
@@ -0,0 +1,38 @@
from django.shortcuts import resolve_url
from django.core.signing import Signer
from django.core.exceptions import SuspiciousOperation
signer = Signer()
import logging
logger = logging.getLogger(__name__)
def sign_data(data, l=None):
sig = signer.sign(data)
p = len(data) + 1
if l:
l += p
return sig[p:l]
def signed_url(name, **kwargs):
"""
>>> signed_url('foo/bar')
"""
url = resolve_url(name, **kwargs)
sig = signer.sign(url)
sep = "&" if "?" in url else "?"
return sig.replace(":", f"{sep}auth=")
def check_signed_url(full_path):
p = full_path.rfind('auth')
url = full_path[:p-1]
logger.debug("check_signed_url: %s", url)
signed = signed_url(url)
if signed != full_path:
logger.debug("Mismatch: %s != %s", full_path, signed)
signed = "_HIDDEN_"
raise SuspiciousOperation("Bad auth code")
if __name__ == '__main__':
import doctest
print(doctest.testmod())
+434
View File
@@ -0,0 +1,434 @@
"""
"""
from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.views.generic import RedirectView
from django.views.generic.detail import DetailView, SingleObjectMixin
from django.views.generic.list import ListView
from django.views.generic.edit import CreateView, UpdateView, FormMixin
from django.core.exceptions import SuspiciousOperation
from django.http import Http404, HttpResponseRedirect
from django.db.models import Q
from django.utils import timezone
from markdown2 import markdown
from . import models, forms
from interface.utils import check_signed_url
import logging
logger = logging.getLogger(__name__)
class AuthorizedResourceMixin(object):
"""
Handles these parts of the permission system:
* signed urls
* persistent authenticated resources
* Admin enforcing
"""
SESSION_KEY = 'authorized'
admin_required = False
def is_authorized(self):
"By default check if superuser or a signed request"
if self.request.is_admin:
#logger.debug("is_authorized: superuser")
return True
if 'sig' in self.request.GET:
check_signed_url(self.request.get_full_path())
self.on_signed_request()
return True
return False
def on_signed_request(self):
pass
def is_authorized_key(self, resource, key, auth):
current = self.get_authorized_keys(resource).get(str(key), None)
if current is None:
#logger.debug("is_authorized_key: %s %s not in session", resource, key)
return False
if auth == current:
return True
#logger.info("Authorisation revoked")
self.del_authorized_key(resource, key)
return False
def get_authorized_keys(self, resource):
'Returns a set of authorized keys for this resource'
return self._authorized.get(resource, {})
def add_authorized_key(self, resource, key, auth):
'Adds a key to the authorized list for this resource'
current = self.get_authorized_keys(resource)
current[str(key)] = auth
self._authorized[resource] = current
self.request.session[self.SESSION_KEY] = self._authorized
def del_authorized_key(self, resource, key):
logger.info("Revoking authorization for %s %s", resource, key)
current = self.get_authorized_keys(resource)
if current.pop(key, None) is None:
return False
if current:
self._authorized[resource] = current
else:
self._authorized.pop(resource)
self.request.session[self.SESSION_KEY] = self._authorized
return True
def request_denied(self):
raise Http404("Either the given resource doesn't exist or you dont have access to it.")
def dispatch(self, request, *args, **kwargs):
self._authorized = request.session.get('authorized', {})
request.is_admin = request.user.is_superuser
if not self.is_authorized():
return self.request_denied()
if self.admin_required and not request.is_admin:
return self.request_denied()
return super().dispatch(request, *args, **kwargs)
# TODO: RevokeResourceView - increment nonce
class ForgetResourceView(AuthorizedResourceMixin, RedirectView):
def is_authorized(self):
return True
def get_redirect_url(self, resource, key):
self.del_authorized_key(resource, key)
return "/"
class EnsembleMixin(AuthorizedResourceMixin):
ensemble_slug_kwarg = 'ensemble'
limited_project_access = False
def is_authorized(self):
ensemble_slug = self.kwargs[self.ensemble_slug_kwarg]
self.ensemble = get_object_or_404(models.Ensemble, slug=ensemble_slug)
if super().is_authorized():
return True
if self.ensemble.has_admin(self.request.user):
self.request.is_admin = True
return True
if self.is_authorized_key('ensemble', ensemble_slug, self.ensemble.nonce):
return True
authorized = set([ int(x) for x in self.get_authorized_keys('project').keys() ])
projects = set(self.ensemble.projects.values_list('pk', flat=True))
logger.debug("is_authorized: %r & %r", authorized, projects)
if authorized & projects:
self.limited_project_access = True
logger.debug("is_authorized: allowing due to project link")
return True
return False
def get_object(self):
return self.ensemble
class ProjectMixin(AuthorizedResourceMixin):
project_kwarg = 'project'
def is_authorized(self):
project_id = self.kwargs[self.project_kwarg]
self.project = get_object_or_404(models.Project.objects.select_related('ensemble'), pk=project_id)
if super().is_authorized():
return True
# check if the current user is an admin on the ensemble
if self.project.ensemble.has_admin(self.request.user):
logger.debug("is_authorized: ensemble admin for project")
self.request.is_admin = True
return True
if self.is_authorized_key('ensemble', self.project.ensemble.pk, self.project.ensemble.nonce):
logger.debug('is_authorized: has ensemble link for project')
return True
if self.is_authorized_key('project', project_id, self.project.nonce):
logger.debug('is_authorized: has project link')
return True
return False
# filter any generated querysets
def get_queryset(self):
return super().get_queryset().filter(project=self.project)
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(AuthorizedResourceMixin, ListView):
def is_authorized(self):
return True
def get_queryset(self):
return models.Ensemble.objects.for_user(self.request.user,
self.get_authorized_keys('ensemble').keys(),
self.get_authorized_keys('project').keys())
#ensembles = models.Ensemble.objects.all()
#if self.request.is_admin:
# return ensembles
# limit to registered ensembles
#f = Q(slug__in=self.get_authorized_keys('ensemble').keys()) | Q(projects__in=self.get_authorized_keys('project').keys())
# or ensembles where the user is admin
#if self.request.user.is_authenticated:
# f |= Q(admins=self.request.user.pk)
#return ensembles.filter(f).distinct()
class EnsembleDetailView(EnsembleMixin, DetailView):
def request_denied(self):
if 'auth' in self.request.GET:
if self.request.GET['auth'] != self.ensemble.auth():
raise SuspiciousOperation("Bad ensemble link")
self.add_authorized_key('ensemble', self.ensemble.slug, self.ensemble.nonce)
return HttpResponseRedirect(resolve_url('ensemble_detail', self.ensemble.slug))
return super().request_denied()
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
inactive = 'inactive' in self.request.GET and self.request.is_admin
projects = self.ensemble.projects.all()
if self.limited_project_access:
projects = projects.filter(pk__in=self.get_authorized_keys('project').keys())
if inactive:
projects = projects.order_by('-pk')
else:
projects = projects.active().current()
data['inactive'] = inactive
data['object_list'] = projects
if self.request.is_admin:
data['ensemble_link'] = self.request.path + "?auth=" + self.ensemble.auth()
return data
class EnsembleRevokeView(SingleObjectMixin, RedirectView):
def get_redirect_url(self):
return
""" PROJECT VIEWS """
class ProjectListView(ProjectMixin, ListView):
def is_authorized(self):
return True
def get_project_queryset(self):
return models.Project.objects.for_user(self.request.user,
self.get_authorized_keys('project'),
self.get_authorized_keys('ensemble'))
def get_queryset(self):
return self.get_project_queryset().current().active()
class ProjectCreateView(EnsembleMixin, CreateView):
admin_required = True
model = models.Project
template_name = "interface/default_form.html"
title = "Add a new project"
form_class = forms.ProjectForm
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.ensemble = self.ensemble
self.object.owner = self.request.user
self.object.save()
for module in form.cleaned_data['modules']:
self.object.modules.create(name=module)
return redirect('project_detail', project=self.object.pk)
class ProjectDetailView(ProjectMixin, DetailView):
def request_denied(self):
if 'auth' in self.request.GET:
if self.request.GET['auth'] != self.project.auth():
raise SuspiciousOperation("Bad project link")
self.add_authorized_key('project', self.project.pk, self.project.nonce)
return HttpResponseRedirect(resolve_url('project_detail', self.project.pk))
return super().request_denied()
def get_object(self, queryset=None):
return self.project
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
if self.request.is_admin:
#data['project_link'] = signed_url(f'{self.request.path}?nonce={self.project.nonce}')
data['project_link'] = self.request.path + "?auth=" + self.project.auth()
return data
class ProjectUpdateView(ProjectMixin, UpdateView):
admin_required = True
template_name = "interface/default_form.html"
pk_url_kwarg = 'project'
form_class = forms.ProjectForm
def get_object(self):
return self.project
def get_initial(self):
data = super().get_initial()
data['modules'] = self.object.active_modules
print(data)
return data
def form_valid(self, form):
self.object = form.save()
current = set(self.object.active_modules)
desired = set(form.cleaned_data['modules'])
self.object.modules.exclude(name__in=desired).delete()
for module in desired-current:
self.object.modules.create(name=module)
return redirect('project_detail', self.kwargs['project'])
@property
def cancel_url(self):
return resolve_url('project_detail', self.kwargs['project'])
# Old Makefile from submission module
#class ProjectMakefileView(EnsembleMixin, DetailView):
# template_name = 'interface/project_submissions.mk'
# content_type = 'text/plain'
#
# def get_queryset(self):
# if self.request.is_admin:
# return models.Project.objects.all()
#
# return models.Project.objects.filter(ensemble=self.request.ensemble_id)
#
# def get_context_data(self, **kwargs):
# data = super().get_context_data(**kwargs)
#
# data['submissions'] = []
# data['targets'] = []
# for s in self.object.submissions:
# name = s.short_name
# data['targets'].append(name)
# data['submissions'].append({
# 'url': self.request.build_absolute_uri(signed_url('submission_download', project=self.kwargs['pk'], pk=s.pk)),
# 'name': name,
# })
#
# return data
""" WIKI VIEWS """
class WikiView(ProjectMixin, DetailView):
template_name = 'interface/wiki.html'
model = models.WikiPage
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
data['wiki_html'] = markdown(self.object.markdown)
return data
class WikiCreateView(ProjectMixin, CreateView):
admin_required = True
model = models.WikiPage
template_name = 'interface/default_form.html'
form_class = forms.WikiForm
def cancel_url(self):
return resolve_url('project_detail', self.kwargs['project'])
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.project = self.project
self.object.save()
return redirect('wiki', project=self.object.project_id, pk=self.object.pk)
class WikiEditView(ProjectMixin, UpdateView):
admin_required = True
model = models.WikiPage
form_class = forms.WikiForm
def cancel_url(self):
return resolve_url('wiki', self.kwargs['project'], self.kwargs['pk'])
""" RESOURCE VIEWS """
class ResourceCreateView(ProjectMixin, CreateView):
admin_required = True
model = models.Resource
form_class = forms.ResourceForm
template_name = 'interface/default_form.html'
title = "Add a new resource"
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.project = self.project
self.object.save()
return redirect('resource_upload', project=self.object.project_id, pk=self.object.pk)
class ResourceUploadView(ProjectMixin, UpdateView):
admin_required = True
model = models.Resource
fields = ['file']
template_name = 'interface/default_form.html'
def get_success_url(self):
return resolve_url('resource_list', project=self.kwargs['project'])
class ResourceListView(ProjectMixin, ListView):
model = models.Resource
def get_queryset(self):
qs = super().get_queryset()
if not self.request.is_admin:
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'])
+8 -2
View File
@@ -27,13 +27,14 @@ class MetaInline(admin.TabularInline):
class WorkAdmin(admin.ModelAdmin):
list_display = ['name', 'composer', 'edition', 'identifier', 'running_time']
list_filter = ['collection']
search_fields = ['name', 'composer']
inlines = [MetaInline, DocInline, ItemInline]
admin.site.register(models.Work, WorkAdmin)
class SectionInline(admin.TabularInline):
model = models.Section
fields = ['tag', 'start', 'end']
fields = ['tag', 'start', 'end', 'page']
class DocumentAdmin(admin.ModelAdmin):
list_display = ['work', '__str__']
@@ -52,4 +53,9 @@ class EnsembleAccessAdmin(admin.ModelAdmin):
list_display = ['ensemble', 'collection', 'access_type']
list_filter = ['ensemble']
admin.site.register(models.EnsembleAccess, EnsembleAccessAdmin)
admin.site.register(models.EnsembleAccess, EnsembleAccessAdmin)
class OrchestrationAdmin(admin.ModelAdmin):
list_display = ['name', 'instruments']
admin.site.register(models.Orchestration, OrchestrationAdmin)
@@ -0,0 +1,38 @@
[
{
"model": "library.orchestration",
"pk": 2,
"fields": {
"ensemble": null,
"name": "SATB",
"instruments": "score sop alt ten bass"
}
},
{
"model": "library.orchestration",
"pk": 3,
"fields": {
"ensemble": null,
"name": "String Quartet",
"instruments": "score vn-1 vn-2 va vc"
}
},
{
"model": "library.orchestration",
"pk": 4,
"fields": {
"ensemble": null,
"name": "Chamber Orchestra",
"instruments": "score fl cl ob bn vn-1 vn-2 va vc db"
}
},
{
"model": "library.orchestration",
"pk": 5,
"fields": {
"ensemble": null,
"name": "Full Orchestra",
"instruments": "score picc fl-1 fl-2 ob cl-1 cl-2 bcl bn tpt-1 tpt-2 tpt-3 hn-1 hn-2 hn-3 hn-4 tbn-1 tbn-2 tbn-3 tba timp perc-1 perc-2 mall pf vn-1 vn-2 va vc db"
}
}
]
+7 -6
View File
@@ -1,17 +1,14 @@
from django import forms
from .models import Work
from interface.models import Project
from interface.widgets import DatePickerInput
from django.db.models import Q
from interface.forms import BaseForm
class WorkCreateForm(forms.ModelForm, BaseForm):
#uploads = forms.FileField(label="PDFs to upload", widget=forms.ClearableFileInput(attrs={'multiple': True}), required=False)
class Meta:
model = Work
fields = ['name', 'code', 'running_time', 'notes']
fields = ['name', 'composer', 'edition', 'code', 'orchestration', 'licence', 'running_time', 'notes']
class PlaylistAddForm(forms.Form):
work = forms.ModelChoiceField(queryset=Work.objects.all())
@@ -28,5 +25,9 @@ class PlaylistAddForm(forms.Form):
def save(self):
self.instance.works.add(self.cleaned_data['work'])
class ProjectSelectForm(forms.Form):
project = forms.ModelChoiceField(queryset=Project.objects.all())
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,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'),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 3.2.7 on 2022-11-30 22:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='work',
name='slug',
),
migrations.AddField(
model_name='work',
name='path',
field=models.CharField(default='', editable=False, help_text='Used as folder name', max_length=255),
preserve_default=False,
),
migrations.AlterField(
model_name='work',
name='composer',
field=models.CharField(default='Anon', help_text='Surname, Initials', max_length=255),
),
]
@@ -0,0 +1,21 @@
# Generated by Django 3.2.7 on 2022-12-01 04:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('library', '0002_auto_20221201_0934'),
]
operations = [
migrations.AlterUniqueTogether(
name='work',
unique_together={('collection', 'composer', 'name', 'edition')},
),
migrations.RemoveField(
model_name='work',
name='path',
),
]
@@ -0,0 +1,40 @@
# Generated by Django 3.2.7 on 2023-01-01 04:35
import byostorage.cached
import byostorage.user
from django.db import migrations, models
import library.models
class Migration(migrations.Migration):
dependencies = [
('library', '0003_auto_20221201_1540'),
]
operations = [
migrations.AlterModelOptions(
name='section',
options={'ordering': ['doc', 'type', 'start', 'pk']},
),
migrations.AddField(
model_name='section',
name='type',
field=models.SmallIntegerField(choices=[(1, 'Instrument'), (2, 'Movement')], default=1),
preserve_default=False,
),
migrations.AlterField(
model_name='document',
name='upload',
field=models.FileField(storage=byostorage.cached.CachedStorage(byostorage.user.BYOStorage()), upload_to=library.models.doc_upload_filename),
),
migrations.AlterField(
model_name='work',
name='original_parts',
field=models.JSONField(default=dict, help_text='Original printed parts (IMSLP format)'),
),
migrations.AlterUniqueTogether(
name='work',
unique_together=set(),
),
]
@@ -0,0 +1,22 @@
# Generated by Django 3.2.7 on 2023-01-01 04:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0004_auto_20230101_1535'),
]
operations = [
migrations.AlterModelOptions(
name='section',
options={'ordering': ['type', 'ordinal', 'doc', 'start', 'pk']},
),
migrations.AddField(
model_name='section',
name='ordinal',
field=models.IntegerField(default=0),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 3.2.7 on 2023-02-01 21:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0005_auto_20230101_1547'),
]
operations = [
migrations.AlterField(
model_name='section',
name='tag',
field=models.CharField(blank=True, max_length=50),
),
migrations.AlterField(
model_name='section',
name='type',
field=models.SmallIntegerField(choices=[(1, 'Instrument'), (2, 'Movement'), (3, 'Excerpt')]),
),
migrations.AlterField(
model_name='work',
name='licence',
field=models.PositiveSmallIntegerField(choices=[(2, 'Public Domain'), (4, 'Copyright Expired'), (5, 'Recording Licence'), (6, 'Performance Licence'), (8, 'Perusal Licence'), (10, 'Internal use only')], default=6, help_text='Copyright status'),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 3.2.7 on 2023-02-14 04:54
from django.db import migrations, models
import django.db.models.deletion
def add_default_orchestration(apps, schema_editor):
m = apps.get_model('library', 'Orchestration').objects
m.create(pk=1, name="Custom", instruments="")
def remove_default_orchestration(apps, schema_editor):
m = apps.get_model('library', 'Orchestration').objects
m.filter(pk=1).delete()
class Migration(migrations.Migration):
dependencies = [
('interface', '0004_auto_20230210_0938'),
('library', '0006_auto_20230202_0804'),
]
operations = [
migrations.CreateModel(
name='Orchestration',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('instruments', models.TextField()),
('ensemble', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='orchestrations', to='interface.ensemble')),
],
),
migrations.RunPython(add_default_orchestration, remove_default_orchestration),
]
@@ -0,0 +1,19 @@
# Generated by Django 3.2.7 on 2023-02-14 05:05
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('library', '0007_orchestration'),
]
operations = [
migrations.AddField(
model_name='work',
name='orchestration',
field=models.ForeignKey(default=1, help_text='Orchestration for the work', on_delete=django.db.models.deletion.SET_DEFAULT, to='library.orchestration'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 3.2.7 on 2023-02-15 03:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0010_work_orchestration'),
]
operations = [
migrations.AddField(
model_name='section',
name='page',
field=models.SmallIntegerField(choices=[(0, 'auto'), (1, 'left'), (2, 'right')], default=0),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 3.2.7 on 2023-02-19 23:13
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('library', '0011_section_page'),
]
operations = [
migrations.RemoveField(
model_name='orchestration',
name='ensemble',
),
migrations.AddField(
model_name='orchestration',
name='collection',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='custom_orchestrations', to='library.collection'),
),
migrations.AlterField(
model_name='work',
name='original_parts',
field=models.JSONField(blank=True, default=dict, help_text='Original printed parts (IMSLP format)'),
),
migrations.AlterField(
model_name='work',
name='running_time',
field=models.DurationField(blank=True, help_text='Running time in mm:ss format', null=True),
),
]
@@ -0,0 +1,25 @@
# Generated by Django 3.2.7 on 2023-02-23 02:22
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('library', '0012_auto_20230220_1013'),
]
operations = [
migrations.AlterModelOptions(
name='section',
options={'ordering': ['doc', 'start', 'pk']},
),
migrations.RemoveField(
model_name='section',
name='ordinal',
),
migrations.RemoveField(
model_name='section',
name='type',
),
]
@@ -0,0 +1,28 @@
# Generated by Django 3.2.7 on 2023-02-23 03:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0013_auto_20230223_1322'),
]
operations = [
migrations.AddField(
model_name='collection',
name='nonce',
field=models.SmallIntegerField(default=1, help_text='Increment this to reset the authentication links'),
),
migrations.AlterField(
model_name='work',
name='code',
field=models.CharField(blank=True, help_text='Collection specific code or number. Will be auto-generated if not supplied', max_length=100),
),
migrations.AlterField(
model_name='work',
name='composer',
field=models.CharField(default='Anon', help_text='Composer or compilation editor. Use <b>Surname, Initial</b> for easy searching', max_length=255),
),
]
+187 -82
View File
@@ -1,5 +1,6 @@
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
@@ -7,8 +8,14 @@ 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 .imslp import Instrument
from byostorage.cached import CachedStorage
from library.music_tags import MusicTag
from interface.utils import sign_data
import logging
@@ -22,53 +29,35 @@ 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'),
]
'''
class Orchestration(models.Model):
"""
Stores a list of instrument codes as a single entry (space delimited).
Can be global or ensemble specific
"""
ensemble = models.ForeignKey('interface.Ensemble', on_delete=models.CASCADE, related_name="orchestrations", null=True, blank=True)
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, tag_to_instrument(t)) for t in tags if t ]
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()
@@ -76,7 +65,6 @@ class Orchestration(models.Model):
def __str__(self):
return self.name
'''
class ProjectItem(models.Model):
"""
@@ -91,13 +79,13 @@ class ProjectItem(models.Model):
approved_by = models.ForeignKey('auth.User', on_delete=models.CASCADE)
order = models.SmallIntegerField(default=0)
section = models.CharField(max_length=100, blank=True)
#version = models.CharField(max_length=30, blank=True, help_text="Limited to specific version tag")
class Meta:
ordering = ['order', 'work']
def __str__(self):
return f"<{self.project_id}:{self.work.slug}>"
return f"<{self.project_id}:{slugify(self.work.name)}>"
class Collection(models.Model):
"""
@@ -115,6 +103,33 @@ 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)")
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
@@ -123,6 +138,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,61 +154,82 @@ 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="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")
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 seconds")
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")
def extract(self, *tags):
@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 list(qs.values_list('upload', 'start', 'end'))
return qs
def list_sections(self, *tags):
return list(self.tagged_sections(*tags).values_list('upload', 'start', 'end'))
@property
def digital_parts(self):
return Section.objects.filter(doc__work=self.pk)
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 []
return [ (Instrument.from_tag(k), v) for (k, v) in self.original_parts.items() ]
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):
@@ -194,15 +239,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)
@property
def current_loans(self):
return self.project_items.filter(checkout__lte=now(), returned=None).select_related('project')
@@ -234,21 +270,43 @@ class Work(models.Model):
return self.code;
composer = self.composer or "Anon"
composer = re.sub('[^\w]', '', composer)
words = self.name.split()
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()
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_TAGS)
name = models.SlugField(max_length=20, choices=META_CHOICES)
value = models.CharField(max_length=255)
def doc_upload_filename(doc, filename):
@@ -256,18 +314,44 @@ 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_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=1)
upload = models.FileField(upload_to=doc_upload_filename, storage=BYOStorage())
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
@@ -275,21 +359,42 @@ 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)
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 instrument(self):
return Instrument.from_tag(self.tag)
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.instrument}') + '.pdf'
return slugify(f'{self.doc.work.name} - {self.name}').title() + '.pdf'
@property
def pagerange(self):
@@ -300,4 +405,4 @@ class Section(models.Model):
return "all"
def __str__(self):
return f'{self.doc.upload} [{self.pagerange}]'
return self.name
+265
View File
@@ -0,0 +1,265 @@
from collections import namedtuple
GENERAL = """
mvmt Movement
ex Excerpt
sec Section
pce Piece
no Number
"""
# taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_MusicTags
# Include any aliases at the top
INSTRUMENTS = """
score Score
cb Double Bass
mall Mallet Percussion
vln Violin
vla Viola
acc Accordion
afl Alto flute
alt Alto (voice) (contralto)
arp Arpeggione
bag Bagpipe
bar Baritone (voice)
bass Bass (voice)
bbar Bass baritone (voice)
bc Continuo (Basso continuo)
bcl Bass clarinet
bell Bell (Chimes)
bfl Bass flute
bgtr Bass guitar
bjo Banjo
bn Bassoon
bob Bass oboe (Baritone oboe)
br Brass MusicTags
bryt Baryton
bstcl Basset clarinet
bsthn Basset horn
bug Bugle
cbcl Contrabass clarinet
cbn Contrabassoon
cch Children's chorus
cel Celesta
ch Mixed chorus
cimb Cimbalom
cit Cittern
cl Clarinet
clvd Clavichord
cm Chalumeau
conc Concertina
crh Crumhorn
crt Cornet
crtt Cornett (Zink)
cv Child's voice
db Double Bass
drum Drumset
dlcn Dulcian
dom Domra
dulc Dulcimer
egtr Electric guitar
eh English horn (Cor anglais)
elec Electronic MusicTags
epf Electric piano
eq Equal voices
erhu Erhu
euph Euphonium
fch Female chorus
fda Flute d'amore (Tenor flute)
fgh Flugelhorn
fife Fife
fl Flute
flag Flageolet
ghca Glass harmonica (Bowl organ)
gl Glockenspiel
gtr Guitar
harm Harmonium
hca Harmonica (Mouth Organ)
heck Heckelphone
hn Horn
hp Harp
hpd Harpsichord
kbd Keyboard MusicTag
lute Lute
lyre Lyre
mand Mandolin
mar Marimba
mch Male chorus
mez Mezzo-soprano
mus Musette
nar Narrator (Reciter)
ob Oboe
oca Ocarina
oda Oboe d'amore
om Ondes Martenot
oph Ophicleide
orch Orchestra
org Organ
oud Oud
pan Pan flute (Pan-pipes)
perc Percussion
pf Piano
pf3h Piano 3 hands
pf4h Piano 4 hands
pf5h Piano 5 hands
pf6h Piano 6 hands
pflh Piano left hand
pfped Pedal piano
pfrh Piano right hand
picc Piccolo
pipa Pipa
pk Timpani
ptpt Piccolo trumpet
reb Rebec
rec Recorder
sar Sarrusophone
sax Saxophone
sheng Sheng
shw Shawm
sit Sitar
skbt Sackbut
sop Soprano (voice)
srp Serpent
stpt Slide trumpet
str String MusicTags
sxh Saxhorn
syn Synthesizer
tba Tuba
tbn Trombone
ten Tenor
thrm Theremin
timp Timpani
tpt Trumpet
uch Unison chorus
uke Ukelele (Ukulele)
v Voice (solo)
va Viola
vap Viola pomposa
vc Cello
vda Viola d'amore
vib Vibraphone
vie Vielle (Hurdy-Gurdy)
viol Viol (Viola da gamba)
vlne Violone
vn Violin
vuv Vuvuzela
vv Voices (multiple soloists)
wag Wagner tuba
ww Woodwind MusicTags
xiao Xiao
xyl Xylophone
zith Zither
"""
MUSIC_TAGS = []
GENERAL_TAGS = set()
for i, abbreviations in enumerate((GENERAL, INSTRUMENTS)):
for line in abbreviations.split('\n'):
parts = line.strip().split(maxsplit=1)
if len(parts) < 2: continue
name, _, _ = parts[1].partition('(')
MUSIC_TAGS.append((parts[0], name))
if i == 0:
GENERAL_TAGS.add(parts[0])
MUSIC_NAME_BY_TAG = dict(MUSIC_TAGS)
MUSIC_TAG_BY_NAME = dict( ( (x[1].lower(), x[0]) for x in MUSIC_TAGS ) )
class MusicTag(namedtuple('MusicTag', ('name', 'variant'), defaults=[None])):
@classmethod
def from_tag(cls, tag):
"""
>>> MusicTag.from_tag('vn-1')
MusicTag(name='Violin', variant='1')
>>> MusicTag.from_tag('db')
MusicTag(name='Double Bass', variant=None)
>>> MusicTag.from_tag('Jaws Harp')
MusicTag(name='Jaws Harp', variant=None)
>>> MusicTag.from_tag('mvmt-2')
MusicTag(name='Movement', variant='2')
>>> MusicTag.from_tag('pce-A2')
MusicTag(name='Piece', variant='A2')
"""
abbr, _, variant = tag.partition('-')
name = MUSIC_NAME_BY_TAG.get(abbr.lower(), abbr)
if variant:
return cls(name, variant)
return cls(name, None)
@property
def tag(self):
l = self.name.lower()
return MUSIC_TAG_BY_NAME.get(l, l)
@property
def is_general(self):
"""
>>> MusicTag('Piece', 'A3').is_general
True
>>> MusicTag('Violin', 2).is_general
False
"""
return self.tag in GENERAL_TAGS
def abbreviate(self):
"""
>>> MusicTag('Violin', 1).abbreviate()
'vn-1'
>>> MusicTag('Double Bass').abbreviate()
'db'
"""
tag = MUSIC_TAG_BY_NAME.get(self.name.lower())
if self.variant:
tag = f"{tag}-{self.variant}"
return tag
def __str__(self):
"""
>>> str(MusicTag('Violin', 1))
'Violin 1'
>>> str(MusicTag('Double Bass'))
'Double Bass'
"""
if self.variant:
return f"{self.name} {self.variant}"
return self.name
import re
PATTERNS = [re.compile('([A-Za-z]+)[_\- ]*(\d+)'), re.compile('([A-Za-z]+)()')]
def auto_tag(filename):
'''
>>> auto_tag('Ode to Joy - Violin 1.pdf')
MusicTag(name='Violin', variant=1)
>>> auto_tag('Ode to Joy_Cello.pdf')
MusicTag(name='Cello', variant=None)
>>> auto_tag('Ode to Joy violin - 1.pdf')
MusicTag(name='Violin', variant=1)
>>> auto_tag('Ode to Joy - vla.pdf')
MusicTag(name='Viola', variant=None)
>>> auto_tag('Ode to Joy - fl-2 (piccolo).pdf')
MusicTag(name='Flute', variant=2)
'''
for pattern in PATTERNS:
for inst, ordinal in pattern.findall(filename):
inst = inst.lower()
ordinal = int(ordinal) if ordinal else None
if inst in MUSIC_TAG_BY_NAME:
return MusicTag(inst.title(), ordinal)
if inst in MUSIC_NAME_BY_TAG:
return MusicTag(MUSIC_NAME_BY_TAG[inst], ordinal)
if __name__ == "__main__":
import doctest
print(doctest.testmod())
@@ -24,12 +24,12 @@ def extract_and_concat(items):
if count == 0:
continue
if start is None:
if not start:
sections.append(source)
else:
if end is None:
if not end:
end = start
dest = os.path.join(d.name, f'section_{i}.pdf')
@@ -0,0 +1,50 @@
{% extends "interface/project_base.html" %}
{% block page %}
<h3 class="title">Library collections for {% firstof request.user.first_name request.user.username %}</h3>
<div class="block">
<form method="GET" action="{% url 'work_list' %}">
<div class="field has-addons">
<div class="control is-expanded">
<input class="input" name="filter" type="text" placeholder="Find a work" value="{{ request.GET.filter }}"/>
</div>
<div class="control">
<a class="button" href="?"><i class="fas fa-times"></i></a>
</div>
</div>
</form>
</div>
<div class="columns is-multiline">
{% for collection in object_list %}
<div class="column is-half">
<div class="card">
<header class="card-header">
<a class="" href="{% url 'collection_work_list' collection=collection.id %}">
<p class="card-header-title">{{ collection.name }}</p>
</a>
</header>
<div class="card-content">
<p>
{% if collection.location %}{{ collection.location }},{% endif %}
{{ collection.works.count }} item{{ collection.works.count|pluralize }}.
</p>
<p>
{% for tag in collection.tags %}
<a href="{% url 'collection_work_list' collection.pk %}?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 %}
@@ -5,7 +5,7 @@
<span class="icon"><i class="fas fa-save"></i></span>
<span>Save</span>
</a>
<a href="{% url 'work_detail' pk=object.work.pk %}" class="button is-link is-light">
<a href="{% url 'work_detail' collection=collection.pk pk=object.work_id %}" class="button is-link is-light">
<span>Cancel</span>
</a>
{% endblock %}
@@ -21,7 +21,7 @@
}
.grid-page {
height: 25px;
margin-bottom: 10px;
margin-bottom: 5px;
border: 1px solid #999;
border-radius: 5px;
text-align: center;
@@ -44,46 +44,45 @@
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
align-items: start;
}
#tag-area {
min-width: 220px;
}
.instrument {
display: list-item !important;
}
.instrument:hover {
background-color: var(--primary);
color: white;
}
</style>
{% endblock %}
{% block page %}
<h3 class="subtitle"><a href="{% url 'work_detail' document.work.pk %}">{{ document.work.name }}</a></h3>
<h3 class="subtitle"><a href="{% url 'work_detail' collection.pk document.work_id %}">{{ document.work.name }}</a></h3>
<div id="annotation-area" class="columns is-centered">
<div class="column is-narrow">
<div class="columns">
<div class="column is-narrow">
<button class="button is-small is-primary" id="prev">&lt;</button>
</div>
<div class="column">
<span id="page-num">-</span> / <span id="page-count">-</span>
</div>
<div class="column is-narrow">
<button class="button is-small is-primary" id="next">&gt;</button>
</div>
</div>
<ul id="unassigned-area">
{% for tag, inst in document.work.music_tags %}
<li class="is-clickable" onclick="assignInstrument('{{tag}}', this)")>{{ inst }}</li>
{% endfor %}
</ul>
<a onclick="document.getElementById('add-modal').classList.add('is-active')">Add Tag</a>
</div>
<div class="column is-narrow">
<div class="has-text-centered">
<div class="level">
<div class="level-left">
<div class="level-item">
<p>Page <span id="page-num">-</span> / <span id="page-count">-</span></p>
</div>
</div>
<div class="level-right">
<div class="level-item">
<div class="field has-addons">
<span class="control">
<input type="text" class="input" list="instrument-list" id="add-instrument-name"/>
<datalist id="instrument-list">
{% for inst in json_data.instruments.values %}
<option value="{{inst}}"/>
{% endfor %}
</datalist>
</span>
<span class="control">
<input type="number" class="input" max="4" min="1" size="3" id="add-instrument-variant"/>
</span>
<span class="control">
<button class="button is-primary" onclick="addInstrument()">Add</button>
</span>
</div>
</div>
</div>
</div>
<div class="box" style="display: inline-block;">
<canvas id="inline-viewer" style="width: 500px;"></canvas>
</div>
@@ -97,9 +96,39 @@
<div class="grid-column" id="tag-area">
</div>
</div>
<button class="button is-primary" style="width: 100%" onclick="expandEntries()">Expand</button>
</div>
</div>
<div class="modal" id="add-modal">
<div class="modal-background" onclick="closeAddModal()"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Add Instrument</p>
<button class="delete" aria-label="close" onclick="closeAddModal()"></button>
</header>
<section class="modal-card-body">
<div class="field has-addons">
<span class="control">
<input type="text" class="input" list="instrument-list" id="add-instrument-name"/>
<datalist id="instrument-list">
{% for inst in json_data.instruments.values %}
<option value="{{inst}}"/>
{% endfor %}
</datalist>
</span>
<span class="control">
<input type="number" class="input" max="4" min="1" size="3" id="add-instrument-variant"/>
</span>
<span class="control">
<button class="button is-primary" onclick="addInstrument();">Add</button>
</span>
</div>
</section>
</div>
</div>
<p>{{ document.upload.name }}</p>
{% endblock %}
@@ -194,10 +223,9 @@
if (pageNum <= 1) {
return;
}
pageNum--;
queueRenderPage(pageNum);
queueRenderPage(pageNum-1);
}
//document.getElementById('prev').addEventListener('click', onPrevPage);
document.getElementById('prev').addEventListener('click', onPrevPage);
/**
* Displays next page.
@@ -206,10 +234,9 @@
if (pageNum >= pdfDoc.numPages) {
return;
}
pageNum++;
queueRenderPage(pageNum);
queueRenderPage(pageNum+1);
}
//document.getElementById('next').addEventListener('click', onNextPage);
document.getElementById('next').addEventListener('click', onNextPage);
/**
* Asynchronously downloads PDF.
@@ -243,6 +270,12 @@
dirty = false;
});
function closeAddModal() {
document.getElementById('add-modal').classList.remove('is-active');
document.getElementById('add-instrument-name').value = "";
document.getElementById('add-instrument-variant').value = "";
}
function addInstrument() {
let name = document.getElementById('add-instrument-name');
let variant = document.getElementById('add-instrument-variant');
@@ -268,7 +301,19 @@
variant.value = '';
name.value = '';
let li = document.createElement('li');
li.classList.add("is-clickable");
li.addEventListener('click', () => assignInstrument(tag, li));
li.innerHTML = get_instrument(tag);
document.getElementById('unassigned-area').appendChild(li);
addTag(tag, pageNum, pageNum);
closeAddModal();
}
function assignInstrument(tag, el) {
addTag(tag, pageNum, pageNum);
//el.remove();
}
function addTag(tag, start, end) {
@@ -295,7 +340,10 @@
let del = document.createElement('span');
del.className = "icon is-action";
del.innerHTML = '<i class="fas fa-trash-alt" title="Remove this tag"></i>';
del.addEventListener('click', () => {el.remove(), dirty=true});
del.addEventListener('click', () => {
el.remove();
dirty=true;
});
label.appendChild(del)
el.appendChild(label);
@@ -315,8 +363,8 @@
let start = tag.dataset.start;
let end = tag.dataset.end;
let span = end-start+1;
let height = span * 25 + (span-1) * 10;
let top = (start-1) * 35;
let height = span * 25 + (span-1) * 5;
let top = (start-1) * 30;
tag.style.height = height + 'px';
tag.style.marginTop = top + 'px';
@@ -341,6 +389,19 @@
updateTag(el);
}
function expandEntries() {
const entries = Array.from(tagArea.children);
entries.sort((a, b) => a.dataset.start-b.dataset.start);
const c = entries.length;
for (let i=0; i<c-1; i++) {
entries[i].dataset.end = entries[i+1].dataset.start - 1;
updateTag(entries[i]);
}
entries[c-1].dataset.end = pdfDoc.numPages;
updateTag(entries[c-1]);
}
function saveTags() {
const pageTags = [];
for (let pageTag of tagArea.children ) {
@@ -361,7 +422,7 @@
}
).then((response) => {
if (response.ok) {
window.location = "{% url 'work_detail' document.work.pk %}"
window.location = "{% url 'work_detail' collection.pk document.work_id %}"
} else {
alert("Failed: " + response.statusText)
}
@@ -0,0 +1,22 @@
{% extends "interface/project_base.html" %}
{% block page %}
<div class="columns">
<div class="column is-half is-centered">
<div class="block">
<p>Are you sure you want to delete<br>
<b>"{{ object.upload.name }}"</b>?</p>
</div>
<form method="post">{% csrf_token %}
<div class="field is-grouped">
<div class="control">
<button class="button is-link">Yes</button>
</div>
<div class="control">
<a class="button is-link is-light" href="{% url 'work_detail' collection.pk object.work.pk %}">No</a>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
@@ -0,0 +1,19 @@
{% load path_filters %}
<tr>
<td><a href="{{ doc.upload.url }}" target="_blank">
{{ doc.upload.name|basename }}</a></td>
<td>
{% for section in doc.sections.all %}
<a class="tag is-{{ section.bulma_class }}" target="_blank" href="{% url 'part_download' collection.pk section.pk section.filename %}">{{ section.name }}</a>
{% endfor %}
</td>
<td class="has-text-right" style="white-space: nowrap;">
{% if request.is_admin %}
{% if doc.doctype == 1 %}
<a href="{% url 'document_annotate' collection.pk doc.pk %}"><i class="fas fa-tags"
title="Manage Tags"></i></a>
{% endif %}
<a href="{% url 'document_delete' collection.pk doc.pk %}"><i class="fas fa-trash-alt" title="Delete Document"></i></a>
{% endif %}
</td>
</tr>
@@ -1,21 +1,14 @@
{% extends "interface/project_base.html" %}
{% block admin %}
<a class="button is-link" href="{% url 'item_list_manage' project.pk %}">
<span class="icon"><i class="fas fa-list"></i></span>
<span>Change items</span>
</a>
{% endblock %}
{% block page %}
<form action="" method="post" target="_blank">
{% csrf_token %}
<div>
<div class="field is-grouped is-grouped-centered">
<div class="field has-addons control">
<div class="field is-grouped is-grouped-centered is-grouped-multiline">
<div class="field has-addons control has-addons-centered is-expanded">
<span class="control">
<a class="button is-static">Your Instrument</a>
<span class="control is-hidden-mobile">
<a class="button is-static">Instrument</a>
</span>
<span class="control">
@@ -47,12 +40,12 @@
</div>
</div>
<table class="table is-striped is-fullwidth">
<table class="table is-striped mx-auto">
<thead>
<tr>
<th/>
<th>Piece</th>
<th>Running time</th>
<th class="is-hidden-mobile">Running time</th>
<th>Part</th>
<th/>
</tr>
@@ -63,25 +56,27 @@
<td>{{ forloop.counter }}.</td>
<td>
{% if request.is_admin %}
<a href="{% url 'work_detail' item.work.pk %}">{{ item.work.name }}</a>
<a href="{% url 'work_detail' item.work.collection.pk item.work.pk %}">{{ item.work.name }}</a>
{% else %}
{{ item.work.name }}
{% endif %}
</td>
<td>{% firstof item.work.running_time "------" %}</td>
<td class="is-hidden-mobile">{% firstof item.work.running_time "------" %}</td>
<td class="select-cell">
<input type="hidden" name="works" value="{{ item.work.pk }}"/>
<span class="select is-small">
<select name="instruments">
<span class="select is-small" style="width: 100%">
<select name="instruments" id="part-{{ item.work.pk }}" style="width: 100%">
<option value='-'>None</option>
{% for part in item.work.digital_parts %}
<option value='{{ part.tag }}'>{{ part.instrument }}</option>
{% for tag, name in item.work.digital_parts %}
<option value='{{ tag }}'>{{ name }}</option>
{% endfor %}
</select>
</span>
</td>
<td>
<a href=""><i class="fas fa-download"></i></a>
<span class="is-action" onclick="downloadPart({{ item.work.collection_id }}, {{ item.work.pk }})">
<i class="fas fa-download" title="Download Part"></i>
</span>
</td>
</tr>
{% endfor %}
@@ -90,11 +85,13 @@
<tr>
<td/>
<td/>
<td>{{ running_time }}</td>
<td>{% firstof running_time "------" %}</td>
<td/>
<td>
<!--
<button class="button is-link is-small" type="submit"><span class="icon"><i class="fas fa-copy"></i></span><span>Single combined PDF</span></button>
<a class="button is-link is-small"><span class="icon"><i class="fas fa-archive"></i></span><span>Individual files (zipped)</span></a>
-->
</td>
</tr>
</tfoot>
@@ -154,6 +151,17 @@ function selectParts(inst, part) {
}
function downloadPart(collection, work) {
let part = document.getElementById("part-" + work).value;
if (part == '-') {
alert("No part selected!");
return;
}
console.log(part);
let url = "/collections/" + collection + "/works/" + work + "/download?tag=" + part;
window.location = url;
}
document.getElementById("instrument-name").value = localStorage.getItem('instrument-name', 'All');
document.getElementById("part-preference").value = localStorage.getItem('part-preference', 'Any');
updateParts();
@@ -0,0 +1,50 @@
<div class="panel">
<p class="panel-heading">
Items
</p>
{% for item in project.items.all %}
{% with work=item.work %}
<a class="panel-block" onclick="showDownloadOptions('{% url 'work_parts' work.collection_id work.pk %}')">
{{ item.work.name }}
</a>
{% endwith %}
{% empty %}
<span class="panel-block">
There are no items listed yet for this project - please check back later...
</span>
{% endfor %}
</div>
<div class="modal" id="download-modal">
<div class="modal-background" onclick="closeDownloadModal()"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Parts</p>
<button class="delete" aria-label="close" onclick="closeDownloadModal()"></button>
</header>
<section class="modal-card-body" id="download-target">
</section>
</div>
</div>
<script>
function showDownloadOptions(url) {
let target = document.getElementById("download-target");
target.innerHTML = "Loading available parts...";
document.getElementById("download-modal").classList.add('is-active');
fetch(url).then((data) => {
console.log(data);
data.text().then((text) => {
target.innerHTML = text;
});
});
}
function closeDownloadModal() {
document.getElementById("download-modal").classList.remove('is-active');
}
</script>
@@ -3,7 +3,11 @@
{% if project %}
<li><a href="{% url 'item_list' project=project.pk %}">My Music</a></li>
{% endif %}
{% if request.is_admin %}
{% if collection %}
{% endif %}
{% if request.user.is_authenticated %}
<li><a href="{% url 'work_list' %}">Library</a></li>
<li><a href="{% url 'collection_list' %}">Collections</a></li>
{% endif %}
</ul>
@@ -7,11 +7,11 @@
{% endblock %}
{% block admin %}
<a href="{% url 'work_edit' work.pk %}" class="button is-link">
<a href="{% url 'work_edit' collection.pk work.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-edit"></i></span>
<span>Edit</span>
</a>
<a href="{% url 'work_add_to_project' work.pk %}" class="button is-link">
<a href="{% url 'work_add_to_project' collection.pk work.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add to project</span>
</a>
@@ -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>
@@ -29,16 +29,27 @@
<p class="block">{{ work.notes }}</p>
<p class="block">
Location: <a href="{% url 'collection_work_list' work.collection.pk %}">{{ work.collection }}</a> [{{ work.identifier }}]<br/>
Running time: {{ work.duration }}<br/>
Licence: {{ work.get_licence_display }}<br/>
<table class="table">
<tr>
<th>Location:</th><td><a href="{% url 'collection_work_list' work.collection.pk %}">{{ work.collection }}</a> [{{ work.identifier }}]</td>
<th>Orchestration:</th><td>{{ work.orchestration }}</td>
</tr><tr>
<th>Running time:</th><td>{% firstof work.duration 'Unknown' %}</td>
<th>Licence:</th><td>{{ work.get_licence_display }}</td>
</tr><tr>
<td colspan="4">
{% for meta in work.meta %}
{{ meta.get_name_display }}: {{ meta.value }}<br/>
<a href="{% url 'collection_work_list' work.collection.pk %}?filter={{ meta.name}}:{{ meta.value }}" class="tag" >
{{ meta.get_name_display }}:
{{ meta.value }}
</a>
{% endfor %}
</p>
</td>
</tr>
</table>
{% if work.parent %}
<p>From <a href="{% url 'work_detail' work.parent.pk %}">{{ work.parent.name }} - {{ work.parent.composer }}</a>
<p>From <a href="{% url 'work_detail' work.parent.collection.pk work.parent.pk %}">{{ work.parent.name }} - {{ work.parent.composer }}</a>
</p>
{% endif %}
@@ -46,7 +57,7 @@
<h3>Related</h3>
<ul>
{% for related in work.related_works.all %}
<li><a href="{% url 'work_detail' related.pk %}">{{ related.name }} - {{ related.composer }}</a></li>
<li><a href="{% url 'work_detail' related.collection.pk related.pk %}">{{ related.name }} - {{ related.composer }}</a></li>
{% endfor %}
</ul>
{% endif %}
@@ -62,7 +73,7 @@
</h4>
<div class="tags">
{% for inst, c in work.physical_parts %}
<span class="tag is-warning">{{ inst }} ({{ c }})</span>
<span class="tag is-warning">{{ inst }}{% if c > 1 %} [<strong>{{ c }}</strong>]{% endif %}</span>
{% empty %}
<p class="is-italic">No printed parts listed</p>
{% endfor %}
@@ -76,15 +87,17 @@
Digital Parts
</h4>
<div class="tags">
{% 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>
{% empty %}
<p class="is-italic">No digital parts available</p>
{% endfor %}
{% with sections=work.digital_parts %}
{% if sections %}
<a class="tag is-danger" href="{% url 'work_partset' collection.pk work.pk %}">Full Set</a>
{% endif %}
{% for tag, name in sections %}
<a class="tag is-info" href="{% url 'work_download' collection.pk work.pk %}?tag={{ tag }}"
target="section_{{ tag }}" rel="">{{ name }}</a>
{% empty %}
<p class="is-italic">No digital parts available</p>
{% endfor %}
{% endwith %}
</div>
</div>
</div>
@@ -110,22 +123,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>
@@ -133,7 +131,7 @@
{% if request.is_admin %}
<div class="column is-one-quarter">
<h4 class="is-size-5">Upload files</h4>
<form action="{% url 'document_add' object.pk %}" class="dropzone" id="doc-upload">
<form action="{% url 'document_add' collection.pk object.pk %}" class="dropzone" id="doc-upload" style="-moz-user-select: none">
{% csrf_token %}
</form>
</div>
@@ -150,7 +148,7 @@
Loans
</h4>
<span class="level-right">
<a class="icon-text" href="{% url 'work_add_to_project' work.pk %}"><span class="icon"><i
<a class="icon-text" href="{% url 'work_add_to_project' collection.pk work.pk %}"><span class="icon"><i
class="fas fa-plus-circle"></i></span> Checkout</a>
</span>
</div>
@@ -188,7 +186,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,
@@ -2,8 +2,8 @@
{% load url_tools %}
{% block admin %}
{% if collection_id %}
<a href="{% url 'work_add' pk=collection_id %}" class="button is-link">
{% 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>
@@ -29,22 +29,16 @@
<th>Work</th>
<th>Composer</th>
<th class="is-hidden-mobile">Edition</th>
{% if request.is_admin %}
<th class="is-hidden-touch">Collection</th>
<th class="is-hidden-mobile">Copies</th>
{% endif %}
{% 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' pk=work.pk %}">{{ work.name }}</a></td>
<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 request.is_admin %}
<td class="is-hidden-touch">{{ work.collection.name }}</td>
<td class="is-hidden-mobile {{ work.is_available|yesno:'has-text-success,has-text-danger' }}">{{ work.available }}</td>
{% endif %}
{% if not collection %}<td class="is-hidden-touch">{{ work.collection.name }}</td>{% endif %}
</tr>
{% empty %}
<tr><td colspan="4">No works found</td></tr>
@@ -0,0 +1,25 @@
<h3 class="subtitle">{{ work.name }}</h3>
<div class="block">
{% for tag, name in work.digital_parts %}
<a class="tag is-info" href="{% url 'work_download' work.collection_id work.pk %}?tag={{ tag }}">{{ name }}</a>
{% endfor %}
</div>
<h3 class="subtitle">Files</h3>
<table class="table is-narrow is-fullwidth">
<tbody>
{% for doc in work.pdfs %}
<tr>
<td><a href="{{ doc.upload.url }}">{{ doc.filename }}</a></td>
</tr>
{% empty %}
<tr>
<td>There are no files available for this work</td>
</tr>
{% endfor %}
</tbody>
</table>
<p>
<a href="{% url 'work_detail' work.collection_id work.pk %}">More details...</a>
</p>
@@ -0,0 +1,53 @@
{% extends "interface/project_base.html" %}
{% block page %}
<h3 class="subtitle"><a href="{% url 'work_detail' collection.pk work.pk %}">{{ work.name }}</a></h3>
<p class="block">
You can generate a custom partset for printing - select the number of copies of each you want...
</p>
<div class="columns is-centered">
<div class="column is-narrow">
<form action="" method="post" target="_blank">
{% csrf_token %}
<table class="table">
<thead>
</thead>
<tbody>
{% for tag, name in work.digital_parts %}
<tr>
<td>{{ name }}</td>
<td>
<input name="parts" type="hidden" value="{{ tag }}">
<input class="input is-small" name="copies" type="number" value="{% if tag == 'score' %}0{% else %}1{% endif %}">
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="field is-grouped">
<div class="control">
<button class="button is-link">
<span class="icon"><i class="fas fa-print"></i></span>
<span>Print Set</span>
</button>
</div>
<div class="control">
<a class="button is-link is-light" href="{% url 'work_detail' collection.pk object.pk %}">
<span>Cancel</span>
</a>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
+176
View File
@@ -0,0 +1,176 @@
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'), [])
+44
View File
@@ -0,0 +1,44 @@
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"),
]
+482
View File
@@ -0,0 +1,482 @@
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'])
+200
View File
@@ -0,0 +1,200 @@
"""
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 interface.views import AuthorizedResourceMixin
from rest_framework import routers, serializers, viewsets
from rest_framework.exceptions import APIException
from library.models import Collection, Work, Document, Section, WorkMeta
import requests
from io import BytesIO
import urllib
import shutil
import os.path
from django.db import transaction
from django.core.files.uploadedfile import TemporaryUploadedFile
class WorkMetaSerializer(serializers.ModelSerializer):
class Meta:
model = WorkMeta
exclude = ['id', 'work']
def to_representation(self, instance):
return f"{instance.name}:{instance.value}"
def to_internal_value(self, data):
name, _, value = data.partition(':')
return super().to_internal_value({'name': name, 'value': value})
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}:{start}:{end}"
def to_internal_value(self, data):
tag, start, end = data.split(":")
start = int(start)
end = int(end)
if start < 1:
start = None
if end < 1:
end = None
return super().to_internal_value({'tag': tag, 'start': start, 'end': end})
class DocumentSerializer(serializers.ModelSerializer):
upload = serializers.URLField()
sections = SectionSerializer(many=True)
#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)
data['upload'] = instance.upload.url
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)
meta_info = WorkMetaSerializer(many=True)
class Meta:
model = Work
exclude = ['id', 'collection', 'projects', 'parent']
def create(self, validated):
with transaction.atomic():
docs = validated.pop('docs', [])
meta = validated.pop('meta_info', [])
work = Work.objects.create(**validated)
for d in docs:
sections = d.pop('sections', [])
url = urllib.parse.urlparse(d['upload'])
filename = os.path.basename(url.path)
r = requests.get(d['upload'], stream=True)
if r.status_code != 200:
raise APIException("Failed to download file")
f = TemporaryUploadedFile(filename, r.headers['content-type'], r.headers.get('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)
for m in meta:
WorkMeta.objects.create(work_id=work.pk, **m)
return work
class CollectionSerializer(serializers.Serializer):
works = WorkSerializer(many=True)
def create(self, validated):
s = WorkSerializer()
print(validated)
collection = validated['collection_id']
with transaction.atomic():
for work in validated['works']:
work['collection_id'] = collection
s.create(work)
return Collection.objects.get(pk=collection)
from rest_framework import generics
class CollectionExportView(AuthorizedResourceMixin, generics.RetrieveAPIView):
serializer_class = CollectionSerializer
def get_queryset(self):
if self.request.user.is_superuser:
return Collection.objects.all()
return Collection.objects.filter(administrators=self.request.user)
class WorkExportView(AuthorizedResourceMixin, generics.RetrieveAPIView):
serializer_class = WorkSerializer
def get_queryset(self):
works = Work.objects.filter(collection=self.kwargs['collection'])
if self.request.user.is_superuser:
return works
return works.filter(collection__administrators=self.request.user)
class WorkImportView(AuthorizedResourceMixin, generics.CreateAPIView):
serializer_class = WorkSerializer
def perform_create(self, serializer):
serializer.save(collection_id=self.kwargs['collection'])
class CollectionImportView(AuthorizedResourceMixin, generics.CreateAPIView):
serializer_class = CollectionSerializer
def perform_create(self, serializer):
serializer.save(collection_id=self.kwargs['pk'])
+1 -1
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
@@ -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'
+7
View File
@@ -0,0 +1,7 @@
try:
from .local_settings import *
except ImportError:
from .default_settings import *
INSTALLED_APPS += POLYPHONIC_MODULES
+29
View File
@@ -0,0 +1,29 @@
asgiref==3.4.1
boto3==1.18.34
botocore==1.21.34
certifi==2022.12.7
charset-normalizer==2.1.1
crispy-bulma==0.8.0
Django==3.2.7
#-e git+ssh://git@gitea.tfconsulting.com.au/tris/django-byostorage.git@227ce5fccb5864657f080c25528263b655c2b6b7#egg=django_byostorage
git+https://gitea.tfconsulting.com.au/tris/django-byostorage.git
django-crispy-forms==1.14.0
django-markdown2==0.3.1
django-rest-framework==0.1.0
django-storages==1.13.1
django_debug_toolbar==3.8.1
djangorestframework==3.14.0
djantic==0.7.0
idna==3.4
jmespath==0.10.0
markdown2==2.4.1
pydantic==1.10.2
pydantic-django==0.1.1
python-dateutil==2.8.2
pytz==2021.1
requests==2.28.1
s3transfer==0.5.2
six==1.16.0
sqlparse==0.4.1
typing_extensions==4.4.0
urllib3==1.26.6
+8
View File
@@ -0,0 +1,8 @@
from .default_settings import *
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': '/var/polyphonic/db.sqlite3',
}
}
-46
View File
@@ -1,46 +0,0 @@
from django import forms
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit
from . import models, fields
class BaseForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = self.get_form_helper()
def get_form_helper(self):
helper = FormHelper(self)
helper.add_input(Submit('submit', 'Submit', css_class='button is-link'))
return helper
class ProjectForm(forms.ModelForm, BaseForm):
class Meta:
model = models.Project
fields = ['name', 'description', 'event_date']
widgets = {
'event_date': forms.DateTimeInput(attrs={'type': 'date'})
}
class ResourceForm(forms.ModelForm, BaseForm):
class Meta:
model = models.Resource
fields = ['name', 'media_type', 'description', 'file']
def get_form_helper(self):
helper = super().get_form_helper()
helper[3].wrap(fields.BulmaFileUpload)
return helper
class CodeForm(BaseForm):
code = forms.CharField(max_length=14,
widget=forms.TextInput(attrs={'placeholder': 'xxx-xxx-xxx', 'inputmode': 'numeric'}))
passphrase = forms.CharField(max_length=32)
class ResourceUploadForm(forms.Form):
pass
# file = S3UploadField()

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