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
52 changed files with 1847 additions and 742 deletions
+11 -9
View File
@@ -1,13 +1,15 @@
__pycache__ __pycache__
*.pyc *.pyc
db.sqlite3 *.sqlite3
credentials.json
credentials credentials
local_settings.py local_settings.py
env .coverage
old /env
test.* /data
static /old
teststore /static
cache /teststore
local_storage /cache
media /local_storage
/media
+2 -2
View File
@@ -13,9 +13,9 @@ COPY app /opt/polyphonic
WORKDIR /opt/polyphonic WORKDIR /opt/polyphonic
COPY docker_settings.py polyphonic/local_settings.py COPY docker_settings.py polyphonic/local_settings.py
RUN mkdir /var/polyphonic
RUN SECRET_KEY=_ python3 manage.py collectstatic --noinput RUN SECRET_KEY=_ python3 manage.py collectstatic --noinput
VOLUME ["/var/polyphonic"]
ENTRYPOINT ["python3", "manage.py"] ENTRYPOINT ["python3", "manage.py"]
CMD ["runserver", "0.0.0.0:8000", "--insecure"] CMD ["runserver", "0.0.0.0:8000", "--insecure"]
+5 -1
View File
@@ -1,6 +1,11 @@
PYTHON=env/bin/python PYTHON=env/bin/python
DROPZONE=5.7.0 DROPZONE=5.7.0
test:
coverage run --include "app/*" --omit "*/migrations/*" app/manage.py test app
coverage html
coverage report
dev-setup: dev-setup:
env/bin/pip install -r requirements.txt env/bin/pip install -r requirements.txt
env/bin/pip install -r dev-requirements.txt env/bin/pip install -r dev-requirements.txt
@@ -35,4 +40,3 @@ start_s3_storage:
stop_s3_storage: stop_s3_storage:
kill `cat teststore/pid` | true kill `cat teststore/pid` | true
rm teststore/pid rm teststore/pid
+1 -1
View File
@@ -3,7 +3,7 @@ from django.contrib import admin
from . import models from . import models
class EnsembleAdmin(admin.ModelAdmin): class EnsembleAdmin(admin.ModelAdmin):
list_display = ['name', 'ensemble_code', 'slug'] list_display = ['name', 'slug']
class ModuleInline(admin.StackedInline): class ModuleInline(admin.StackedInline):
model = models.Module model = models.Module
+4 -2
View File
@@ -25,14 +25,16 @@ class BaseForm(forms.Form):
class ProjectForm(forms.ModelForm, BaseForm): class ProjectForm(forms.ModelForm, BaseForm):
class Meta: class Meta:
model = models.Project model = models.Project
fields = ['name', 'description', 'event_date'] fields = ['name', 'description', 'modules', 'event_date']
#widgets = { #widgets = {
# 'event_date': forms.DateTimeInput(attrs={'type': 'date'}) # '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 ResourceForm(forms.ModelForm, BaseForm):
class Meta: class Meta:
@@ -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',
),
]
+57 -9
View File
@@ -11,6 +11,7 @@ import random
from urllib.parse import urlparse from urllib.parse import urlparse
import os.path import os.path
from .utils import sign_data
MEDIA_TYPES = [ MEDIA_TYPES = [
('audio', "Audio"), ('audio', "Audio"),
@@ -40,6 +41,19 @@ def rough_date(d):
def generate_code(length=9): def generate_code(length=9):
return "".join([ random.choice('0123456789') for _ in range(length) ]) return "".join([ random.choice('0123456789') for _ in range(length) ])
class EnsembleQuerySet(models.QuerySet):
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): class Ensemble(models.Model):
''' A group that plays together ''' A group that plays together
@@ -48,25 +62,21 @@ class Ensemble(models.Model):
help_text="Display name") help_text="Display name")
slug = models.SlugField(max_length=100, editable=False, unique=True, slug = models.SlugField(max_length=100, editable=False, unique=True,
help_text="Short name for the ensemble - used for folders") 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') admins = models.ManyToManyField('auth.User', related_name='ensembles')
details = models.TextField(blank=True, details = models.TextField(blank=True,
help_text="Description of the ensemble (markdown)") help_text="Description of the ensemble (markdown)")
storage = models.ForeignKey('byostorage.UserStorage', null=True, on_delete=models.SET_NULL, storage = models.ForeignKey('byostorage.UserStorage', null=True, on_delete=models.SET_NULL,
help_text="Default storage for this ensemble") 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: class Meta:
ordering = ('slug', ) ordering = ('slug', )
def active_projects(self): def active_projects(self):
return self.projects.filter(active=True, event_date__gte=timezone.now()) 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): def has_admin(self, user):
if not user.is_authenticated: if not user.is_authenticated:
@@ -80,9 +90,32 @@ class Ensemble(models.Model):
self.slug = slugify(self.name) self.slug = slugify(self.name)
super(Ensemble, self).save(**kwargs) 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): def __str__(self):
return self.name 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): class Project(models.Model):
''' A Project linked to an ensemble ''' A Project linked to an ensemble
@@ -94,6 +127,10 @@ class Project(models.Model):
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
event_date =models.DateTimeField(null=True, blank=True) event_date =models.DateTimeField(null=True, blank=True)
owner = models.CharField(max_length=255, blank=True) owner = models.CharField(max_length=255, blank=True)
nonce = models.SmallIntegerField(default=1,
help_text="Increment this to reset the authentication links")
objects = ProjectQuerySet.as_manager()
class Meta: class Meta:
ordering = ['active', 'event_date'] ordering = ['active', 'event_date']
@@ -110,6 +147,8 @@ class Project(models.Model):
@property @property
def rough_date(self): def rough_date(self):
if not self.event_date:
return "No timescale"
in_past, s = rough_date(self.event_date) in_past, s = rough_date(self.event_date)
if in_past: if in_past:
return f"{s} ago" return f"{s} ago"
@@ -125,6 +164,12 @@ class Project(models.Model):
def active_modules(self): def active_modules(self):
return self.modules.values_list('name', flat=True) return self.modules.values_list('name', flat=True)
def get_absolute_url(self):
return resolve_url('project_detail', project=self.pk)
def auth(self):
return sign_data(f'{self.pk}-{self.nonce}', 12)
def __str__(self): def __str__(self):
return self.name return self.name
@@ -134,6 +179,9 @@ class Module(models.Model):
name = models.SlugField(max_length=20, choices=[ (x, x.title()) for x in settings.POLYPHONIC_MODULES ]) 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) project = models.ForeignKey(Project, related_name="modules", on_delete=models.CASCADE)
def __str__(self):
return self.name
def resource_key(resource, filename): def resource_key(resource, filename):
return f'{resource.project.folder}/resources/{filename}' return f'{resource.project.folder}/resources/{filename}'
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');
}
}
});
+9 -6
View File
@@ -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="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css">
<link rel="stylesheet" href="{% static 'interface/css/polyphonic.css' %}"></link> <link rel="stylesheet" href="{% static 'interface/css/polyphonic.css' %}"></link>
<script src="{% static 'interface/js/interface.js' %}"></script> <script src="{% static 'interface/js/interface.js' %}"></script>
<script src="//unpkg.com/alpinejs" defer></script>
<script src="//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> <title>{% block title %}Polyphonic{% endblock %}</title>
{% block media %}{% endblock %} {% block media %}{% endblock %}
<style>{% block style %}{% endblock %}</style>
</head> </head>
<body> <body>
{% block navigation %} {% block navigation %}
<nav class="navbar" role="navigation"> <nav class="navbar" role="navigation">
<div class="navbar-brand has-text-primary"> <div class="navbar-brand has-text-primary">
<a class="navbar-item" href="/"> <a class="navbar-item" href="/">
<span class="icon fancy is-size-2 mx-4"><i class="fas fa-random"></i></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">Polyphonic</span> <span class="fancy is-size-2 is-size-4-mobile">Polyphonic</span>
</a> </a>
<span class="navbar-item is-hidden-mobile fancy is-size-5">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"> <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 id="navbarMain" class="navbar-menu">
<div class="navbar-end"> <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>
</div> </div>
@@ -47,8 +52,6 @@
{% endblock %} {% endblock %}
<!-- late load scripts --> <!-- 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 %} {% block scripts %}
{% endblock %} {% endblock %}
</body> </body>
@@ -21,29 +21,37 @@
{% block page %} {% block page %}
<h3 class="title">Projects for {{ensemble.name }}</h3> <h3 class="title">Projects for {{ensemble.name }}</h3>
<p>{{ ensemble.details|markdown }}</p> <div class="content">
{{ ensemble.details|markdown }}
</div>
<h4>Administrators</h4> <div class="block">
<ul> Contacts:
{% for admin in ensemble.admins.all %} {% for admin in ensemble.admins.all %}
<li><a href="mailto:{{ admin.email }}">{% firstof admin.get_full_name admin.get_username %}</a></li> <a href="mailto:{{ admin.email }}" class="tag">{% firstof admin.get_full_name admin.get_username %}</a>
{% endfor %} {% endfor %}
</ul> </div>
{% include 'interface/project_items.html' %} {% include 'interface/project_items.html' %}
{% if request.is_admin %}
<div class=""> <div class="">
<div class="card"> <div class="card">
<header class="card header"> <header class="card header">
<p class="card-header-title">Admin Details</p> <p class="card-header-title">Admin Details</p>
</header> </header>
<div class="card-content"> <div class="card-content">
<ul>
<p> <li><a href="{{ ensemble_link }}">Ensemble Sharing Link</a></li>
Joining instructions for participants<br/><br/> <li><a href="{% url 'project_create' ensemble.pk %}">Add a new project</a></li>
URL: <a href="{{ ensemble_link }}">{{ ensemble_link }}</a><br/> </ul>
</p>
</div> </div>
</div> </div>
</div> </div>
{% endif %}
<div>
<a href="{% url 'forget_resource' 'ensemble' ensemble.slug %}">Forget this ensemble</a>
</div>
{% endblock %} {% endblock %}
@@ -19,22 +19,18 @@
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<div class="media"> <div class="media">
<div class="media-left"> <div class="media-left">
<figure class="image is-48x48"> <figure class="image is-48x48">
<img src="https://bulma.io/images/placeholders/96x96.png" alt="Placeholder image"> <img src="https://www.gravatar.com/avatar/{{ ensemble.email }}?d=mp" alt="Placeholder image">
</figure> </figure>
</div> </div>
<div class="media-content"> <div class="media-content" style="min-height: 60px">
<a href="{% url 'ensemble_detail' ensemble.slug %}"> <a href="{% url 'ensemble_detail' ensemble.slug %}">
<p class="title is-4">{{ ensemble.name }}</p> <p class="title is-4">{{ ensemble.name }}</p>
</a> </a>
</div> </div>
</div> </div>
{% if ensemble.details %}
<div class="content">
{{ ensemble.details | markdown }}
</div>
{% endif %}
</div> </div>
<div class="card-footer"> <div class="card-footer">
{% with projects=ensemble.active_projects.count %} {% with projects=ensemble.active_projects.count %}
@@ -2,9 +2,7 @@
{% block content %} {% block content %}
<div class="columns mx-2">
<div class="columns">
<div class="column is-narrow is-hidden-touch" id="projectMenu"> <div class="column is-narrow is-hidden-touch" id="projectMenu">
<div style="margin: auto 2em;"> <div style="margin: auto 2em;">
<aside class="menu"> <aside class="menu">
@@ -13,41 +11,38 @@
<ul class="menu-list"> <ul class="menu-list">
<li><a href="{% url 'ensemble_list' %}">Ensembles</a></li> <li><a href="{% url 'ensemble_list' %}">Ensembles</a></li>
<li><a href="{% url 'project_list' %}">Projects</a></li> <li><a href="{% url 'project_list' %}">Projects</a></li>
<li><a href="{% url 'work_list' %}">Library</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> </ul>
{% if project %} {% if project %}
<p class="menu-label">This Project</p> <p class="menu-label">This Project</p>
<ul class="menu-list"> <ul class="menu-list">
<li><a role="tab" href="{% url 'project_detail' project=project.id %}">Project Info</a></li> <li><a role="tab" href="{% url 'project_detail' project=project.id %}">Project Info</a></li>
{% for page in project.wiki_pages.all %} {% if 'library' in modules %}
<li><a class="nav-link {% if page.id == wiki_id %}active{% endif %}" <li><a class="nav-link" href="{% url 'item_list' project=project.pk %}">My Music</a></li>
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>
{% endif %} {% 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> {% if collection %}
{% endif %} <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> </ul>
{% endif %} {% endif %}
<p class="menu-label">Admin</p> <p class="menu-label">Admin</p>
<ul class="menu-list"> <ul class="menu-list">
{% if request.is_admin or request.user.is_superuser %} {% if request.user.is_staff %}
<li><a class="admin-link" href="{% url 'collection_list' %}">Collections</a></li> <li><a href="/admin" target="polyphonic_admin" rel="noopener noreferrer">Django Admin</a></li>
{% endif %}
{% if request.user.is_superuser %}
<li><a class="admin-link" href="/admin" target="polyphonic_admin" rel="noopener noreferrer">Django Admin</a></li>
{% endif %} {% endif %}
</ul> </ul>
@@ -86,9 +81,9 @@
</div> </div>
{% endif %} {% endif %}
<section class="section"> <div class="block">
{% if request.is_admin %} {% if request.is_admin %}
<div class="admin-tools is-pulled-right"> <div class="admin-tools is-pulled-right is-hidden-mobile">
{% block admin %} {% block admin %}
{% endblock %} {% endblock %}
</div> </div>
@@ -97,7 +92,7 @@
{% block page %} {% block page %}
No content No content
{% endblock %} {% endblock %}
</section> </div>
</div> </div>
</div> </div>
@@ -14,55 +14,71 @@
{% endblock %} {% endblock %}
{% block page %} {% block page %}
<div class="box"> <div class="columns is-multiline">
{% if project.event_date %} <div class="column ">
<h3 class="subtitle is-centered"> <div class="box">
<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.event_date %}
{% if project.description %} <h3 class="subtitle is-centered">
<p class="content">{{ project.description|markdown }}</p> <b>{{ project.event_date|date:"l jS F Y, g:i A" }}</b>
{% else %} {% if project.has_happened %}
<p>No description</p> ({{ project.event_date|roughtimesince }} ago)
{% else %}
(in {{ project.event_date|roughtimeuntil }})
{% endif %}
</h3>
{% endif %} {% 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> </div>
{% if 'library' in modules %} {% if 'library' in modules %}
<div class="block"> <div class="column is-one-third">
{% include 'library/project_detail.html' %} {% include 'library/project_detail.html' %}
</div> </div>
{% endif %} {% endif %}
{% if 'submission' in modules %} {% if 'submission' in modules %}
<div class="block"> <div class="column">
{% include 'submissions/project_detail.html' %} {% include 'submissions/project_detail.html' %}
</div> </div>
{% endif %}
{% if project.owner %}
<div class="block">
{% if project.owner.email %}
The project owner is <a href="mailto:{{ project.owner.email }}">{{ project.owner }}</a>
{% else %}
The project owner is {{ project.owner }}.
{% endif %}
</div>
{% endif %}
{% if request.is_admin %}
<div class="block">
<a href="{{ project_link }}">Project Link</a>
</div>
{% endif %} {% endif %}
</div> </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 %} {% 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 %} {% endblock %}
{% block page %} {% block page %}
<h3 class="subtitle">Resources</h3>
<div class="columns is-multiline"> <div class="columns is-multiline">
{% for resource in object_list %} {% for resource in object_list %}
{% with download=resource.file.url %} {% with download=resource.file.url %}
@@ -51,7 +52,7 @@
{% endwith %} {% endwith %}
{% empty %} {% empty %}
<div class="column"> <div class="column">
<p>There are resources for this project</p> <p>There are no resources for this project</p>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
+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)
+71 -125
View File
@@ -2,85 +2,103 @@ from django.test import TestCase, Client
from interface import models, utils from interface import models, utils
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils import timezone
from datetime import timedelta
class AccessTestCase(TestCase): from . import AccessTestCase
@classmethod class InterfaceAccessTestCase(AccessTestCase):
def setUpTestData(cls):
admin = User.objects.create_user(username='admin', password='foobar', is_superuser=True) USERS = (
homer = User.objects.create_user(username='homer', password='maggie') {'username': 'admin', 'password': 'secret', 'is_superuser': True, 'is_staff': True},
{'username': 'homer', 'password': 'maggie'},
)
now = timezone.now() ENSEMBLES = (
{'name': 'The Be Sharps', 'slug': 'be-sharps', 'admins': ['homer']},
{'name': 'Lisa & the Bleeding Gums', 'slug': 'bleeding-gums'},
{'name': 'Party Posse'},
)
b_sharps = models.Ensemble.objects.create(name='The Be Sharps') PROJECTS = (
b_sharps.admins.add(homer) {'name': 'Baker St', 'ensemble': 'bleeding-gums', 'when': -12},
bleeding_gums = models.Ensemble.objects.create(name='Lisa and the Bleeding Gums', slug='bleeding-gums') {'name': 'Navy Recruitment Day', 'ensemble': 'party-posse', 'when': 6},
party_posse = models.Ensemble.objects.create(name="Party Posse", slug='party-posse') {'name': 'Barbershop Contest', 'ensemble': 'be-sharps', 'when': 28},
{'name': 'Open Mic Night', 'ensemble': 'bleeding-gums', 'when': 1 },
{'name': 'Current Repertoire', 'ensemble': 'be-sharps'},
)
bleeding_gums.projects.create(name='Baker St', event_date=now-timedelta(days=12)) PROTECTED_URLS = (
party_posse.projects.create(name='Navy Recruitment Day', event_date=now+timedelta(days=6)) '/ensembles/be-sharps',
b_sharps.projects.create(name='Baby on Board', event_date=now+timedelta(days=28)) '/ensembles/be-sharps/new-project',
bleeding_gums.projects.create(name='Open Mic Night', event_date=now+timedelta(hours=1))
'/projects/3',
'/projects/3/resources',
'/projects/3/resources/add',
def test_admin_ensembles(self): '/admin/interface/ensemble/',
self.client.post('/login', {'username': 'admin', 'password': 'foobar'}) '/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') response = self.client.get('/ensembles')
self.assertObjectList(response, ['Lisa and the Bleeding Gums', 'Party Posse', 'The Be Sharps']) self.assertObjectList(response, ['The Be Sharps', 'Lisa & the Bleeding Gums', 'Party Posse'])
self.assertContains(response, 'Django Admin') self.assertContains(response, 'Django Admin')
def test_admin_ensemble_permissions(self): def test_superuser_ensemble_permissions(self):
self.client.post('/login', {'username': 'admin', 'password': 'foobar'}) self.login('admin', 'secret')
response = self.client.get('/ensembles/party-posse') response = self.client.get('/ensembles/party-posse')
self.assertTrue(response.context['request'].is_admin) self.assertTrue(response.context['request'].is_admin)
self.assertContains(response, "Add project") self.assertContains(response, "Add project")
self.assertAccess({ self.assertAccess({
'/ensembles/the-be-sharps': True, '/ensembles/be-sharps': True,
'/ensembles/bleeding-gums': True, '/ensembles/bleeding-gums': True,
'/ensembles/party-posse': True, '/ensembles/party-posse': True,
'/ensembles/unknown': False, '/ensembles/unknown': False,
'/ensembles/the-be-sharps/new-project': True, '/ensembles/be-sharps/new-project': True,
}) })
def test_admin_projects(self): def test_superuser_projects(self):
self.client.post('/login', {'username': 'admin', 'password': 'foobar'}) self.login('admin', 'secret')
response = self.client.get('/projects') response = self.client.get('/projects')
self.assertObjectList(response, ['Open Mic Night', 'Navy Recruitment Day', 'Baby on Board']) 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'), ['Open Mic Night'])
self.assertObjectList(self.client.get('/ensembles/bleeding-gums?inactive'), ['Open Mic Night', 'Baker St']) self.assertObjectList(self.client.get('/ensembles/bleeding-gums?inactive'), ['Open Mic Night', 'Baker St'])
def test_user_ensembles(self): def test_user_ensembles(self):
self.client.post('/login', {'username': 'homer', 'password': 'maggie'}) self.login('homer', 'maggie')
response = self.client.get('/ensembles') response = self.client.get('/ensembles')
self.assertObjectList(response, ['The Be Sharps']) self.assertObjectList(response, ['The Be Sharps'])
self.assertNotContains(response, 'Django Admin') self.assertNotContains(response, 'Django Admin')
def test_user_ensemble_permissions(self): def test_user_ensemble_permissions(self):
self.client.post('/login', {'username': 'homer', 'password': 'maggie'}) self.login('homer', 'maggie')
response = self.client.get('/ensembles/the-be-sharps') response = self.client.get('/ensembles/be-sharps')
self.assertTrue(response.context['request'].is_admin) self.assertTrue(response.context['request'].is_admin)
self.assertContains(response, "Add project") self.assertContains(response, "Add project")
self.assertContains(response, 'Show all') self.assertContains(response, 'Show all')
self.assertAccess({ self.assertAccess({
'/ensembles/the-be-sharps': True, '/ensembles/be-sharps': True,
'/ensembles/bleeding-gums': False, '/ensembles/bleeding-gums': False,
'/ensembles/party-posse': False, '/ensembles/party-posse': False,
'/ensembles/the-be-sharps/new-project': True, '/ensembles/be-sharps/new-project': True,
'/ensembles/party-posse/new-project': False, '/ensembles/party-posse/new-project': False,
}) })
self.authorize('ensemble_detail', ensemble='bleeding-gums') self.authorize(models.Ensemble, slug='bleeding-gums')
self.assertAccess({ self.assertAccess({
'/ensembles/the-be-sharps': True, '/ensembles/be-sharps': True,
'/ensembles/bleeding-gums': True, '/ensembles/bleeding-gums': True,
'/ensembles/party-posse': False, '/ensembles/party-posse': False,
'/ensembles/the-be-sharps/new-project': True, '/ensembles/be-sharps/new-project': True,
'/ensembles/party-posse/new-project': False, '/ensembles/party-posse/new-project': False,
}) })
response = self.client.get('/ensembles/bleeding-gums') response = self.client.get('/ensembles/bleeding-gums')
@@ -89,9 +107,9 @@ class AccessTestCase(TestCase):
self.assertNotContains(response, 'Show all') self.assertNotContains(response, 'Show all')
def test_user_projects(self): def test_user_projects(self):
self.client.post('/login', {'username': 'homer', 'password': 'maggie'}) self.login('homer', 'maggie')
response = self.client.get('/projects') response = self.client.get('/projects')
self.assertObjectList(response, ['Baby on Board']) self.assertObjectList(response, ['Current Repertoire', 'Barbershop Contest'])
response = self.client.get('/projects/3') response = self.client.get('/projects/3')
self.assertTrue(response.context['request'].is_admin) self.assertTrue(response.context['request'].is_admin)
@@ -105,9 +123,9 @@ class AccessTestCase(TestCase):
'/projects/4/resources/add': False, '/projects/4/resources/add': False,
}) })
self.client.get(utils.signed_url('project_detail', project=4)) self.authorize(models.Project, pk=4)
response = self.client.get('/projects') response = self.client.get('/projects')
self.assertObjectList(response, ['Open Mic Night', 'Baby on Board']) self.assertObjectList(response, ['Current Repertoire', 'Open Mic Night', 'Barbershop Contest'])
response = self.client.get('/projects/4') response = self.client.get('/projects/4')
self.assertFalse(response.context['request'].is_admin) self.assertFalse(response.context['request'].is_admin)
@@ -117,14 +135,15 @@ class AccessTestCase(TestCase):
self.assertContains(response, 'You don\'t currently have access to any ensembles') self.assertContains(response, 'You don\'t currently have access to any ensembles')
def test_anon_authorized_ensemble(self): def test_anon_authorized_ensemble(self):
response = self.client.get(utils.signed_url('ensemble_detail', ensemble='party-posse')) self.authorize(models.Ensemble, slug='party-posse')
response = self.client.get('/ensembles/party-posse')
self.assertContains(response, 'Party Posse') self.assertContains(response, 'Party Posse')
response = self.client.get('/ensembles') response = self.client.get('/ensembles')
self.assertObjectList(response, ['Party Posse']) self.assertObjectList(response, ['Party Posse'])
self.assertAccess({ self.assertAccess({
'/ensembles/the-be-sharps': False, '/ensembles/be-sharps': False,
'/ensembles/party-posse': True, '/ensembles/party-posse': True,
'/ensembles/bleeding-gums': False, '/ensembles/bleeding-gums': False,
'/ensembles/unknown': False, '/ensembles/unknown': False,
@@ -133,9 +152,9 @@ class AccessTestCase(TestCase):
self.assertObjectList(response, ['Navy Recruitment Day']) self.assertObjectList(response, ['Navy Recruitment Day'])
def test_anon_authorized_project(self): def test_anon_authorized_project(self):
self.authorize('project_detail', project=4) self.authorize(models.Project, pk=4)
self.assertObjectList(self.client.get('/projects'), ['Open Mic Night']) self.assertObjectList(self.client.get('/projects'), ['Open Mic Night'])
self.assertObjectList(self.client.get('/ensembles'), []) self.assertObjectList(self.client.get('/ensembles'), ['Lisa & the Bleeding Gums'])
self.assertAccess({ self.assertAccess({
'/projects/4': True, '/projects/4': True,
@@ -147,91 +166,18 @@ class AccessTestCase(TestCase):
def test_anon_permission_denied(self): def test_anon_permission_denied(self):
self.assertAccess({ self.assertAccess({
'/ensembles': True, '/ensembles': True,
'/ensembles/the-be-sharps': False, '/ensembles/be-sharps': False,
'/ensembles/party-posse': False, '/ensembles/party-posse': False,
'/ensembles/bleeding-gums': False, '/ensembles/bleeding-gums': False,
'/ensembles/unknown': False, '/ensembles/unknown': False,
}) })
def authorize(self, url, **kwargs): def test_anon_deauthorize_project(self):
response = self.client.get(utils.signed_url(url, **kwargs)) self.authorize(models.Project, pk=4)
self.assertEqual(response.status_code, 200) self.assertAccess({
'/projects/4': True
def assertAccess(self, urls): })
for url, expected in urls.items(): models.Project.objects.filter(pk=4).update(nonce=2)
response = self.client.get(url) self.assertAccess({
self.assertEqual(response.status_code == 200, expected, f"Expected {expected} for {url} (status: {response.status_code})") '/projects/4': False
})
def assertObjectList(self, response, expected, element='name'):
self.assertEqual(response.status_code, 200, "No result returned")
objects = response.context['object_list'].values_list(element, flat=True)
self.assertEqual(list(objects), expected)
"""
def test_redirect(self):
self.skipTest("No redirect")
response = self.client.get('/')
self.assertRedirects(response, '/register?')
def test_redirect_project(self):
response = self.client.get('/projects/1')
self.assertEqual(response.status_code, 404)
#def test_redirect_with_code(self):
# response = self.client.get('/?code=123-456-789')
# self.assertRedirects(response, '/register?code=123-456-789')
def test_register(self):
response = self.client.get('/ensembles/1')
self.assertEqual(response.status_code, 404)
url = utils.signed_url('register', group='ensemble', pk=1)
response = self.client.get(url + "i")
self.assertEqual(response.status_code, 400)
response = self.client.get(url)
self.assertRedirects(response, '/ensembles/1')
response = self.client.get('/ensembles/1')
self.assertEqual(response.context['object'].pk, 1)
response = self.client.get('/projects/1', )
def old_test_register(self):
response = self.client.post('/register', {'code': '123-456-789', })
self.assertFormError(response, 'form', 'passphrase', 'This field is required.')
response = self.client.post('/register', {'code': '123-456-789', 'passphrase': 'Foo'})
self.assertFormError(response, 'form', None, 'Incorrect code or passphrase')
response = self.client.post('/register', {'code': '12-34', 'passphrase': 'Homer'})
self.assertRedirects(response, '/')
response = self.client.get(response.url)
self.assertEqual(response.context['object'].pk, 1)
# revisting original url get redirected back to homepage
response = self.client.get('/?code=12-34')
response = self.client.get(response.url)
response = self.client.get(response.url)
self.assertEqual(response.context['object'].pk, 1)
# providing a new code
response = self.client.get('/?code=23-45')
self.assertRedirects(response, '/register?code=23-45')
response = self.client.get(response.url)
#self.assertQuerysetEqual(response.context['current'], ['<Ensemble: The Be Sharps>'])
#self.assertEqual(response.context['form'].code.initial, 'foo')
response = self.client.post('/register', {'code': '23-45', 'passphrase': 'maggie'})
self.assertRedirects(response, '/')
response = self.client.get('/')
self.assertEqual(response.context['object'].pk, 2)
# can use previous link to switch back without passphrase
response = self.client.get('/?code=12-34')
response = self.client.get(response.url)
response = self.client.get(response.url)
self.assertEqual(response.context['object'].pk, 1)
"""
+1 -3
View File
@@ -10,13 +10,11 @@ urlpatterns = [
path('login', auth_views.LoginView.as_view(), name='login'), path('login', auth_views.LoginView.as_view(), name='login'),
path('logout', auth_views.LogoutView.as_view(), name='logout'), path('logout', auth_views.LogoutView.as_view(), name='logout'),
#path('register/<group>/<int:pk>', views.register, name='register'), path('forget/<resource>/<key>', views.ForgetResourceView.as_view(), name="forget_resource"),
#path('deregister/<group>/<int:pk>', views.deregister, name='deregister'),
path('ensembles', views.EnsembleListView.as_view(), name="ensemble_list"), path('ensembles', views.EnsembleListView.as_view(), name="ensemble_list"),
path('ensembles/<slug:ensemble>', views.EnsembleDetailView.as_view(), name='ensemble_detail'), path('ensembles/<slug:ensemble>', views.EnsembleDetailView.as_view(), name='ensemble_detail'),
path('ensembles/<slug:ensemble>/new-project', views.ProjectCreateView.as_view(), name="project_create"), path('ensembles/<slug:ensemble>/new-project', views.ProjectCreateView.as_view(), name="project_create"),
#path('ensembles/<int:pk>/forget', views.EnsembleForgetView.as_view(), name='ensemble_forget'),
path('projects', views.ProjectListView.as_view(), name="project_list"), path('projects', views.ProjectListView.as_view(), name="project_list"),
path('projects/<int:project>', views.ProjectDetailView.as_view(), name="project_detail"), path('projects/<int:project>', views.ProjectDetailView.as_view(), name="project_detail"),
+20 -5
View File
@@ -4,18 +4,33 @@ from django.core.exceptions import SuspiciousOperation
signer = Signer() 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): def signed_url(name, **kwargs):
""" """
>>> signed_url('foo/bar') >>> signed_url('foo/bar')
""" """
url = resolve_url(name, **kwargs) url = resolve_url(name, **kwargs)
sig = signer.sign(url) sig = signer.sign(url)
return sig.replace(":", "?auth=") sep = "&" if "?" in url else "?"
return sig.replace(":", f"{sep}auth=")
def check_signed_url(url, auth): def check_signed_url(full_path):
sig = signer.sign(url) p = full_path.rfind('auth')
if sig[len(url)+1:] != auth: url = full_path[:p-1]
sig = "_HIDDEN_" 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") raise SuspiciousOperation("Bad auth code")
if __name__ == '__main__': if __name__ == '__main__':
+173 -83
View File
@@ -3,29 +3,30 @@
""" """
from django.shortcuts import render, get_object_or_404, redirect, resolve_url from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.views.generic import TemplateView, RedirectView from django.views.generic import RedirectView
from django.views.generic.detail import DetailView from django.views.generic.detail import DetailView, SingleObjectMixin
from django.views.generic.list import ListView from django.views.generic.list import ListView
from django.views.generic.edit import CreateView, UpdateView from django.views.generic.edit import CreateView, UpdateView, FormMixin
from django.core.exceptions import SuspiciousOperation, PermissionDenied from django.core.exceptions import SuspiciousOperation
from django.http import Http404, HttpResponseForbidden from django.http import Http404, HttpResponseRedirect
from django.contrib import auth
from django.db.models import Q from django.db.models import Q
from django.utils import timezone from django.utils import timezone
from markdown2 import markdown from markdown2 import markdown
from functools import cached_property
from . import models, forms from . import models, forms
from interface.utils import signed_url, check_signed_url from interface.utils import check_signed_url
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class AuthorizedResourceMixin(object): class AuthorizedResourceMixin(object):
""" """
Handles two parts of the permission system, signed urls and persistent authenticated resources Handles these parts of the permission system:
* signed urls
* persistent authenticated resources
* Admin enforcing
""" """
SESSION_KEY = 'authorized' SESSION_KEY = 'authorized'
@@ -34,10 +35,11 @@ class AuthorizedResourceMixin(object):
def is_authorized(self): def is_authorized(self):
"By default check if superuser or a signed request" "By default check if superuser or a signed request"
if self.request.is_admin: if self.request.is_admin:
#logger.debug("is_authorized: superuser")
return True return True
if 'auth' in self.request.GET: if 'sig' in self.request.GET:
check_signed_url(self.request.path, self.request.GET['auth']) check_signed_url(self.request.get_full_path())
self.on_signed_request() self.on_signed_request()
return True return True
@@ -46,25 +48,43 @@ class AuthorizedResourceMixin(object):
def on_signed_request(self): def on_signed_request(self):
pass 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): def get_authorized_keys(self, resource):
'Returns a set of authorized keys for this resource' 'Returns a set of authorized keys for this resource'
return set(self._authorized.get(resource, [])) return self._authorized.get(resource, {})
def add_authorized_key(self, resource, key): def add_authorized_key(self, resource, key, auth):
'Adds a key to the authorized list for this resource' 'Adds a key to the authorized list for this resource'
current = self.get_authorized_keys(resource) current = self.get_authorized_keys(resource)
current.add(key) current[str(key)] = auth
self._authorized[resource] = list(current) self._authorized[resource] = current
self.request.session[self.SESSION_KEY] = self._authorized self.request.session[self.SESSION_KEY] = self._authorized
def del_authorized_key(self, resource, key): def del_authorized_key(self, resource, key):
logger.info("Revoking authorization for %s %s", resource, key)
current = self.get_authorized_keys(resource) current = self.get_authorized_keys(resource)
current.discard(key) if current.pop(key, None) is None:
return False
if current: if current:
self._authorized[resource] = list(current) self._authorized[resource] = current
else: else:
self._authorized.pop(current) self._authorized.pop(resource)
self.request.session[self.SESSION_KEY] = self._authorized 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): def dispatch(self, request, *args, **kwargs):
@@ -72,81 +92,85 @@ class AuthorizedResourceMixin(object):
request.is_admin = request.user.is_superuser request.is_admin = request.user.is_superuser
if not self.is_authorized(): if not self.is_authorized():
raise Http404("Either the given resource doesn't exist or you dont have access to it.") return self.request_denied()
if self.admin_required and not request.is_admin: if self.admin_required and not request.is_admin:
raise PermissionDenied("You must be an ensemble admin.") return self.request_denied()
return super().dispatch(request, *args, **kwargs) 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): class EnsembleMixin(AuthorizedResourceMixin):
ensemble_slug_kwarg = 'ensemble'
limited_project_access = False
def is_authorized(self): def is_authorized(self):
if 'forget' in self.request.GET: ensemble_slug = self.kwargs[self.ensemble_slug_kwarg]
self.del_authorized_key('ensemble', self.kwargs['ensemble']) self.ensemble = get_object_or_404(models.Ensemble, slug=ensemble_slug)
raise Http404("Access removed")
super().is_authorized() if super().is_authorized():
try:
self.ensemble = self.get_ensemble()
return True return True
except models.Ensemble.DoesNotExist:
return False
def get_ensemble(self): if self.ensemble.has_admin(self.request.user):
ensemble = self.get_queryset().get(slug=self.kwargs['ensemble']) self.request.is_admin = True
return True
self.request.is_admin = ensemble.has_admin(self.request.user) if self.is_authorized_key('ensemble', ensemble_slug, self.ensemble.nonce):
return True
return ensemble 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): def get_object(self):
return self.ensemble return self.ensemble
def get_queryset(self):
ensembles = models.Ensemble.objects.all()
if self.request.is_admin:
return ensembles
# limit to registered ensembles
f = Q(slug__in=self.get_authorized_keys('ensemble'))
# or ensembles where the user is admin
if self.request.user.is_authenticated:
f |= Q(admins=self.request.user.pk)
return ensembles.filter(f)
class ProjectMixin(AuthorizedResourceMixin): class ProjectMixin(AuthorizedResourceMixin):
project_kwarg = 'project'
def is_authorized(self): def is_authorized(self):
super().is_authorized() project_id = self.kwargs[self.project_kwarg]
try: self.project = get_object_or_404(models.Project.objects.select_related('ensemble'), pk=project_id)
self.project = self.get_project()
if super().is_authorized():
return True return True
except models.Project.DoesNotExist:
return False
def get_project(self):
project = self.get_project_queryset().get(pk=self.kwargs['project'])
self.request.is_admin = project.ensemble.has_admin(self.request.user)
return project
def get_project_queryset(self): # check if the current user is an admin on the ensemble
projects = models.Project.objects.select_related('ensemble') if self.project.ensemble.has_admin(self.request.user):
if self.request.is_admin: logger.debug("is_authorized: ensemble admin for project")
return projects self.request.is_admin = True
return True
f = Q(pk__in=self.get_authorized_keys('project')) | Q(ensemble__slug__in=self.get_authorized_keys('ensemble')) 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.request.user.is_authenticated: if self.is_authorized_key('project', project_id, self.project.nonce):
f |= Q(ensemble__admins=self.request.user.pk) logger.debug('is_authorized: has project link')
else: return True
f &= Q(active=True)
return projects.filter(f) return False
# filter any generated querysets
def get_queryset(self): def get_queryset(self):
return super().get_queryset().filter(project=self.project) return super().get_queryset().filter(project=self.project)
@@ -166,28 +190,64 @@ class CrispyFormMixin(object):
""" ENSEMBLE VIEWS """ """ ENSEMBLE VIEWS """
class EnsembleListView(EnsembleMixin, ListView): class EnsembleListView(AuthorizedResourceMixin, ListView):
model = models.Ensemble
def is_authorized(self): def is_authorized(self):
return True 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): class EnsembleDetailView(EnsembleMixin, DetailView):
def on_signed_request(self): def request_denied(self):
self.add_authorized_key('ensemble', self.kwargs['ensemble']) 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): def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs) data = super().get_context_data(**kwargs)
data['inactive'] = 'inactive' in self.request.GET inactive = 'inactive' in self.request.GET and self.request.is_admin
if data['inactive']:
data['object_list'] = self.object.projects.all().order_by('-pk') 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: else:
data['object_list'] = self.object.active_projects() projects = projects.active().current()
data['inactive'] = inactive
data['object_list'] = projects
if self.request.is_admin: if self.request.is_admin:
data['ensemble_link'] = signed_url(self.request.path) data['ensemble_link'] = self.request.path + "?auth=" + self.ensemble.auth()
return data return data
class EnsembleRevokeView(SingleObjectMixin, RedirectView):
def get_redirect_url(self):
return
""" PROJECT VIEWS """ """ PROJECT VIEWS """
class ProjectListView(ProjectMixin, ListView): class ProjectListView(ProjectMixin, ListView):
@@ -195,8 +255,13 @@ class ProjectListView(ProjectMixin, ListView):
def is_authorized(self): def is_authorized(self):
return True 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): def get_queryset(self):
return self.get_project_queryset().filter(active=True, event_date__gte=timezone.now()-timezone.timedelta(7)) return self.get_project_queryset().current().active()
class ProjectCreateView(EnsembleMixin, CreateView): class ProjectCreateView(EnsembleMixin, CreateView):
admin_required = True admin_required = True
@@ -205,24 +270,36 @@ class ProjectCreateView(EnsembleMixin, CreateView):
title = "Add a new project" title = "Add a new project"
form_class = forms.ProjectForm form_class = forms.ProjectForm
def form_valid(self, form): def form_valid(self, form):
self.object = form.save(commit=False) self.object = form.save(commit=False)
self.object.ensemble_id = self.kwargs['pk'] self.object.ensemble = self.ensemble
self.object.owner = self.request.user self.object.owner = self.request.user
self.object.save() self.object.save()
for module in form.cleaned_data['modules']:
self.object.modules.create(name=module)
return redirect('project_detail', project=self.object.pk) return redirect('project_detail', project=self.object.pk)
class ProjectDetailView(ProjectMixin, DetailView): class ProjectDetailView(ProjectMixin, DetailView):
def on_signed_request(self): def request_denied(self):
self.add_authorized_key('project', self.kwargs['project']) 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): def get_object(self, queryset=None):
return self.project return self.project
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs) data = super().get_context_data(**kwargs)
data['project_link'] = signed_url(self.request.path) 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 return data
class ProjectUpdateView(ProjectMixin, UpdateView): class ProjectUpdateView(ProjectMixin, UpdateView):
@@ -234,12 +311,25 @@ class ProjectUpdateView(ProjectMixin, UpdateView):
def get_object(self): def get_object(self):
return self.project 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 @property
def cancel_url(self): def cancel_url(self):
return self.get_success_url() return resolve_url('project_detail', self.kwargs['project'])
def get_success_url(self):
return resolve_url('project_detail', project=self.kwargs['project'])
# Old Makefile from submission module # Old Makefile from submission module
#class ProjectMakefileView(EnsembleMixin, DetailView): #class ProjectMakefileView(EnsembleMixin, DetailView):
+7 -1
View File
@@ -27,13 +27,14 @@ class MetaInline(admin.TabularInline):
class WorkAdmin(admin.ModelAdmin): class WorkAdmin(admin.ModelAdmin):
list_display = ['name', 'composer', 'edition', 'identifier', 'running_time'] list_display = ['name', 'composer', 'edition', 'identifier', 'running_time']
list_filter = ['collection'] list_filter = ['collection']
search_fields = ['name', 'composer']
inlines = [MetaInline, DocInline, ItemInline] inlines = [MetaInline, DocInline, ItemInline]
admin.site.register(models.Work, WorkAdmin) admin.site.register(models.Work, WorkAdmin)
class SectionInline(admin.TabularInline): class SectionInline(admin.TabularInline):
model = models.Section model = models.Section
fields = ['type', 'tag', 'ordinal', 'start', 'end'] fields = ['tag', 'start', 'end', 'page']
class DocumentAdmin(admin.ModelAdmin): class DocumentAdmin(admin.ModelAdmin):
list_display = ['work', '__str__'] list_display = ['work', '__str__']
@@ -53,3 +54,8 @@ class EnsembleAccessAdmin(admin.ModelAdmin):
list_filter = ['ensemble'] 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 -4
View File
@@ -5,11 +5,10 @@ from interface.forms import BaseForm
class WorkCreateForm(forms.ModelForm, BaseForm): class WorkCreateForm(forms.ModelForm, BaseForm):
#uploads = forms.FileField(label="PDFs to upload", widget=forms.ClearableFileInput(attrs={'multiple': True}), required=False)
class Meta: class Meta:
model = Work model = Work
fields = ['name', 'composer', 'edition', 'code', 'running_time', 'notes'] fields = ['name', 'composer', 'edition', 'code', 'orchestration', 'licence', 'running_time', 'notes']
class PlaylistAddForm(forms.Form): class PlaylistAddForm(forms.Form):
work = forms.ModelChoiceField(queryset=Work.objects.all()) work = forms.ModelChoiceField(queryset=Work.objects.all())
@@ -26,5 +25,9 @@ class PlaylistAddForm(forms.Form):
def save(self): def save(self):
self.instance.works.add(self.cleaned_data['work']) self.instance.works.add(self.cleaned_data['work'])
class ProjectSelectForm(forms.Form): class ProjectEnsembleChoiceField(forms.ModelChoiceField):
project = forms.ModelChoiceField(queryset=Project.objects.all()) def label_from_instance(self, obj):
return f"{obj.ensemble.name} - {obj.name}"
class ProjectSelectForm(BaseForm):
project = ProjectEnsembleChoiceField(queryset=Project.objects.all())
@@ -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),
),
]
+102 -58
View File
@@ -1,5 +1,6 @@
from os import SCHED_OTHER from os import SCHED_OTHER
from django.conf import settings from django.conf import settings
from django.shortcuts import resolve_url
from django.db import models from django.db import models
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.timezone import now from django.utils.timezone import now
@@ -7,9 +8,14 @@ from django.utils.functional import cached_property
from django.core.files.storage import get_storage_class from django.core.files.storage import get_storage_class
from django.db.models import Q, Count, Min, Max from django.db.models import Q, Count, Min, Max
import re
import os.path
from byostorage.user import BYOStorage from byostorage.user import BYOStorage
from byostorage.cached import CachedStorage from byostorage.cached import CachedStorage
from .imslp import Instrument
from library.music_tags import MusicTag
from interface.utils import sign_data
import logging import logging
@@ -26,22 +32,32 @@ logger = logging.getLogger(__name__)
# FIXME: move back to settings # FIXME: move back to settings
library_storage = CachedStorage(BYOStorage()) library_storage = CachedStorage(BYOStorage())
'''
class Orchestration(models.Model): class Orchestration(models.Model):
""" """
Stores a list of instrument codes as a single entry (space delimited). Stores a list of instrument codes as a single entry (space delimited).
Can be global or ensemble specific 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) name = models.CharField(max_length=100)
instruments = models.TextField() instruments = models.TextField()
def as_list(self): def as_list(self):
tags = [ t.strip() for t in self.instruments.split(' ') ] 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): def save(self):
self.as_list() self.as_list()
@@ -49,7 +65,6 @@ class Orchestration(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
'''
class ProjectItem(models.Model): class ProjectItem(models.Model):
""" """
@@ -64,7 +79,7 @@ class ProjectItem(models.Model):
approved_by = models.ForeignKey('auth.User', on_delete=models.CASCADE) approved_by = models.ForeignKey('auth.User', on_delete=models.CASCADE)
order = models.SmallIntegerField(default=0) order = models.SmallIntegerField(default=0)
section = models.CharField(max_length=100, blank=True) 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: class Meta:
ordering = ['order', 'work'] ordering = ['order', 'work']
@@ -88,6 +103,8 @@ class Collection(models.Model):
help_text="User storage for documents") help_text="User storage for documents")
notes = models.TextField(blank=True, notes = models.TextField(blank=True,
help_text="Publicly visible notes about collection and loans policy (markdown format)") 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): def meta(self, name):
items = WorkMeta.objects.filter(work__collection=self.pk, name=name).values_list('value', flat=True).distinct() items = WorkMeta.objects.filter(work__collection=self.pk, name=name).values_list('value', flat=True).distinct()
@@ -101,6 +118,18 @@ class Collection(models.Model):
def genres(self): def genres(self):
return self.meta('genre') 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): def __str__(self):
return self.name return self.name
@@ -152,18 +181,19 @@ class Work(models.Model):
parent = models.ForeignKey('Work', null=True, blank=True, on_delete=models.SET_NULL, related_name="related_works", 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") help_text="Arrangement of another work or part of an anthology")
composer = models.CharField(max_length=255, default='Anon', composer = models.CharField(max_length=255, default='Anon',
help_text="Surname, Initials") help_text="Composer or compilation editor. Use <b>Surname, Initial</b> for easy searching")
original_parts = models.JSONField(default=dict, help_text="Original printed parts (IMSLP format)") 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 details
collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name="works") 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") 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") max_projects = models.IntegerField(default=1, help_text="How many active projects can this work be attached to")
# Extra info # 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) notes = models.TextField(blank=True)
# Allocation to projects # Allocation to projects
@@ -173,23 +203,33 @@ class Work(models.Model):
def folder(self): def folder(self):
return f"{slugify(self.composer)}/{slugify(self.name)}-{self.pk:04d}" return f"{slugify(self.composer)}/{slugify(self.name)}-{self.pk:04d}"
def extract(self, *tags): def tagged_sections(self, *tags):
qs = self.docs.filter(sections__tag__in=tags) qs = self.docs.filter(sections__tag__in=tags)
qs = qs.annotate(Count('sections'), end=Min('sections__end'), start=Max('sections__start')) \ qs = qs.annotate(Count('sections'), end=Min('sections__end'), start=Max('sections__start')) \
.filter(sections__count=len(tags)) .filter(sections__count=len(tags))
return qs
return list(qs.values_list('upload', 'start', 'end')) def list_sections(self, *tags):
return list(self.tagged_sections(*tags).values_list('upload', 'start', 'end'))
@property @property
def digital_parts(self): 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 @property
def physical_parts(self): def physical_parts(self):
if not self.original_parts: if not self.original_parts:
return [] 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 @property
def tags(self): def tags(self):
@@ -199,10 +239,6 @@ class Work(models.Model):
def meta(self): def meta(self):
return self.meta_info.exclude(name='tag') return self.meta_info.exclude(name='tag')
@property
def active_projects(self):
return self.projects.filter(active=True)
@property @property
def current_loans(self): def current_loans(self):
return self.project_items.filter(checkout__lte=now(), returned=None).select_related('project') return self.project_items.filter(checkout__lte=now(), returned=None).select_related('project')
@@ -234,14 +270,25 @@ class Work(models.Model):
return self.code; return self.code;
composer = self.composer or "Anon" composer = self.composer or "Anon"
composer = re.sub('[^\w]', '', composer)
words = self.name.split() 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] 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): def __str__(self):
@@ -277,17 +324,23 @@ class Document(models.Model):
DOCTYPE_PDF = 1 DOCTYPE_PDF = 1
DOCTYPE_AUDIO = 2 DOCTYPE_AUDIO = 2
DOCTYPE_VIDEO = 3 DOCTYPE_VIDEO = 3
DOCTYPE_SOURCE = 4 DOCTYPE_MISC= 4
DOCTYPES = ( DOCTYPES = (
(DOCTYPE_PDF, 'PDF'), (DOCTYPE_PDF, 'PDF'),
(DOCTYPE_AUDIO, 'Audio'), (DOCTYPE_AUDIO, 'Audio'),
(DOCTYPE_VIDEO, 'Video'), (DOCTYPE_VIDEO, 'Video'),
(DOCTYPE_SOURCE, 'Source'), (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") work = models.ForeignKey('Work', on_delete=models.CASCADE, related_name="docs")
doctype = models.PositiveSmallIntegerField(choices=DOCTYPES, default=1) doctype = models.PositiveSmallIntegerField(choices=DOCTYPES, default=DOCTYPE_PDF)
upload = models.FileField(upload_to=doc_upload_filename, storage=library_storage) upload = models.FileField(upload_to=doc_upload_filename, storage=library_storage)
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
version = models.CharField(max_length=30, blank=True) version = models.CharField(max_length=30, blank=True)
@@ -296,6 +349,9 @@ class Document(models.Model):
self.upload.delete(save=False) self.upload.delete(save=False)
return super().delete(*args, **kwargs) return super().delete(*args, **kwargs)
def filename(self):
return os.path.basename(self.upload.name)
def __str__(self): def __str__(self):
return self.upload.name return self.upload.name
@@ -304,53 +360,41 @@ class Section(models.Model):
Section is a tagged portion of a Document Section is a tagged portion of a Document
""" """
TYPE_INSTRUMENT = 1 PAGE_AUTO = 0
TYPE_MOVEMENT = 2 PAGE_LEFT = 1
TYPE_EXCERPT = 3 PAGE_RIGHT = 2
SECTION_TYPES = ( PAGE_PREFERENCE = (
(TYPE_INSTRUMENT, "Instrument"), (PAGE_AUTO, 'auto'),
(TYPE_MOVEMENT, "Movement"), (PAGE_LEFT, 'left'),
(TYPE_EXCERPT, "Excerpt"), (PAGE_RIGHT, 'right'),
) )
SECTION_CLASSES = {
TYPE_INSTRUMENT: 'info',
TYPE_MOVEMENT: 'success',
TYPE_EXCERPT: 'warning',
}
type = models.SmallIntegerField(choices=SECTION_TYPES)
doc = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="sections") doc = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="sections")
tag = models.CharField(max_length=50, blank=True) tag = models.CharField(max_length=50, blank=True)
ordinal = models.IntegerField(default=0)
start = models.SmallIntegerField(null=True, blank=True) start = models.SmallIntegerField(null=True, blank=True)
end = 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: class Meta:
ordering = ['type', 'ordinal', 'doc', 'start', 'pk'] ordering = ['doc', 'start', 'pk']
@property
def music_tag(self):
return MusicTag.from_tag(self.tag)
@property @property
def name(self): def name(self):
if self.type == self.TYPE_INSTRUMENT: return str(self.music_tag)
instr = Instrument.from_tag(self.tag)
if self.ordinal:
return f'{instr} {self.ordinal}'
return str(instr)
return f"{self.ordinal} - {self.tag}"
#@property
#def instrument(self):
# return Instrument.from_tag(self.tag)
@property @property
def bulma_class(self): def bulma_class(self):
return self.SECTION_CLASSES[self.type] return "success" if self.music_tag.is_general else 'info'
@property @property
def filename(self): def filename(self):
return slugify(f'{self.doc.work.name} - {self.name}') + '.pdf' return slugify(f'{self.doc.work.name} - {self.name}').title() + '.pdf'
@property @property
def pagerange(self): def pagerange(self):
@@ -1,12 +1,24 @@
from collections import namedtuple from collections import namedtuple
# taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_Instruments GENERAL = """
# Place any extra abbreviations at the top mvmt Movement
ex Excerpt
sec Section
pce Piece
no Number
"""
ABBREVIATIONS = """ # taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_MusicTags
# Include any aliases at the top
INSTRUMENTS = """
score Score score Score
cb Double bass
cb Double Bass
mall Mallet Percussion
vln Violin
vla Viola
acc Accordion acc Accordion
afl Alto flute afl Alto flute
@@ -24,7 +36,7 @@ bgtr Bass guitar
bjo Banjo bjo Banjo
bn Bassoon bn Bassoon
bob Bass oboe (Baritone oboe) bob Bass oboe (Baritone oboe)
br Brass instruments br Brass MusicTags
bryt Baryton bryt Baryton
bstcl Basset clarinet bstcl Basset clarinet
bsthn Basset horn bsthn Basset horn
@@ -51,7 +63,7 @@ dom Domra
dulc Dulcimer dulc Dulcimer
egtr Electric guitar egtr Electric guitar
eh English horn (Cor anglais) eh English horn (Cor anglais)
elec Electronic Instruments elec Electronic MusicTags
epf Electric piano epf Electric piano
eq Equal voices eq Equal voices
erhu Erhu erhu Erhu
@@ -71,7 +83,7 @@ heck Heckelphone
hn Horn hn Horn
hp Harp hp Harp
hpd Harpsichord hpd Harpsichord
kbd Keyboard instrument kbd Keyboard MusicTag
lute Lute lute Lute
lyre Lyre lyre Lyre
mand Mandolin mand Mandolin
@@ -113,7 +125,7 @@ skbt Sackbut
sop Soprano (voice) sop Soprano (voice)
srp Serpent srp Serpent
stpt Slide trumpet stpt Slide trumpet
str String instruments str String MusicTags
sxh Saxhorn sxh Saxhorn
syn Synthesizer syn Synthesizer
tba Tuba tba Tuba
@@ -137,77 +149,117 @@ vn Violin
vuv Vuvuzela vuv Vuvuzela
vv Voices (multiple soloists) vv Voices (multiple soloists)
wag Wagner tuba wag Wagner tuba
ww Woodwind instruments ww Woodwind MusicTags
xiao Xiao xiao Xiao
xyl Xylophone xyl Xylophone
zith Zither zith Zither
""" """
ORCHESTRATIONS = { MUSIC_TAGS = []
'SATB': ('S', 'A', 'T', 'B'), GENERAL_TAGS = set()
'String Quartet': ('Vln1', 'Vln2', 'Vla', 'Vc'), for i, abbreviations in enumerate((GENERAL, INSTRUMENTS)):
'String Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb'), for line in abbreviations.split('\n'):
'Chamber Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb', parts = line.strip().split(maxsplit=1)
'Fl1', 'Fl2', 'Cl1', 'Cl2', 'Hn1', 'Hn2', if len(parts) < 2: continue
'Tpt1', 'Tpt2', 'Tbn1', 'Tbn2', 'Tuba', name, _, _ = parts[1].partition('(')
'Timp', 'Drum', 'Perc'), MUSIC_TAGS.append((parts[0], name))
'Custom': (), if i == 0:
} GENERAL_TAGS.add(parts[0])
INSTRUMENTS = [] MUSIC_NAME_BY_TAG = dict(MUSIC_TAGS)
for line in ABBREVIATIONS.split('\n'): MUSIC_TAG_BY_NAME = dict( ( (x[1].lower(), x[0]) for x in MUSIC_TAGS ) )
parts = line.strip().split(maxsplit=1)
if len(parts) < 2: continue
name, _, _ = parts[1].partition('(')
INSTRUMENTS.append((parts[0], name))
INSTRUMENT_NAMES = dict(INSTRUMENTS) class MusicTag(namedtuple('MusicTag', ('name', 'variant'), defaults=[None])):
INSTRUMENT_TAGS = dict( ( (x[1].lower(), x[0]) for x in INSTRUMENTS ) )
class Instrument(namedtuple('Instrument', ('name', 'variant'), defaults=[None])):
@classmethod @classmethod
def from_tag(cls, tag): def from_tag(cls, tag):
""" """
>>> Instrument.from_tag('vn-1') >>> MusicTag.from_tag('vn-1')
Instrument(name='Violin', variant='1') MusicTag(name='Violin', variant='1')
>>> Instrument.from_tag('db') >>> MusicTag.from_tag('db')
Instrument(name='Double Bass', variant=None) MusicTag(name='Double Bass', variant=None)
>>> Instrument.from_tag('Jaws Harp') >>> MusicTag.from_tag('Jaws Harp')
Instrument(name='Jaws Harp', variant=None) 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('-') abbr, _, variant = tag.partition('-')
name = INSTRUMENT_NAMES.get(abbr.lower(), abbr) name = MUSIC_NAME_BY_TAG.get(abbr.lower(), abbr)
if variant: if variant:
return cls(name, variant) return cls(name, variant)
return cls(name, None) 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): def abbreviate(self):
""" """
>>> Instrument('Violin', 1).abbreviate() >>> MusicTag('Violin', 1).abbreviate()
'vn-1' 'vn-1'
>>> Instrument('Double Bass').abbreviate() >>> MusicTag('Double Bass').abbreviate()
'db' 'db'
""" """
tag = INSTRUMENT_TAGS.get(self.name.lower()) tag = MUSIC_TAG_BY_NAME.get(self.name.lower())
if self.variant: if self.variant:
tag = f"{tag}-{self.variant}" tag = f"{tag}-{self.variant}"
return tag return tag
def __str__(self): def __str__(self):
""" """
>>> str(Instrument('Violin', 1)) >>> str(MusicTag('Violin', 1))
'Violin 1' 'Violin 1'
>>> str(Instrument('Double Bass')) >>> str(MusicTag('Double Bass'))
'Double Bass' 'Double Bass'
""" """
if self.variant: if self.variant:
return f"{self.name} {self.variant}" return f"{self.name} {self.variant}"
return self.name 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__": if __name__ == "__main__":
import doctest import doctest
print(doctest.testmod()) print(doctest.testmod())
+2 -2
View File
@@ -24,12 +24,12 @@ def extract_and_concat(items):
if count == 0: if count == 0:
continue continue
if start is None: if not start:
sections.append(source) sections.append(source)
else: else:
if end is None: if not end:
end = start end = start
dest = os.path.join(d.name, f'section_{i}.pdf') dest = os.path.join(d.name, f'section_{i}.pdf')
@@ -3,19 +3,32 @@
{% block page %} {% block page %}
<h3 class="title">Library collections for {% firstof request.user.first_name request.user.username %}</h3> <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"> <div class="columns is-multiline">
{% for collection in object_list %} {% for collection in object_list %}
<div class="column is-half"> <div class="column is-half">
<div class="card"> <div class="card">
<header class="card-header"> <header class="card-header">
<a class="" href="{% url 'collection_work_list' pk=collection.id %}"> <a class="" href="{% url 'collection_work_list' collection=collection.id %}">
<p class="card-header-title">{{ collection.name }}</p> <p class="card-header-title">{{ collection.name }}</p>
</a> </a>
</header> </header>
<div class="card-content"> <div class="card-content">
<p> <p>
{% if collection.location %}{{ collection.location }},{% endif %} {% if collection.location %}{{ collection.location }},{% endif %}
{{ collection.works.count }} items. {{ collection.works.count }} item{{ collection.works.count|pluralize }}.
</p> </p>
<p> <p>
{% for tag in collection.tags %} {% for tag in collection.tags %}
@@ -5,7 +5,7 @@
<span class="icon"><i class="fas fa-save"></i></span> <span class="icon"><i class="fas fa-save"></i></span>
<span>Save</span> <span>Save</span>
</a> </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> <span>Cancel</span>
</a> </a>
{% endblock %} {% endblock %}
@@ -21,7 +21,7 @@
} }
.grid-page { .grid-page {
height: 25px; height: 25px;
margin-bottom: 10px; margin-bottom: 5px;
border: 1px solid #999; border: 1px solid #999;
border-radius: 5px; border-radius: 5px;
text-align: center; text-align: center;
@@ -44,46 +44,45 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: start;
} }
#tag-area { #tag-area {
min-width: 220px; min-width: 220px;
} }
.instrument {
display: list-item !important;
}
.instrument:hover {
background-color: var(--primary);
color: white;
}
</style> </style>
{% endblock %} {% endblock %}
{% block page %} {% 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 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="column is-narrow">
<div class="has-text-centered"> <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;"> <div class="box" style="display: inline-block;">
<canvas id="inline-viewer" style="width: 500px;"></canvas> <canvas id="inline-viewer" style="width: 500px;"></canvas>
</div> </div>
@@ -97,9 +96,39 @@
<div class="grid-column" id="tag-area"> <div class="grid-column" id="tag-area">
</div> </div>
</div> </div>
<button class="button is-primary" style="width: 100%" onclick="expandEntries()">Expand</button>
</div> </div>
</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> <p>{{ document.upload.name }}</p>
{% endblock %} {% endblock %}
@@ -194,10 +223,9 @@
if (pageNum <= 1) { if (pageNum <= 1) {
return; return;
} }
pageNum--; queueRenderPage(pageNum-1);
queueRenderPage(pageNum);
} }
//document.getElementById('prev').addEventListener('click', onPrevPage); document.getElementById('prev').addEventListener('click', onPrevPage);
/** /**
* Displays next page. * Displays next page.
@@ -206,10 +234,9 @@
if (pageNum >= pdfDoc.numPages) { if (pageNum >= pdfDoc.numPages) {
return; return;
} }
pageNum++; queueRenderPage(pageNum+1);
queueRenderPage(pageNum);
} }
//document.getElementById('next').addEventListener('click', onNextPage); document.getElementById('next').addEventListener('click', onNextPage);
/** /**
* Asynchronously downloads PDF. * Asynchronously downloads PDF.
@@ -243,6 +270,12 @@
dirty = false; 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() { function addInstrument() {
let name = document.getElementById('add-instrument-name'); let name = document.getElementById('add-instrument-name');
let variant = document.getElementById('add-instrument-variant'); let variant = document.getElementById('add-instrument-variant');
@@ -268,7 +301,19 @@
variant.value = ''; variant.value = '';
name.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); addTag(tag, pageNum, pageNum);
closeAddModal();
}
function assignInstrument(tag, el) {
addTag(tag, pageNum, pageNum);
//el.remove();
} }
function addTag(tag, start, end) { function addTag(tag, start, end) {
@@ -295,7 +340,10 @@
let del = document.createElement('span'); let del = document.createElement('span');
del.className = "icon is-action"; del.className = "icon is-action";
del.innerHTML = '<i class="fas fa-trash-alt" title="Remove this tag"></i>'; 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) label.appendChild(del)
el.appendChild(label); el.appendChild(label);
@@ -315,8 +363,8 @@
let start = tag.dataset.start; let start = tag.dataset.start;
let end = tag.dataset.end; let end = tag.dataset.end;
let span = end-start+1; let span = end-start+1;
let height = span * 25 + (span-1) * 10; let height = span * 25 + (span-1) * 5;
let top = (start-1) * 35; let top = (start-1) * 30;
tag.style.height = height + 'px'; tag.style.height = height + 'px';
tag.style.marginTop = top + 'px'; tag.style.marginTop = top + 'px';
@@ -341,6 +389,19 @@
updateTag(el); 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() { function saveTags() {
const pageTags = []; const pageTags = [];
for (let pageTag of tagArea.children ) { for (let pageTag of tagArea.children ) {
@@ -361,7 +422,7 @@
} }
).then((response) => { ).then((response) => {
if (response.ok) { if (response.ok) {
window.location = "{% url 'work_detail' document.work.pk %}" window.location = "{% url 'work_detail' collection.pk document.work_id %}"
} else { } else {
alert("Failed: " + response.statusText) alert("Failed: " + response.statusText)
} }
@@ -13,7 +13,7 @@
<button class="button is-link">Yes</button> <button class="button is-link">Yes</button>
</div> </div>
<div class="control"> <div class="control">
<a class="button is-link is-light" href="{% url 'work_detail' object.work.pk %}">No</a> <a class="button is-link is-light" href="{% url 'work_detail' collection.pk object.work.pk %}">No</a>
</div> </div>
</div> </div>
</form> </form>
@@ -4,14 +4,16 @@
{{ doc.upload.name|basename }}</a></td> {{ doc.upload.name|basename }}</a></td>
<td> <td>
{% for section in doc.sections.all %} {% for section in doc.sections.all %}
<a class="tag is-{{ section.bulma_class }}" target="_blank" href="{% url 'part_download' pk=section.pk filename=section.filename %}">{{ section.name }}</a> <a class="tag is-{{ section.bulma_class }}" target="_blank" href="{% url 'part_download' collection.pk section.pk section.filename %}">{{ section.name }}</a>
{% endfor %} {% endfor %}
</td> </td>
<td class="has-text-right" style="white-space: nowrap;"> <td class="has-text-right" style="white-space: nowrap;">
{% if request.is_admin %} {% if request.is_admin %}
<a href="{% url 'document_annotate' pk=doc.pk %}"><i class="fas fa-tags" {% if doc.doctype == 1 %}
<a href="{% url 'document_annotate' collection.pk doc.pk %}"><i class="fas fa-tags"
title="Manage Tags"></i></a> title="Manage Tags"></i></a>
<a href="{% url 'document_delete' pk=doc.pk %}"><i class="fas fa-trash-alt" title="Delete Document"></i></a> {% endif %}
<a href="{% url 'document_delete' collection.pk doc.pk %}"><i class="fas fa-trash-alt" title="Delete Document"></i></a>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
+29 -21
View File
@@ -1,21 +1,14 @@
{% extends "interface/project_base.html" %} {% 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 %} {% block page %}
<form action="" method="post" target="_blank"> <form action="" method="post" target="_blank">
{% csrf_token %} {% csrf_token %}
<div> <div>
<div class="field is-grouped is-grouped-centered"> <div class="field is-grouped is-grouped-centered is-grouped-multiline">
<div class="field has-addons control"> <div class="field has-addons control has-addons-centered is-expanded">
<span class="control"> <span class="control is-hidden-mobile">
<a class="button is-static">Your Instrument</a> <a class="button is-static">Instrument</a>
</span> </span>
<span class="control"> <span class="control">
@@ -47,12 +40,12 @@
</div> </div>
</div> </div>
<table class="table is-striped is-fullwidth"> <table class="table is-striped mx-auto">
<thead> <thead>
<tr> <tr>
<th/> <th/>
<th>Piece</th> <th>Piece</th>
<th>Running time</th> <th class="is-hidden-mobile">Running time</th>
<th>Part</th> <th>Part</th>
<th/> <th/>
</tr> </tr>
@@ -63,25 +56,27 @@
<td>{{ forloop.counter }}.</td> <td>{{ forloop.counter }}.</td>
<td> <td>
{% if request.is_admin %} {% 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 %} {% else %}
{{ item.work.name }} {{ item.work.name }}
{% endif %} {% endif %}
</td> </td>
<td>{% firstof item.work.running_time "------" %}</td> <td class="is-hidden-mobile">{% firstof item.work.running_time "------" %}</td>
<td class="select-cell"> <td class="select-cell">
<input type="hidden" name="works" value="{{ item.work.pk }}"/> <input type="hidden" name="works" value="{{ item.work.pk }}"/>
<span class="select is-small"> <span class="select is-small" style="width: 100%">
<select name="instruments"> <select name="instruments" id="part-{{ item.work.pk }}" style="width: 100%">
<option value='-'>None</option> <option value='-'>None</option>
{% for part in item.work.digital_parts %} {% for tag, name in item.work.digital_parts %}
<option value='{{ part.tag }}'>{{ part.instrument }}</option> <option value='{{ tag }}'>{{ name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</span> </span>
</td> </td>
<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> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -90,11 +85,13 @@
<tr> <tr>
<td/> <td/>
<td/> <td/>
<td>{{ running_time }}</td> <td>{% firstof running_time "------" %}</td>
<td/> <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> <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> <a class="button is-link is-small"><span class="icon"><i class="fas fa-archive"></i></span><span>Individual files (zipped)</span></a>
-->
</td> </td>
</tr> </tr>
</tfoot> </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("instrument-name").value = localStorage.getItem('instrument-name', 'All');
document.getElementById("part-preference").value = localStorage.getItem('part-preference', 'Any'); document.getElementById("part-preference").value = localStorage.getItem('part-preference', 'Any');
updateParts(); updateParts();
@@ -1,12 +1,50 @@
{% with project.items.count as items %} <div class="panel">
{% if items %} <p class="panel-heading">
<p> Items
There {{ items|pluralize:"is,are" }} currently {{ items}} item{{ items|pluralize }} for this project.<br/> </p>
Click the '<a href="{% url 'item_list' project=project.pk %}">My Music</a>' button below to access your parts...
</p> {% for item in project.items.all %}
{% else %} {% with work=item.work %}
<p> <a class="panel-block" onclick="showDownloadOptions('{% url 'work_parts' work.collection_id work.pk %}')">
There are no items listed yet for this project - please check back later... {{ item.work.name }}
</p> </a>
{% endif %} {% endwith %}
{% 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 %} {% if project %}
<li><a href="{% url 'item_list' project=project.pk %}">My Music</a></li> <li><a href="{% url 'item_list' project=project.pk %}">My Music</a></li>
{% endif %} {% 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 'work_list' %}">Library</a></li>
<li><a href="{% url 'collection_list' %}">Collections</a></li>
{% endif %} {% endif %}
</ul> </ul>
+34 -21
View File
@@ -7,11 +7,11 @@
{% endblock %} {% endblock %}
{% block admin %} {% 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 class="icon"><i class="fas fa-edit"></i></span>
<span>Edit</span> <span>Edit</span>
</a> </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 class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add to project</span> <span>Add to project</span>
</a> </a>
@@ -29,16 +29,27 @@
<p class="block">{{ work.notes }}</p> <p class="block">{{ work.notes }}</p>
<p class="block"> <p class="block">
Location: <a href="{% url 'collection_work_list' work.collection.pk %}">{{ work.collection }}</a> [{{ work.identifier }}]<br/> <table class="table">
Running time: {% firstof work.duration 'Unknown' %}<br/> <tr>
Licence: {{ work.get_licence_display }}<br/> <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 %} {% for meta in work.meta %}
{{ meta.get_name_display }}: <a href="{% url 'collection_work_list' work.collection.pk %}?filter={{ meta.name}}:{{ meta.value }}">{{ meta.value }}</a><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 %} {% endfor %}
</p> </td>
</tr>
</table>
{% if work.parent %} {% 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> </p>
{% endif %} {% endif %}
@@ -46,7 +57,7 @@
<h3>Related</h3> <h3>Related</h3>
<ul> <ul>
{% for related in work.related_works.all %} {% 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 %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
@@ -62,7 +73,7 @@
</h4> </h4>
<div class="tags"> <div class="tags">
{% for inst, c in work.physical_parts %} {% 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 %} {% empty %}
<p class="is-italic">No printed parts listed</p> <p class="is-italic">No printed parts listed</p>
{% endfor %} {% endfor %}
@@ -76,15 +87,17 @@
Digital Parts Digital Parts
</h4> </h4>
<div class="tags"> <div class="tags">
{% if work.digital_parts %} {% with sections=work.digital_parts %}
<a class="tag is-danger" href="{% url 'work_partset' pk=work.pk %}">Full Set</a> {% if sections %}
{% endif %} <a class="tag is-danger" href="{% url 'work_partset' collection.pk work.pk %}">Full Set</a>
{% for section in work.digital_parts %} {% endif %}
<a class="tag is-info" href="{% url 'part_download' pk=section.pk filename=section.filename %}" {% for tag, name in sections %}
target="section_{{ section.pk }}" rel="">{{ section.name }}</a> <a class="tag is-info" href="{% url 'work_download' collection.pk work.pk %}?tag={{ tag }}"
{% empty %} target="section_{{ tag }}" rel="">{{ name }}</a>
<p class="is-italic">No digital parts available</p> {% empty %}
{% endfor %} <p class="is-italic">No digital parts available</p>
{% endfor %}
{% endwith %}
</div> </div>
</div> </div>
</div> </div>
@@ -118,7 +131,7 @@
{% if request.is_admin %} {% if request.is_admin %}
<div class="column is-one-quarter"> <div class="column is-one-quarter">
<h4 class="is-size-5">Upload files</h4> <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 %} {% csrf_token %}
</form> </form>
</div> </div>
@@ -135,7 +148,7 @@
Loans Loans
</h4> </h4>
<span class="level-right"> <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> class="fas fa-plus-circle"></i></span> Checkout</a>
</span> </span>
</div> </div>
+5 -11
View File
@@ -2,8 +2,8 @@
{% load url_tools %} {% load url_tools %}
{% block admin %} {% block admin %}
{% if collection_id %} {% if collection %}
<a href="{% url 'work_add' pk=collection_id %}" class="button is-link"> <a href="{% url 'work_add' collection.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span> <span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add a work</span> <span>Add a work</span>
</a> </a>
@@ -29,22 +29,16 @@
<th>Work</th> <th>Work</th>
<th>Composer</th> <th>Composer</th>
<th class="is-hidden-mobile">Edition</th> <th class="is-hidden-mobile">Edition</th>
{% if request.is_admin %} {% if not collection %}<th class="is-hidden-touch">Collection</th>{% endif %}
<th class="is-hidden-touch">Collection</th>
<th class="is-hidden-mobile">Copies</th>
{% endif %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for work in object_list %} {% for work in object_list %}
<tr> <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 title="{{ work.composer }}">{{ work.composer|truncatewords:3 }}</td>
<td class="is-hidden-mobile" title="{{ work.edition }}">{{ work.edition|truncatewords:2 }}</td> <td class="is-hidden-mobile" title="{{ work.edition }}">{{ work.edition|truncatewords:2 }}</td>
{% if request.is_admin %} {% if not collection %}<td class="is-hidden-touch">{{ work.collection.name }}</td>{% endif %}
<td class="is-hidden-touch">{{ work.collection.name }}</td>
<td class="is-hidden-mobile {{ work.is_available|yesno:'has-text-success,has-text-danger' }}">{{ work.available }}</td>
{% endif %}
</tr> </tr>
{% empty %} {% empty %}
<tr><td colspan="4">No works found</td></tr> <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>
+39 -23
View File
@@ -3,35 +3,51 @@
{% block page %} {% 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"> <form action="" method="post" target="_blank">
{% csrf_token %} {% csrf_token %}
<div class="admin-tools is-pulled-right"> <table class="table">
<button type="submit" class="button is-link"> <thead>
<span class="icon"><i class="fas fa-print"></i></span>
<span>Print Set</span>
</button>
<a class="button is-link is-light" href="{% url 'work_detail' pk=object.pk %}">
<span>Cancel</span>
</a>
</div>
</thead>
<h3 class="subtitle"><a href="{% url 'work_detail' pk=work.pk %}">{{ work.name }}</a></h3> <tbody>
{% for tag, name in work.digital_parts %}
<p class="block"> <tr>
You can generate a custom partset for printing - select the number of copies of each you want... <td>{{ name }}</td>
</p> <td>
<input name="parts" type="hidden" value="{{ tag }}">
<div class="columns is-multiline is-mobile"> <input class="input is-small" name="copies" type="number" value="{% if tag == 'score' %}0{% else %}1{% endif %}">
{% for part in work.digital_parts %} </td>
<div class="column is-3 has-text-right"> </tr>
<span style="white-space: nowrap">{{ part.instrument }}</span>
<input name="parts" type="hidden" value="{{ part.tag }}">
<input name="copies" type="number" value="{% if part.tag == 'Score' %}0{% else %}1{% endif %}" size="1">
</div>
{% endfor %} {% 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> </div>
</form> </form>
</div>
</div>
{% endblock %} {% endblock %}
+149 -23
View File
@@ -1,50 +1,176 @@
from django.test import TestCase from interface.tests import AccessTestCase
from django.contrib.auth.models import User from byostorage.user import UserStorage
from interface.models import Ensemble, Project
from . import models 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
class IntegrationTestCase(TestCase):
def setUp(self): def setUp(self):
self.homer = User.objects.create(username='homer') pass
self.ned = User.objects.create(username="ned")
self.lisa = User.objects.create(username="lisa")
self.dewey = User.objects.create(username="dewey")
self.be_sharps = self.homer.ensembles.create(name='Be Sharps', code="barbershop") @classmethod
self.sesd = self.dewey.ensembles.create(name="Springfield Elementary School Band", code="sax") def tearDownClass(cls):
cls.temp_dir.cleanup()
self.sel = self.lisa.collections.create(name="Springfield Elementary Library")
self.flanders = self.ned.collections.create(name="Neds Shed")
def test_integration(self): def test_integration(self):
pass 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): 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. 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' This also should give us the ability to store an anthology as one Work have Project reference 'no:23'
''' '''
work = self.sel.works.create(name="Some Quartet", composer="Beethoven") work = self.collections['sel'].works.create(name="Some Quartet", composer="Beethoven")
for g in ('vl1', 'vl2', 'vla', 'vc'): for g in ('vl-1', 'vl-2', 'vla', 'vc'):
doc = work.docs.create(upload=f'sel/beethoven/some_quartet/some_quartet_{g}.pdf') 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-1', start=1, end=3)
doc.sections.create(tag='mvmt:2', start=4, end=8) doc.sections.create(tag='mvmt-2', start=4, end=8)
doc.sections.create(tag='mvmt:3', start=9, end=12) doc.sections.create(tag='mvmt-3', start=9, end=12)
doc.sections.create(tag=f'inst:{g}') doc.sections.create(tag=g)
# no tags - get nothing (should it be everything?) # no tags - get nothing (should it be everything?)
self.assertEqual(work.extract(), []) self.assertEqual(work.list_sections(), [])
# single tag - should get just that range # single tag - should get just that range
self.assertEqual(work.extract('inst:vl1'), [('sel/beethoven/some_quartet/some_quartet_vl1.pdf', None, None)]) 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 # single tag - returns all documents with that range
result = work.extract('mvmt:2') result = work.list_sections('mvmt-2')
self.assertEqual(len(result), 4) self.assertEqual(len(result), 4)
# multiple tags - returns the overlapping portion of all documents that have all tags # multiple tags - returns the overlapping portion of all documents that have all tags
self.assertEqual(work.extract('inst:vl1', 'mvmt:2'), [('sel/beethoven/some_quartet/some_quartet_vl1.pdf', 4, 8)]) self.assertEqual(work.list_sections('vl-1', 'mvmt-2'), [('sel/beethoven/some_quartet/some_quartet_vl-1.pdf', 4, 8)])
self.assertEqual(work.extract('inst:vl1', 'inst:vl2'), []) self.assertEqual(work.list_sections('vl-1', 'vl-2'), [])
+21 -16
View File
@@ -16,24 +16,29 @@ urlpatterns = [
path('projects/<int:project>/items/manage', views.ProjectItemManageView.as_view(), name="item_list_manage"), 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('projects/<int:project>/items/append', views.ProjectItemAddView.as_view(), name="item_list_append"),
path('library/collections', views.CollectionListView.as_view(), name="collection_list"), path('library', views.LibraryWorkListView.as_view(), name="work_list"),
path('library/collections/<int:pk>', views.CollectionWorkListView.as_view(), name="collection_work_list"),
path('library/collections/<int:pk>/create', views.WorkAddView.as_view(), name="work_add"),
path('library/works', views.WorkListView.as_view(), name="work_list"), path('collections', views.CollectionListView.as_view(), name="collection_list"),
path('library/works/<int:pk>', views.WorkDetailView.as_view(), name="work_detail"), path('collections/<int:collection>', views.CollectionWorkListView.as_view(), name="collection_work_list"),
path('library/works/<int:pk>/edit', views.WorkUpdateView.as_view(), name="work_edit"), path('collections/<int:collection>/add', views.WorkAddView.as_view(), name="work_add"),
path('library/works/<int:pk>/partset', views.WorkPartSetView.as_view(), name="work_partset"),
path('library/works/<int:pk>/add_to_project', views.WorkAddToProject.as_view(), name="work_add_to_project"),
path('library/works/<int:pk>/upload', views.WorkAddDocumentView.as_view(), name="document_add"),
path('library/documents/<int:pk>/delete', views.DocumentDeleteView.as_view(), name="document_delete"), path('collections/<int:collection>/works/<int:pk>', views.WorkDetailView.as_view(), name="work_detail"),
path('library/documents/<int:pk>/download', views.DocumentDownloadView.as_view(), name="document_download"), path('collections/<int:collection>/works/<int:pk>/edit', views.WorkUpdateView.as_view(), name="work_edit"),
path('library/documents/<int:pk>/annotate', views.DocumentAnnotateView.as_view(), name="document_annotate"), path('collections/<int:collection>/works/<int:pk>/partset', views.WorkPartSetView.as_view(), name="work_partset"),
path('library/parts/<int:pk>/<str:filename>', views.PartDownloadView.as_view(), name="part_download"), 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/', include(router.urls))
path('api/library/collections/<int:pk>/export', api.CollectionExportView.as_view(), name="collection_export"), path('api/collections/<int:pk>', api.CollectionExportView.as_view(), name="collection_export"),
path('api/library/works/<int:pk>/export', api.WorkExportView.as_view(), name="work_export"), path('api/collections/<int:collection>/works/<int:pk>', api.WorkExportView.as_view(), name="work_export"),
path('api/library/collections/<int:pk>/import', api.WorkImportView.as_view(), name="work_import"), 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"),
] ]
+193 -90
View File
@@ -1,34 +1,38 @@
from django.shortcuts import render, redirect, resolve_url from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.views.generic.detail import DetailView, SingleObjectMixin, View from django.views.generic.detail import DetailView, SingleObjectMixin, View
from django.views.generic.list import ListView, MultipleObjectMixin from django.views.generic.list import ListView, MultipleObjectMixin
from django.views.generic.edit import CreateView, FormView, UpdateView, DeleteView from django.views.generic.edit import CreateView, FormView, UpdateView, DeleteView
from django.http import FileResponse, HttpResponse, JsonResponse from django.http import FileResponse, HttpResponse, JsonResponse
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models import Q, Count, Sum from django.db.models import Q, Count, Sum
from django.db import transaction
from django.utils.timezone import now from django.utils.timezone import now
from django.urls import reverse from django.urls import reverse
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.core.exceptions import SuspiciousOperation
from django.http import Http404, HttpResponseRedirect
import json import json
import os.path import os.path
import re import re
from interface.views import EnsembleMixin, ProjectMixin from interface.views import EnsembleMixin, ProjectMixin, AuthorizedResourceMixin
from interface.models import Project from interface.models import Project
from library.models import Collection, Work, Document, Section from library.models import Collection, Work, Document, Section
from library.imslp import INSTRUMENT_TAGS, INSTRUMENTS from library.music_tags import MUSIC_TAGS, MusicTag, auto_tag
from library import forms, models from library import forms, models
from library.pdf_utils import extract_pages, extract_and_concat from library.pdf_utils import extract_pages, extract_and_concat
import logging
logger = logging.getLogger(__name__)
class ProjectItemListView(ProjectMixin, ListView): class ProjectItemListView(ProjectMixin, ListView):
template_name = "library/item_list.html" template_name = "library/item_list.html"
model = models.ProjectItem model = models.ProjectItem
def post(self, request, **kwargs): def post(self, request, **kwargs):
project = self.get_project() project_works = self.project.works.all()
project_works = project.works.all()
instruments = request.POST.getlist('instruments') instruments = request.POST.getlist('instruments')
works = request.POST.getlist('works') works = request.POST.getlist('works')
@@ -54,7 +58,7 @@ class ProjectItemListView(ProjectMixin, ListView):
result = extract_and_concat(sections) result = extract_and_concat(sections)
download_name = f'{project.name}.pdf' download_name = f'{self.project.name}.pdf'
response = FileResponse(result, content_type="application/pdf") response = FileResponse(result, content_type="application/pdf")
response['Content-Disposition'] = f'inline; filename="{download_name}"' response['Content-Disposition'] = f'inline; filename="{download_name}"'
@@ -66,16 +70,13 @@ class ProjectItemListView(ProjectMixin, ListView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
data = super(ProjectItemListView, self).get_context_data(**kwargs) data = super(ProjectItemListView, self).get_context_data(**kwargs)
data['instruments'] = INSTRUMENTS data['instruments'] = MUSIC_TAGS
data['instrument'] = self.request.session.get('instrument', 'Score') data['instrument'] = self.request.session.get('instrument', 'Score')
data['part'] = self.request.session.get('part', '0') data['part'] = self.request.session.get('part', '0')
data['running_time'] = self.get_queryset().aggregate(Sum('work__running_time'))['work__running_time__sum'] data['running_time'] = self.get_queryset().aggregate(Sum('work__running_time'))['work__running_time__sum']
#if running_time:
# data['running_time'] = "{0:d}:{1:02d}".format(int(running_time / 60), running_time % 60)
#else:
# data['running_time'] = "-:--"
return data return data
class ProjectItemManageView(ProjectMixin, ListView): class ProjectItemManageView(ProjectMixin, ListView):
template_name = "library/item_list_manage.html" template_name = "library/item_list_manage.html"
model = models.ProjectItem model = models.ProjectItem
@@ -109,25 +110,63 @@ class ProjectItemAddView(ProjectMixin, UpdateView):
def get_object(self): def get_object(self):
return self.get_project() return self.get_project()
class CollectionListView(EnsembleMixin, ListView): """ COLLECTION VIEWS """
def get_queryset(self): class CollectionMixin(AuthorizedResourceMixin):
return Collection.objects.filter(administrators=self.request.user)
collection = None
class WorkListView(EnsembleMixin, ListView): def is_authorized(self):
paginate_by = 20 collection_id = self.kwargs['collection']
self.collection = get_object_or_404(models.Collection, pk=collection_id)
def get_works(self): if super().is_authorized():
return Work.objects.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id).order_by('name').distinct().select_related('collection') return True
def get_context_data(self, *args, **kwargs): if self.collection.has_administrator(self.request.user):
data = super(WorkListView, self).get_context_data(*args, **kwargs) self.request.is_admin = True
data['title'] = f'Music available to {self.ensemble.name}' 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 return data
def get_queryset(self): def get_queryset(self):
works = self.get_works().order_by('name') 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') q = self.request.GET.get('filter')
if q: if q:
@@ -137,33 +176,60 @@ class WorkListView(EnsembleMixin, ListView):
else: else:
works = works.filter(Q(name__contains=q) | Q(composer__contains=q) | Q(meta_info__value__contains=q)) works = works.filter(Q(name__contains=q) | Q(composer__contains=q) | Q(meta_info__value__contains=q))
return works 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): class CollectionWorkListView(WorkListView):
def get_works(self): def request_denied(self):
works = Work.objects.filter(collection=self.kwargs['pk']).distinct() 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()
loan_count = Count('project_items', Q(project_items__checkout__lte=now(), project_items__returned=None)) def get_works(self):
works = works.annotate(loan_count=loan_count) 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 return works
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
data = super(CollectionWorkListView, self).get_context_data(*args, **kwargs) data = super(CollectionWorkListView, self).get_context_data(*args, **kwargs)
data['title'] = Collection.objects.get(pk=self.kwargs['pk']).name data['title'] = self.collection.name
data['collection_id'] = self.kwargs['pk']
return data return data
class WorkAddView(EnsembleMixin, FormView): class WorkAddView(CollectionMixin, FormView):
template_name = "interface/default_form.html" template_name = "interface/default_form.html"
form_class = forms.WorkCreateForm form_class = forms.WorkCreateForm
title = "Add a new work" 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): def form_valid(self, form):
work = form.save(commit=False) work = form.save(commit=False)
work.ensemble_id = self.request.ensemble_id #work.ensemble_id = self.request.ensemble_id
work.collection_id = self.kwargs['pk'] work.collection_id = self.collection.pk
work.save() work.save()
# handle the files # handle the files
@@ -175,29 +241,22 @@ class WorkAddView(EnsembleMixin, FormView):
if len(docs) == 1: if len(docs) == 1:
return redirect('document_annotate', docs[0]) return redirect('document_annotate', docs[0])
else: else:
return redirect('work_detail', pk=work.pk) return redirect('work_detail', collection=self.collection.pk, pk=work.pk)
class WorkMixin(object): class WorkDetailView(CollectionMixin, DetailView):
model = models.Work
def get_queryset(self): class WorkUpdateView(CollectionMixin, UpdateView):
if self.request.is_admin: model = models.Work
return Work.objects.all() form_class = forms.WorkCreateForm
return Work.objects.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id)
class WorkDetailView(EnsembleMixin, WorkMixin, DetailView):
pass
class WorkUpdateView(EnsembleMixin, WorkMixin, UpdateView):
fields = ['name', 'composer', 'edition', 'code', 'licence', 'max_projects', 'running_time', 'notes']
template_name = 'interface/default_form.html' template_name = 'interface/default_form.html'
def get_success_url(self): def get_success_url(self):
return resolve_url('work_detail', self.kwargs['pk']) return resolve_url('work_detail', self.collection.pk, self.kwargs['pk'])
class WorkAddToProject(EnsembleMixin, FormView): class WorkAddToProject(CollectionMixin, FormView):
admin_required = True admin_required = True
form_class = forms.ProjectSelectForm form_class = forms.ProjectSelectForm
template_name = "interface/default_form.html" template_name = "interface/default_form.html"
@@ -208,12 +267,16 @@ class WorkAddToProject(EnsembleMixin, FormView):
def get_form(self): def get_form(self):
f = super(WorkAddToProject, self).get_form() f = super(WorkAddToProject, self).get_form()
qs = f.fields['project'].queryset 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() work = self.get_object()
qs = qs.filter(ensemble_id=self.request.ensemble_id).exclude(pk__in=work.projects.all()) qs = qs.exclude(pk__in=work.projects.all())
f.fields['project'].queryset = qs f.fields['project'].queryset = qs.order_by('ensemble__name', 'name')
return f return f
def form_valid(self, form): def form_valid(self, form):
@@ -222,9 +285,11 @@ class WorkAddToProject(EnsembleMixin, FormView):
work.project_items.create(project=project, approved_by=self.request.user, checkout=now()) work.project_items.create(project=project, approved_by=self.request.user, checkout=now())
return redirect('item_list', project=project.pk) 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):
class WorkPartSetView(EnsembleMixin, DetailView):
template_name = "library/work_partset.html" template_name = "library/work_partset.html"
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@@ -237,8 +302,10 @@ class WorkPartSetView(EnsembleMixin, DetailView):
sections = [] sections = []
for i, tag in enumerate(parts): for i, tag in enumerate(parts):
part = work.digital_parts.select_related('doc').get(tag=tag) c = int(copies[i])
sections.append((part.doc.upload.path, part.instrument, part.start, part.end, 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) result = extract_and_concat(sections)
@@ -255,7 +322,39 @@ class WorkPartSetView(EnsembleMixin, DetailView):
works = works.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id) works = works.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id)
return works return works
class WorkAddDocumentView(EnsembleMixin, CreateView): 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" template_name = "interface/default_form.html"
model = Document model = Document
fields = ['upload'] fields = ['upload']
@@ -270,45 +369,47 @@ class WorkAddDocumentView(EnsembleMixin, CreateView):
return super().form_invalid(form) return super().form_invalid(form)
def form_valid(self, 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 = form.save(commit=False)
doc.doctype = models.Document.DOCTYPE_MAP.get(ext.lower(), models.Document.DOCTYPE_MISC)
doc.work_id = self.kwargs['pk'] doc.work_id = self.kwargs['pk']
doc.save() doc.save()
# auto tag the document # auto tag the document
name, _ = os.path.splitext(os.path.basename(doc.upload.name)) #name, ext = os.path.splitext(os.path.basename(doc.upload.name))
parts = re.split(r'[^A-Za-z]+', name) if doc.doctype == models.Document.DOCTYPE_PDF:
parts.reverse() inst = auto_tag(orig_name)
for word in parts: if inst:
try: doc.sections.create(tag=inst.abbreviate())
tag = INSTRUMENT_TAGS[word.lower()]
doc.sections.create(tag=tag)
break
except KeyError:
pass
if self.request.headers['Accept'] == 'application/json': if self.request.headers['Accept'] == 'application/json':
filename = os.path.basename(doc.upload.name) filename = os.path.basename(doc.upload.name)
return JsonResponse({ return JsonResponse({
"message": "created", "message": "created",
"id": doc.pk, "id": doc.pk,
"entry": render_to_string('library/document_entry.html', {'doc': doc, 'request': self.request}) "entry": render_to_string('library/document_entry.html',
{'collection': self.collection, 'doc': doc, 'request': self.request}
)
}, status=201) }, status=201)
return redirect('document_annotate', doc.pk) return redirect('document_annotate', self.collection.pk, doc.pk)
class DocumentMixin(object): class DocumentMixin(CollectionMixin):
model = models.Document
def get_queryset(self): def get_queryset(self):
qs = models.Document.objects.select_related('work')
if self.request.is_admin: if self.request.is_admin:
return Document.objects.select_related('work') return qs
return Document.objects.filter(work__ensemble=self.request.ensemble_id).select_related('work') return qs.filter(work__collection=self.collection)
class DocumentDetailView(EnsembleMixin, DocumentMixin, DetailView): class DocumentDetailView(DocumentMixin, DetailView):
pass pass
class DocumentDownloadView(EnsembleMixin, DocumentMixin, SingleObjectMixin, View): class DocumentDownloadView(DocumentMixin, SingleObjectMixin, View):
def get(self, request, **args): def get(self, request, **args):
self.request = request self.request = request
@@ -319,7 +420,7 @@ class DocumentDownloadView(EnsembleMixin, DocumentMixin, SingleObjectMixin, View
#return response #return response
return redirect(self.object.upload.url) return redirect(self.object.upload.url)
class DocumentAnnotateView(EnsembleMixin, DocumentMixin, DetailView): class DocumentAnnotateView(DocumentMixin, DetailView):
template_name = 'library/document_annotate.html' template_name = 'library/document_annotate.html'
def post(self, request, **args): def post(self, request, **args):
@@ -329,11 +430,12 @@ class DocumentAnnotateView(EnsembleMixin, DocumentMixin, DetailView):
data = json.loads(request.body) data = json.loads(request.body)
self.object.sections.all().delete() with transaction.atomic():
for tag, start, end in data: self.object.sections.all().delete()
#pages.sort() for tag, start, end in data:
#end = pages[-1] if len(pages) > 1 else None #pages.sort()
o = self.object.sections.create(tag=tag, start=start, end=end) #end = pages[-1] if len(pages) > 1 else None
o = self.object.sections.create(tag=tag, start=start, end=end)
return HttpResponse(status=204) return HttpResponse(status=204)
@@ -344,24 +446,29 @@ class DocumentAnnotateView(EnsembleMixin, DocumentMixin, DetailView):
for part in data['document'].sections.all(): for part in data['document'].sections.all():
pages.append((part.tag, part.start, part.end)) pages.append((part.tag, part.start, part.end))
data['json_data'] = {'pageTags': pages, 'instruments': dict(INSTRUMENTS)} data['json_data'] = {'pageTags': pages, 'instruments': dict(MUSIC_TAGS)}
return data return data
class DocumentDeleteView(EnsembleMixin, DocumentMixin, DeleteView): class DocumentDeleteView(DocumentMixin, DeleteView):
#def get_template_names(self): #def get_template_names(self):
# return ["interface/default_form.html"] # return ["interface/default_form.html"]
def get_success_url(self): def get_success_url(self):
return resolve_url('work_detail', self.object.work.pk) return resolve_url('work_detail', self.collection.pk, self.object.work_id)
class PartDownloadView(EnsembleMixin, SingleObjectMixin, View): class PartDownloadView(CollectionMixin, SingleObjectMixin, View):
pk_url_kwarg = 'section'
def get(self, request, **args): def get(self, request, **args):
self.request = request self.request = request
self.args = args self.args = args
self.object = self.get_object() 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) result = extract_pages(self.object.doc.upload.path, self.object.doc.work.name, self.object.start, self.object.end)
#download_name = f'{self.object.doc.work.name}_{self.object.instrument}.pdf' #download_name = f'{self.object.doc.work.name}_{self.object.instrument}.pdf'
@@ -370,10 +477,6 @@ class PartDownloadView(EnsembleMixin, SingleObjectMixin, View):
response['Content-Disposition'] = f'inline; filename="{self.args["filename"]}"' response['Content-Disposition'] = f'inline; filename="{self.args["filename"]}"'
return response return response
def get_queryset(self): def get_object(self):
if self.request.is_admin: return Section.objects.filter(doc__work__collection=self.collection).select_related('doc', 'doc__work').get(pk=self.kwargs['section'])
parts = Section.objects.all()
else:
parts = Section.objects.filter(doc__work__collection__allowed_ensembles__ensemble=self.request.ensemble_id)
return parts.select_related('doc', 'doc__work')
+63 -24
View File
@@ -32,22 +32,34 @@ class WorkExportView(EnsembleMixin, WorkMixin, View):
""" """
from library.views import WorkMixin from interface.views import AuthorizedResourceMixin
from interface.views import EnsembleMixin
from rest_framework import routers, serializers, viewsets from rest_framework import routers, serializers, viewsets
from rest_framework.exceptions import APIException
from library.models import Collection, Work, Document, Section from library.models import Collection, Work, Document, Section, WorkMeta
import requests import requests
from io import BytesIO from io import BytesIO
import tempfile import urllib
import shutil import shutil
import os.path
from django.db import transaction from django.db import transaction
from django.core.files.uploadedfile import TemporaryUploadedFile 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 SectionSerializer(serializers.ModelSerializer):
class Meta: class Meta:
@@ -57,25 +69,22 @@ class SectionSerializer(serializers.ModelSerializer):
def to_representation(self, instance): def to_representation(self, instance):
start = instance.start or 0 start = instance.start or 0
end = instance.end or 0 end = instance.end or 0
return f"{instance.tag}:{instance.type}:{start}:{end}" return f"{instance.tag}:{start}:{end}"
def to_internal_value(self, data): def to_internal_value(self, data):
tag, section_type, start, end = data.split(":") tag, start, end = data.split(":")
try: start = int(start)
start = int(start) end = int(end)
except: if start < 1:
start = 0 start = None
try: if end < 1:
end = int(end) end = None
except: return super().to_internal_value({'tag': tag, 'start': start, 'end': end})
end = 0
return super().to_internal_value({'tag': tag, 'type': int(section_type), 'start': start, 'end': end})
class DocumentSerializer(serializers.ModelSerializer): class DocumentSerializer(serializers.ModelSerializer):
upload = serializers.URLField() upload = serializers.URLField()
sections = SectionSerializer(many=True) sections = SectionSerializer(many=True)
#doctype = serializers.CharField(source='get_doctype_display')
#def to_internal_value(self, data): #def to_internal_value(self, data):
# r = requests.get(data['upload'], stream=True) # r = requests.get(data['upload'], stream=True)
@@ -87,8 +96,7 @@ class DocumentSerializer(serializers.ModelSerializer):
def to_representation(self, instance): def to_representation(self, instance):
data = super().to_representation(instance) data = super().to_representation(instance)
if data['upload'][0] == '/': data['upload'] = instance.upload.url
data['upload'] = 'http://localhost:8000' + (data['upload'])
return data return data
def create(self, validated_data): def create(self, validated_data):
@@ -111,6 +119,7 @@ class DocumentSerializer(serializers.ModelSerializer):
class WorkSerializer(serializers.ModelSerializer): class WorkSerializer(serializers.ModelSerializer):
docs = DocumentSerializer(many=True) docs = DocumentSerializer(many=True)
meta_info = WorkMetaSerializer(many=True)
class Meta: class Meta:
model = Work model = Work
@@ -119,13 +128,19 @@ class WorkSerializer(serializers.ModelSerializer):
def create(self, validated): def create(self, validated):
with transaction.atomic(): with transaction.atomic():
docs = validated.pop('docs', []) docs = validated.pop('docs', [])
meta = validated.pop('meta_info', [])
work = Work.objects.create(**validated) work = Work.objects.create(**validated)
for d in docs: for d in docs:
sections = d.pop('sections', []) sections = d.pop('sections', [])
url = urllib.parse.urlparse(d['upload'])
filename = os.path.basename(url.path)
r = requests.get(d['upload'], stream=True) r = requests.get(d['upload'], stream=True)
f = TemporaryUploadedFile(d['upload'], r.headers['content-type'], r.headers['content-length'], r.encoding) 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) shutil.copyfileobj(r.raw, f.file)
r.close() r.close()
d['upload'] = f d['upload'] = f
@@ -134,28 +149,52 @@ class WorkSerializer(serializers.ModelSerializer):
for s in sections: for s in sections:
Section.objects.create(doc_id=doc.pk, **s) Section.objects.create(doc_id=doc.pk, **s)
for m in meta:
WorkMeta.objects.create(work_id=work.pk, **m)
return work return work
class CollectionSerializer(serializers.Serializer): class CollectionSerializer(serializers.Serializer):
works = WorkSerializer(many=True) 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 from rest_framework import generics
class CollectionExportView(generics.RetrieveAPIView): class CollectionExportView(AuthorizedResourceMixin, generics.RetrieveAPIView):
serializer_class = CollectionSerializer serializer_class = CollectionSerializer
def get_queryset(self): def get_queryset(self):
if self.request.user.is_superuser:
return Collection.objects.all()
return Collection.objects.filter(administrators=self.request.user) return Collection.objects.filter(administrators=self.request.user)
class WorkExportView(generics.RetrieveAPIView): class WorkExportView(AuthorizedResourceMixin, generics.RetrieveAPIView):
serializer_class = WorkSerializer serializer_class = WorkSerializer
def get_queryset(self): def get_queryset(self):
return Work.objects.filter(collection__administrators=self.request.user) 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(generics.CreateAPIView): class WorkImportView(AuthorizedResourceMixin, generics.CreateAPIView):
serializer_class = WorkSerializer serializer_class = WorkSerializer
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(collection_id=self.kwargs['pk']) 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'])
+16 -1
View File
@@ -1,14 +1,29 @@
asgiref==3.4.1 asgiref==3.4.1
boto3==1.18.34 boto3==1.18.34
botocore==1.21.34 botocore==1.21.34
certifi==2022.12.7
charset-normalizer==2.1.1
crispy-bulma==0.8.0 crispy-bulma==0.8.0
Django==3.2.7 Django==3.2.7
django-byostorage @ git+https://gitea.tfconsulting.com.au/tris/django-byostorage.git@4d87edd995a2b0c154298bd00b3fe0abb78ba72c #-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-crispy-forms==1.14.0
django-markdown2==0.3.1 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 markdown2==2.4.1
pydantic==1.10.2
pydantic-django==0.1.1
python-dateutil==2.8.2 python-dateutil==2.8.2
pytz==2021.1 pytz==2021.1
requests==2.28.1
s3transfer==0.5.2
six==1.16.0 six==1.16.0
sqlparse==0.4.1 sqlparse==0.4.1
typing_extensions==4.4.0
urllib3==1.26.6 urllib3==1.26.6