Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04bf1ab1f3 | ||
|
|
fb700b15d6 | ||
|
|
0bd2596ebc | ||
|
|
1f0a336ed4 | ||
|
|
220163bca2 | ||
|
|
d1328ae1b1 | ||
|
|
69f747d5b5 | ||
|
|
2a6554471d | ||
|
|
6edc612ed6 | ||
|
|
f441940e8c | ||
|
|
b1eaf9c7bc | ||
|
|
a892b0bc41 | ||
|
|
f1757be96e | ||
|
|
95ee92cef6 | ||
|
|
b11cfcd6ea | ||
|
|
6e1b26440f | ||
|
|
4268b66b27 | ||
|
|
f22961ad44 | ||
|
|
caeee16657 | ||
|
|
4344cb978b | ||
|
|
a3d8cf0a21 | ||
|
|
70aa7ae3a3 | ||
|
|
f840ee3d8b | ||
|
|
7d4f959146 | ||
|
|
1cb00ebc0b | ||
|
|
928173976b | ||
|
|
d7892d355d | ||
|
|
dbbfa79f10 | ||
|
|
f49ff0fd0e | ||
|
|
6ec5808275 | ||
|
|
948e9deb54 | ||
|
|
75de40f2bd | ||
|
|
295999eaef |
+11
-9
@@ -1,13 +1,15 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
db.sqlite3
|
||||
*.sqlite3
|
||||
credentials.json
|
||||
credentials
|
||||
local_settings.py
|
||||
env
|
||||
old
|
||||
test.*
|
||||
static
|
||||
teststore
|
||||
cache
|
||||
local_storage
|
||||
media
|
||||
.coverage
|
||||
/env
|
||||
/data
|
||||
/old
|
||||
/static
|
||||
/teststore
|
||||
/cache
|
||||
/local_storage
|
||||
/media
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
PYTHON=env/bin/python
|
||||
DROPZONE=5.7.0
|
||||
|
||||
test:
|
||||
coverage run --include "app/*" --omit "*/migrations/*" app/manage.py test app
|
||||
coverage html
|
||||
coverage report
|
||||
|
||||
dev-setup:
|
||||
env/bin/pip install -r requirements.txt
|
||||
env/bin/pip install -r dev-requirements.txt
|
||||
|
||||
@@ -25,14 +25,16 @@ class BaseForm(forms.Form):
|
||||
|
||||
class ProjectForm(forms.ModelForm, BaseForm):
|
||||
|
||||
|
||||
class Meta:
|
||||
model = models.Project
|
||||
fields = ['name', 'description', 'event_date']
|
||||
fields = ['name', 'description', 'modules', 'event_date']
|
||||
#widgets = {
|
||||
# 'event_date': forms.DateTimeInput(attrs={'type': 'date'})
|
||||
#}
|
||||
|
||||
modules = forms.MultipleChoiceField(choices=[ (x, x.title()) for x in models.settings.POLYPHONIC_MODULES ],
|
||||
widget=forms.CheckboxSelectMultiple, required=False)
|
||||
|
||||
class ResourceForm(forms.ModelForm, BaseForm):
|
||||
|
||||
class Meta:
|
||||
|
||||
+40
-1
@@ -41,6 +41,19 @@ def rough_date(d):
|
||||
def generate_code(length=9):
|
||||
return "".join([ random.choice('0123456789') for _ in range(length) ])
|
||||
|
||||
class EnsembleQuerySet(models.QuerySet):
|
||||
|
||||
def for_user(self, user, ensemble_keys=[], project_keys=[]):
|
||||
if user.is_superuser:
|
||||
return self
|
||||
|
||||
f = models.Q(slug__in=ensemble_keys) | models.Q(projects__in=project_keys)
|
||||
|
||||
if user.is_authenticated:
|
||||
f |= models.Q(admins=user.pk)
|
||||
|
||||
return self.filter(f).distinct()
|
||||
|
||||
class Ensemble(models.Model):
|
||||
''' A group that plays together
|
||||
|
||||
@@ -57,11 +70,13 @@ class Ensemble(models.Model):
|
||||
nonce = models.SmallIntegerField(default=1,
|
||||
help_text="Increment this to reset the authentication links")
|
||||
|
||||
objects = EnsembleQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ('slug', )
|
||||
|
||||
def active_projects(self):
|
||||
return self.projects.filter(active=True, event_date__gte=timezone.now())
|
||||
return self.projects.active().current()
|
||||
|
||||
def has_admin(self, user):
|
||||
if not user.is_authenticated:
|
||||
@@ -84,6 +99,23 @@ class Ensemble(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class ProjectQuerySet(models.QuerySet):
|
||||
def current(self):
|
||||
return self.filter(models.Q(event_date__gte=(timezone.now()-timezone.timedelta(7))) | models.Q(event_date=None))
|
||||
|
||||
def active(self):
|
||||
return self.filter(active=True)
|
||||
|
||||
def for_user(self, user, project_keys=[], ensemble_keys=[]):
|
||||
if user.is_superuser:
|
||||
return self
|
||||
|
||||
f = models.Q(pk__in=project_keys) | models.Q(ensemble__slug__in=ensemble_keys)
|
||||
|
||||
if user.is_authenticated:
|
||||
f |= models.Q(ensemble__admins=user.pk)
|
||||
|
||||
return self.filter(f)
|
||||
|
||||
class Project(models.Model):
|
||||
''' A Project linked to an ensemble
|
||||
@@ -98,6 +130,8 @@ class Project(models.Model):
|
||||
nonce = models.SmallIntegerField(default=1,
|
||||
help_text="Increment this to reset the authentication links")
|
||||
|
||||
objects = ProjectQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['active', 'event_date']
|
||||
|
||||
@@ -113,6 +147,8 @@ class Project(models.Model):
|
||||
|
||||
@property
|
||||
def rough_date(self):
|
||||
if not self.event_date:
|
||||
return "No timescale"
|
||||
in_past, s = rough_date(self.event_date)
|
||||
if in_past:
|
||||
return f"{s} ago"
|
||||
@@ -143,6 +179,9 @@ class Module(models.Model):
|
||||
name = models.SlugField(max_length=20, choices=[ (x, x.title()) for x in settings.POLYPHONIC_MODULES ])
|
||||
project = models.ForeignKey(Project, related_name="modules", on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def resource_key(resource, filename):
|
||||
return f'{resource.project.folder}/resources/{filename}'
|
||||
|
||||
|
||||
Binary file not shown.
|
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,17 +9,22 @@
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="{% static 'interface/css/polyphonic.css' %}"></link>
|
||||
<script src="{% static 'interface/js/interface.js' %}"></script>
|
||||
<script src="//unpkg.com/alpinejs" defer></script>
|
||||
<script src="//cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js" defer></script>
|
||||
<script src="//kit.fontawesome.com/c837098e5b.js" crossorigin="anonymous" defer></script>
|
||||
<title>{% block title %}Polyphonic{% endblock %}</title>
|
||||
{% block media %}{% endblock %}
|
||||
<style>{% block style %}{% endblock %}</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
{% block navigation %}
|
||||
<nav class="navbar" role="navigation">
|
||||
<div class="navbar-brand has-text-primary">
|
||||
<a class="navbar-item" href="/">
|
||||
<span class="icon fancy is-size-2 mx-4"><i class="fas fa-random"></i></span>
|
||||
<span class="fancy is-size-2">Polyphonic</span>
|
||||
<span class="icon fancy is-size-2 is-size-4-mobile mx-4"><i class="fas fa-random"></i></span>
|
||||
<span class="fancy is-size-2 is-size-4-mobile">Polyphonic</span>
|
||||
</a>
|
||||
<span class="navbar-item is-hidden-mobile fancy is-size-5">Musical Ensemble Manager</span>
|
||||
|
||||
@@ -34,7 +39,7 @@
|
||||
<div id="navbarMain" class="navbar-menu">
|
||||
|
||||
<div class="navbar-end">
|
||||
<span class="navbar-item is-size-4">{% firstof ensemble project.ensemble %}</span>
|
||||
<span class="navbar-item is-size-5-touch is-size-4-tablet">{% firstof ensemble project.ensemble %}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -47,8 +52,6 @@
|
||||
{% endblock %}
|
||||
|
||||
<!-- late load scripts -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
|
||||
<script src="https://kit.fontawesome.com/c837098e5b.js" crossorigin="anonymous"></script>
|
||||
{% block scripts %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
|
||||
@@ -21,14 +21,16 @@
|
||||
|
||||
{% block page %}
|
||||
<h3 class="title">Projects for {{ensemble.name }}</h3>
|
||||
<p>{{ ensemble.details|markdown }}</p>
|
||||
<div class="content">
|
||||
{{ ensemble.details|markdown }}
|
||||
</div>
|
||||
|
||||
<h4>Administrators</h4>
|
||||
<ul>
|
||||
<div class="block">
|
||||
Contacts:
|
||||
{% 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 %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% include 'interface/project_items.html' %}
|
||||
|
||||
@@ -39,11 +41,10 @@
|
||||
<p class="card-header-title">Admin Details</p>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
|
||||
<p>
|
||||
Joining instructions for participants<br/><br/>
|
||||
URL: <a href="{{ ensemble_link }}">{{ ensemble_link }}</a><br/>
|
||||
</p>
|
||||
<ul>
|
||||
<li><a href="{{ ensemble_link }}">Ensemble Sharing Link</a></li>
|
||||
<li><a href="{% url 'project_create' ensemble.pk %}">Add a new project</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,22 +19,18 @@
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="media">
|
||||
|
||||
<div class="media-left">
|
||||
<figure class="image is-48x48">
|
||||
<img src="https://bulma.io/images/placeholders/96x96.png" alt="Placeholder image">
|
||||
<img src="https://www.gravatar.com/avatar/{{ ensemble.email }}?d=mp" alt="Placeholder image">
|
||||
</figure>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<div class="media-content" style="min-height: 60px">
|
||||
<a href="{% url 'ensemble_detail' ensemble.slug %}">
|
||||
<p class="title is-4">{{ ensemble.name }}</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if ensemble.details %}
|
||||
<div class="content">
|
||||
{{ ensemble.details | markdown }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
{% with projects=ensemble.active_projects.count %}
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
||||
|
||||
<div class="columns">
|
||||
<div class="columns mx-2">
|
||||
<div class="column is-narrow is-hidden-touch" id="projectMenu">
|
||||
<div style="margin: auto 2em;">
|
||||
<aside class="menu">
|
||||
@@ -14,7 +12,7 @@
|
||||
<li><a href="{% url 'ensemble_list' %}">Ensembles</a></li>
|
||||
<li><a href="{% url 'project_list' %}">Projects</a></li>
|
||||
{% if request.user.is_authenticated %}
|
||||
<li><a href="{% url 'work_list' %}">Library</a></li>
|
||||
<!--li><a href="{% url 'work_list' %}">Library</a></li-->
|
||||
<li><a href="{% url 'collection_list' %}">Collections</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
@@ -27,13 +25,20 @@
|
||||
<li><a class="nav-link" href="{% url 'item_list' project=project.pk %}">My Music</a></li>
|
||||
{% endif %}
|
||||
{% for page in project.wiki_pages.all %}
|
||||
<li><a class="nav-link
|
||||
<li><a class="nav-link"
|
||||
href="{% url 'wiki' project=project.id pk=page.id %}">{{ page.title }}</a></li>
|
||||
{% endfor %}
|
||||
<li><a role="tab" href="{% url 'resource_list' project=project.pk %}">Resources</a></li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if collection %}
|
||||
<p class="menu-label">Collection</p>
|
||||
<ul class="menu-list">
|
||||
<li><a role="tab" href="{% url 'collection_work_list' collection.pk %}">Work List</a></li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<p class="menu-label">Admin</p>
|
||||
<ul class="menu-list">
|
||||
{% if request.user.is_staff %}
|
||||
@@ -76,9 +81,9 @@
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
<section class="section">
|
||||
<div class="block">
|
||||
{% if request.is_admin %}
|
||||
<div class="admin-tools is-pulled-right">
|
||||
<div class="admin-tools is-pulled-right is-hidden-mobile">
|
||||
{% block admin %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
@@ -87,7 +92,7 @@
|
||||
{% block page %}
|
||||
No content
|
||||
{% endblock %}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,60 +14,71 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block page %}
|
||||
<div class="box">
|
||||
{% if project.event_date %}
|
||||
<h3 class="subtitle is-centered">
|
||||
<b>{{ project.event_date|date:"l jS F Y, g:i A" }}</b>
|
||||
{% if project.has_happened %}
|
||||
({{ project.event_date|roughtimesince }} ago)
|
||||
{% else %}
|
||||
(in {{ project.event_date|roughtimeuntil }})
|
||||
{% endif %}
|
||||
</h3>
|
||||
{% endif %}
|
||||
|
||||
<div class="block">
|
||||
{% if project.description %}
|
||||
<p class="content">{{ project.description|markdown }}</p>
|
||||
{% else %}
|
||||
<p>No description</p>
|
||||
<div class="columns is-multiline">
|
||||
<div class="column ">
|
||||
<div class="box">
|
||||
|
||||
{% if project.event_date %}
|
||||
<h3 class="subtitle is-centered">
|
||||
<b>{{ project.event_date|date:"l jS F Y, g:i A" }}</b>
|
||||
{% if project.has_happened %}
|
||||
({{ project.event_date|roughtimesince }} ago)
|
||||
{% else %}
|
||||
(in {{ project.event_date|roughtimeuntil }})
|
||||
{% endif %}
|
||||
</h3>
|
||||
{% endif %}
|
||||
|
||||
<div class="block">
|
||||
{% if project.description %}
|
||||
<p class="content">{{ project.description|markdown }}</p>
|
||||
{% else %}
|
||||
<p>No description</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if project.owner %}
|
||||
<div class="block">
|
||||
{% if project.owner.email %}
|
||||
The project owner is <a href="mailto:{{ project.owner.email }}">{{ project.owner }}</a>
|
||||
{% else %}
|
||||
The project owner is {{ project.owner }}.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{% if 'library' in modules %}
|
||||
<div class="block">
|
||||
<div class="column is-one-third">
|
||||
{% include 'library/project_detail.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if 'submission' in modules %}
|
||||
<div class="block">
|
||||
<div class="column">
|
||||
{% include 'submissions/project_detail.html' %}
|
||||
</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>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{% if request.is_admin %}
|
||||
<div class="box">
|
||||
<h3 class="subtitle">Admin Actions</h3>
|
||||
<ul>
|
||||
<li><a href="{{ project_link }}">Project Link</a></li>
|
||||
{% if 'library' in modules %}
|
||||
<li><a href="{% url 'item_list_manage' project=project.pk %}">Manage items</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!--
|
||||
<div>
|
||||
<a href="{% url 'forget_resource' 'project' project.pk %}">Forget this project</a>
|
||||
</div>
|
||||
-->
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -12,14 +12,17 @@
|
||||
<p class="card-header-icon" style="color: black;">{{ project.rough_date }}</p>
|
||||
</header>
|
||||
</a>
|
||||
<div class="card-content">
|
||||
<div class="content" style="height: 100px; overflow: hidden">
|
||||
<div class="card-content" style="height: 100px; overflow: hidden">
|
||||
<div class="content">
|
||||
{{ project.description | markdown }}
|
||||
{% if not ensemble %}
|
||||
<div class="has-text-centered"><i><small>With {{ project.ensemble }}</small></i></div>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block page %}
|
||||
<h3 class="subtitle">Resources</h3>
|
||||
<div class="columns is-multiline">
|
||||
{% for resource in object_list %}
|
||||
{% with download=resource.file.url %}
|
||||
@@ -51,7 +52,7 @@
|
||||
{% endwith %}
|
||||
{% empty %}
|
||||
<div class="column">
|
||||
<p>There are resources for this project</p>
|
||||
<p>There are no resources for this project</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
@@ -2,85 +2,103 @@ from django.test import TestCase, Client
|
||||
|
||||
from interface import models, utils
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
class AccessTestCase(TestCase):
|
||||
from . import AccessTestCase
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
class InterfaceAccessTestCase(AccessTestCase):
|
||||
|
||||
admin = User.objects.create_user(username='admin', password='foobar', is_superuser=True, is_staff=True)
|
||||
homer = User.objects.create_user(username='homer', password='maggie')
|
||||
USERS = (
|
||||
{'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')
|
||||
b_sharps.admins.add(homer)
|
||||
bleeding_gums = models.Ensemble.objects.create(name='Lisa and the Bleeding Gums', slug='bleeding-gums')
|
||||
party_posse = models.Ensemble.objects.create(name="Party Posse", slug='party-posse')
|
||||
PROJECTS = (
|
||||
{'name': 'Baker St', 'ensemble': 'bleeding-gums', 'when': -12},
|
||||
{'name': 'Navy Recruitment Day', 'ensemble': 'party-posse', 'when': 6},
|
||||
{'name': 'Barbershop Contest', 'ensemble': 'be-sharps', 'when': 28},
|
||||
{'name': 'Open Mic Night', 'ensemble': 'bleeding-gums', 'when': 1 },
|
||||
{'name': 'Current Repertoire', 'ensemble': 'be-sharps'},
|
||||
)
|
||||
|
||||
bleeding_gums.projects.create(name='Baker St', event_date=now-timedelta(days=12))
|
||||
party_posse.projects.create(name='Navy Recruitment Day', event_date=now+timedelta(days=6))
|
||||
b_sharps.projects.create(name='Baby on Board', event_date=now+timedelta(days=28))
|
||||
bleeding_gums.projects.create(name='Open Mic Night', event_date=now+timedelta(hours=1))
|
||||
PROTECTED_URLS = (
|
||||
'/ensembles/be-sharps',
|
||||
'/ensembles/be-sharps/new-project',
|
||||
|
||||
'/projects/3',
|
||||
'/projects/3/resources',
|
||||
'/projects/3/resources/add',
|
||||
|
||||
def test_admin_ensembles(self):
|
||||
self.client.post('/login', {'username': 'admin', 'password': 'foobar'})
|
||||
'/admin/interface/ensemble/',
|
||||
'/admin/interface/project/',
|
||||
'/admin/interface/resource/',
|
||||
'/admin/interface/wikipage/',
|
||||
)
|
||||
|
||||
def test_bad_login(self):
|
||||
with self.assertRaisesMessage(self.failureException, 'Failed to login as admin'):
|
||||
self.login('admin', 'admin')
|
||||
|
||||
def test_superuser_ensembles(self):
|
||||
self.login('admin', 'secret')
|
||||
response = self.client.get('/ensembles')
|
||||
self.assertObjectList(response, ['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')
|
||||
|
||||
def test_admin_ensemble_permissions(self):
|
||||
self.client.post('/login', {'username': 'admin', 'password': 'foobar'})
|
||||
def test_superuser_ensemble_permissions(self):
|
||||
self.login('admin', 'secret')
|
||||
response = self.client.get('/ensembles/party-posse')
|
||||
self.assertTrue(response.context['request'].is_admin)
|
||||
self.assertContains(response, "Add project")
|
||||
self.assertAccess({
|
||||
'/ensembles/the-be-sharps': True,
|
||||
'/ensembles/be-sharps': True,
|
||||
'/ensembles/bleeding-gums': True,
|
||||
'/ensembles/party-posse': True,
|
||||
'/ensembles/unknown': False,
|
||||
'/ensembles/the-be-sharps/new-project': True,
|
||||
'/ensembles/be-sharps/new-project': True,
|
||||
})
|
||||
|
||||
def test_admin_projects(self):
|
||||
self.client.post('/login', {'username': 'admin', 'password': 'foobar'})
|
||||
def test_superuser_projects(self):
|
||||
self.login('admin', 'secret')
|
||||
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?inactive'), ['Open Mic Night', 'Baker St'])
|
||||
|
||||
|
||||
def test_user_ensembles(self):
|
||||
self.client.post('/login', {'username': 'homer', 'password': 'maggie'})
|
||||
self.login('homer', 'maggie')
|
||||
response = self.client.get('/ensembles')
|
||||
self.assertObjectList(response, ['The Be Sharps'])
|
||||
|
||||
self.assertNotContains(response, 'Django Admin')
|
||||
|
||||
def test_user_ensemble_permissions(self):
|
||||
self.client.post('/login', {'username': 'homer', 'password': 'maggie'})
|
||||
response = self.client.get('/ensembles/the-be-sharps')
|
||||
self.login('homer', 'maggie')
|
||||
response = self.client.get('/ensembles/be-sharps')
|
||||
self.assertTrue(response.context['request'].is_admin)
|
||||
self.assertContains(response, "Add project")
|
||||
self.assertContains(response, 'Show all')
|
||||
self.assertAccess({
|
||||
'/ensembles/the-be-sharps': True,
|
||||
'/ensembles/be-sharps': True,
|
||||
'/ensembles/bleeding-gums': False,
|
||||
'/ensembles/party-posse': False,
|
||||
'/ensembles/the-be-sharps/new-project': True,
|
||||
'/ensembles/be-sharps/new-project': True,
|
||||
'/ensembles/party-posse/new-project': False,
|
||||
})
|
||||
|
||||
self.authorize(models.Ensemble, slug='bleeding-gums')
|
||||
self.assertAccess({
|
||||
'/ensembles/the-be-sharps': True,
|
||||
'/ensembles/be-sharps': True,
|
||||
'/ensembles/bleeding-gums': True,
|
||||
'/ensembles/party-posse': False,
|
||||
'/ensembles/the-be-sharps/new-project': True,
|
||||
'/ensembles/be-sharps/new-project': True,
|
||||
'/ensembles/party-posse/new-project': False,
|
||||
})
|
||||
response = self.client.get('/ensembles/bleeding-gums')
|
||||
@@ -89,9 +107,9 @@ class AccessTestCase(TestCase):
|
||||
self.assertNotContains(response, 'Show all')
|
||||
|
||||
def test_user_projects(self):
|
||||
self.client.post('/login', {'username': 'homer', 'password': 'maggie'})
|
||||
self.login('homer', 'maggie')
|
||||
response = self.client.get('/projects')
|
||||
self.assertObjectList(response, ['Baby on Board'])
|
||||
self.assertObjectList(response, ['Current Repertoire', 'Barbershop Contest'])
|
||||
response = self.client.get('/projects/3')
|
||||
self.assertTrue(response.context['request'].is_admin)
|
||||
|
||||
@@ -107,7 +125,7 @@ class AccessTestCase(TestCase):
|
||||
|
||||
self.authorize(models.Project, pk=4)
|
||||
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')
|
||||
self.assertFalse(response.context['request'].is_admin)
|
||||
|
||||
@@ -125,7 +143,7 @@ class AccessTestCase(TestCase):
|
||||
self.assertObjectList(response, ['Party Posse'])
|
||||
|
||||
self.assertAccess({
|
||||
'/ensembles/the-be-sharps': False,
|
||||
'/ensembles/be-sharps': False,
|
||||
'/ensembles/party-posse': True,
|
||||
'/ensembles/bleeding-gums': False,
|
||||
'/ensembles/unknown': False,
|
||||
@@ -136,7 +154,7 @@ class AccessTestCase(TestCase):
|
||||
def test_anon_authorized_project(self):
|
||||
self.authorize(models.Project, pk=4)
|
||||
self.assertObjectList(self.client.get('/projects'), ['Open Mic Night'])
|
||||
self.assertObjectList(self.client.get('/ensembles'), ['Lisa and the Bleeding Gums'])
|
||||
self.assertObjectList(self.client.get('/ensembles'), ['Lisa & the Bleeding Gums'])
|
||||
|
||||
self.assertAccess({
|
||||
'/projects/4': True,
|
||||
@@ -148,92 +166,18 @@ class AccessTestCase(TestCase):
|
||||
def test_anon_permission_denied(self):
|
||||
self.assertAccess({
|
||||
'/ensembles': True,
|
||||
'/ensembles/the-be-sharps': False,
|
||||
'/ensembles/be-sharps': False,
|
||||
'/ensembles/party-posse': False,
|
||||
'/ensembles/bleeding-gums': False,
|
||||
'/ensembles/unknown': False,
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
"""
|
||||
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)
|
||||
"""
|
||||
def test_anon_deauthorize_project(self):
|
||||
self.authorize(models.Project, pk=4)
|
||||
self.assertAccess({
|
||||
'/projects/4': True
|
||||
})
|
||||
models.Project.objects.filter(pk=4).update(nonce=2)
|
||||
self.assertAccess({
|
||||
'/projects/4': False
|
||||
})
|
||||
+39
-27
@@ -35,7 +35,7 @@ class AuthorizedResourceMixin(object):
|
||||
def is_authorized(self):
|
||||
"By default check if superuser or a signed request"
|
||||
if self.request.is_admin:
|
||||
logger.debug("is_authorized: superuser")
|
||||
#logger.debug("is_authorized: superuser")
|
||||
return True
|
||||
|
||||
if 'sig' in self.request.GET:
|
||||
@@ -51,12 +51,12 @@ class AuthorizedResourceMixin(object):
|
||||
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)
|
||||
#logger.debug("is_authorized_key: %s %s not in session", resource, key)
|
||||
return False
|
||||
if auth == current:
|
||||
return True
|
||||
|
||||
logger.info("Authorisation revoked")
|
||||
#logger.info("Authorisation revoked")
|
||||
self.del_authorized_key(resource, key)
|
||||
return False
|
||||
|
||||
@@ -196,19 +196,22 @@ class EnsembleListView(AuthorizedResourceMixin, ListView):
|
||||
return True
|
||||
|
||||
def get_queryset(self):
|
||||
ensembles = models.Ensemble.objects.all()
|
||||
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
|
||||
#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())
|
||||
#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)
|
||||
#if self.request.user.is_authenticated:
|
||||
# f |= Q(admins=self.request.user.pk)
|
||||
|
||||
return ensembles.filter(f).distinct()
|
||||
#return ensembles.filter(f).distinct()
|
||||
|
||||
class EnsembleDetailView(EnsembleMixin, DetailView):
|
||||
|
||||
@@ -232,12 +235,11 @@ class EnsembleDetailView(EnsembleMixin, DetailView):
|
||||
if inactive:
|
||||
projects = projects.order_by('-pk')
|
||||
else:
|
||||
projects = projects.filter(active=True, event_date__gte=timezone.now()-timezone.timedelta(7))
|
||||
projects = projects.active().current()
|
||||
|
||||
data['inactive'] = inactive
|
||||
data['object_list'] = projects
|
||||
if self.request.is_admin:
|
||||
#data['ensemble_link'] = signed_url(f'{self.request.path}?nonce={self.ensemble.nonce}')
|
||||
data['ensemble_link'] = self.request.path + "?auth=" + self.ensemble.auth()
|
||||
return data
|
||||
|
||||
@@ -254,20 +256,12 @@ class ProjectListView(ProjectMixin, ListView):
|
||||
return True
|
||||
|
||||
def get_project_queryset(self):
|
||||
projects = models.Project.objects.select_related('ensemble')
|
||||
if self.request.is_admin:
|
||||
return projects
|
||||
|
||||
f = Q(pk__in=self.get_authorized_keys('project').keys()) | Q(ensemble__slug__in=self.get_authorized_keys('ensemble').keys())
|
||||
|
||||
if self.request.user.is_authenticated:
|
||||
f |= Q(ensemble__admins=self.request.user.pk)
|
||||
|
||||
return projects.filter(f)
|
||||
return models.Project.objects.for_user(self.request.user,
|
||||
self.get_authorized_keys('project'),
|
||||
self.get_authorized_keys('ensemble'))
|
||||
|
||||
def get_queryset(self):
|
||||
qs = self.get_project_queryset()
|
||||
return qs.filter(active=True, event_date__gte=timezone.now()-timezone.timedelta(7))
|
||||
return self.get_project_queryset().current().active()
|
||||
|
||||
class ProjectCreateView(EnsembleMixin, CreateView):
|
||||
admin_required = True
|
||||
@@ -276,11 +270,16 @@ class ProjectCreateView(EnsembleMixin, CreateView):
|
||||
title = "Add a new project"
|
||||
form_class = forms.ProjectForm
|
||||
|
||||
|
||||
def form_valid(self, form):
|
||||
self.object = form.save(commit=False)
|
||||
self.object.ensemble = self.ensemble
|
||||
self.object.owner = self.request.user
|
||||
self.object.save()
|
||||
|
||||
for module in form.cleaned_data['modules']:
|
||||
self.object.modules.create(name=module)
|
||||
|
||||
return redirect('project_detail', project=self.object.pk)
|
||||
|
||||
class ProjectDetailView(ProjectMixin, DetailView):
|
||||
@@ -312,12 +311,25 @@ class ProjectUpdateView(ProjectMixin, UpdateView):
|
||||
def get_object(self):
|
||||
return self.project
|
||||
|
||||
def get_initial(self):
|
||||
data = super().get_initial()
|
||||
data['modules'] = self.object.active_modules
|
||||
print(data)
|
||||
return data
|
||||
|
||||
def form_valid(self, form):
|
||||
self.object = form.save()
|
||||
current = set(self.object.active_modules)
|
||||
desired = set(form.cleaned_data['modules'])
|
||||
self.object.modules.exclude(name__in=desired).delete()
|
||||
for module in desired-current:
|
||||
self.object.modules.create(name=module)
|
||||
return redirect('project_detail', self.kwargs['project'])
|
||||
|
||||
@property
|
||||
def cancel_url(self):
|
||||
return 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
|
||||
#class ProjectMakefileView(EnsembleMixin, DetailView):
|
||||
|
||||
@@ -34,7 +34,7 @@ admin.site.register(models.Work, WorkAdmin)
|
||||
|
||||
class SectionInline(admin.TabularInline):
|
||||
model = models.Section
|
||||
fields = ['type', 'tag', 'ordinal', 'start', 'end', 'page']
|
||||
fields = ['tag', 'start', 'end', 'page']
|
||||
|
||||
class DocumentAdmin(admin.ModelAdmin):
|
||||
list_display = ['work', '__str__']
|
||||
|
||||
@@ -5,7 +5,6 @@ from interface.forms import BaseForm
|
||||
|
||||
|
||||
class WorkCreateForm(forms.ModelForm, BaseForm):
|
||||
#uploads = forms.FileField(label="PDFs to upload", widget=forms.ClearableFileInput(attrs={'multiple': True}), required=False)
|
||||
|
||||
class Meta:
|
||||
model = Work
|
||||
@@ -26,5 +25,9 @@ class PlaylistAddForm(forms.Form):
|
||||
def save(self):
|
||||
self.instance.works.add(self.cleaned_data['work'])
|
||||
|
||||
class ProjectSelectForm(forms.Form):
|
||||
project = forms.ModelChoiceField(queryset=Project.objects.all())
|
||||
class ProjectEnsembleChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj):
|
||||
return f"{obj.ensemble.name} - {obj.name}"
|
||||
|
||||
class ProjectSelectForm(BaseForm):
|
||||
project = ProjectEnsembleChoiceField(queryset=Project.objects.all())
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
+59
-49
@@ -1,5 +1,6 @@
|
||||
from os import SCHED_OTHER
|
||||
from django.conf import settings
|
||||
from django.shortcuts import resolve_url
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
from django.utils.timezone import now
|
||||
@@ -8,10 +9,13 @@ from django.core.files.storage import get_storage_class
|
||||
from django.db.models import Q, Count, Min, Max
|
||||
|
||||
import re
|
||||
import os.path
|
||||
|
||||
from byostorage.user import BYOStorage
|
||||
from byostorage.cached import CachedStorage
|
||||
from .imslp import Instrument
|
||||
|
||||
from library.music_tags import MusicTag
|
||||
from interface.utils import sign_data
|
||||
|
||||
import logging
|
||||
|
||||
@@ -33,15 +37,13 @@ class Orchestration(models.Model):
|
||||
Stores a list of instrument codes as a single entry (space delimited).
|
||||
Can be global or ensemble specific
|
||||
"""
|
||||
# TODO: change ensemble to collection
|
||||
#ensemble = models.ForeignKey('interface.Ensemble', on_delete=models.CASCADE, related_name="orchestrations", null=True, blank=True)
|
||||
collection = models.ForeignKey('Collection', on_delete=models.CASCADE, related_name="custom_orchestrations", null=True, blank=True)
|
||||
name = models.CharField(max_length=100)
|
||||
instruments = models.TextField()
|
||||
|
||||
def as_list(self):
|
||||
tags = [ t.strip() for t in self.instruments.split(' ') ]
|
||||
return [ (t, Instrument.from_tag(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 ]
|
||||
@@ -101,6 +103,8 @@ class Collection(models.Model):
|
||||
help_text="User storage for documents")
|
||||
notes = models.TextField(blank=True,
|
||||
help_text="Publicly visible notes about collection and loans policy (markdown format)")
|
||||
nonce = models.SmallIntegerField(default=1,
|
||||
help_text="Increment this to reset the authentication links")
|
||||
|
||||
def meta(self, name):
|
||||
items = WorkMeta.objects.filter(work__collection=self.pk, name=name).values_list('value', flat=True).distinct()
|
||||
@@ -121,6 +125,12 @@ class Collection(models.Model):
|
||||
return True
|
||||
return user.pk in self.administrators.values_list('pk', flat=True)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return resolve_url('collection_work_list', self.pk)
|
||||
|
||||
def auth(self):
|
||||
return sign_data(f'{self.pk}-{self.nonce}', 12)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -171,14 +181,14 @@ class Work(models.Model):
|
||||
parent = models.ForeignKey('Work', null=True, blank=True, on_delete=models.SET_NULL, related_name="related_works",
|
||||
help_text="Arrangement of another work or part of an anthology")
|
||||
composer = models.CharField(max_length=255, default='Anon',
|
||||
help_text="Surname, Initials")
|
||||
help_text="Composer or compilation editor. Use <b>Surname, Initial</b> for easy searching")
|
||||
|
||||
orchestration = models.ForeignKey(Orchestration, on_delete=models.SET_DEFAULT, default=1, help_text="Orchestration for the work")
|
||||
original_parts = models.JSONField(default=dict, blank=True, help_text="Original printed parts (IMSLP format)")
|
||||
|
||||
# Collection details
|
||||
collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name="works")
|
||||
code = models.CharField(max_length=100, blank=True, help_text="Collection specific code or number")
|
||||
code = models.CharField(max_length=100, blank=True, help_text="Collection specific code or number. Will be auto-generated if not supplied")
|
||||
licence = models.PositiveSmallIntegerField(choices=LICENCE_TYPES, default=6, help_text="Copyright status")
|
||||
max_projects = models.IntegerField(default=1, help_text="How many active projects can this work be attached to")
|
||||
|
||||
@@ -193,19 +203,25 @@ class Work(models.Model):
|
||||
def folder(self):
|
||||
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 = qs.annotate(Count('sections'), end=Min('sections__end'), start=Max('sections__start')) \
|
||||
.filter(sections__count=len(tags))
|
||||
|
||||
return list(qs.values_list('upload', 'start', 'end'))
|
||||
return qs
|
||||
|
||||
def list_sections(self, *tags):
|
||||
return list(self.tagged_sections(*tags).values_list('upload', 'start', 'end'))
|
||||
|
||||
@property
|
||||
def digital_parts(self):
|
||||
sections = [ (s.tag, s) for s in 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 ]
|
||||
#return [ s[1] for s in sections ]
|
||||
sections = list(dict(sections).items()) # primitive unique()
|
||||
return sections
|
||||
|
||||
def pdfs(self):
|
||||
return self.docs.filter(doctype=Document.DOCTYPE_PDF)
|
||||
|
||||
@property
|
||||
def physical_parts(self):
|
||||
@@ -213,7 +229,7 @@ class Work(models.Model):
|
||||
return []
|
||||
parts = list(self.original_parts.items())
|
||||
parts.sort(key=self.orchestration.sorter())
|
||||
return [ (Instrument.from_tag(x[0]), x[1]) for x in parts ]
|
||||
return [ (MusicTag.from_tag(x[0]), x[1]) for x in parts ]
|
||||
|
||||
@property
|
||||
def tags(self):
|
||||
@@ -223,10 +239,6 @@ class Work(models.Model):
|
||||
def meta(self):
|
||||
return self.meta_info.exclude(name='tag')
|
||||
|
||||
@property
|
||||
def active_projects(self):
|
||||
return self.projects.filter(active=True)
|
||||
|
||||
@property
|
||||
def current_loans(self):
|
||||
return self.project_items.filter(checkout__lte=now(), returned=None).select_related('project')
|
||||
@@ -265,12 +277,20 @@ class Work(models.Model):
|
||||
return f"{composer[:4]}-{work}-{self.pk:05d}".upper()
|
||||
|
||||
def assigned_instruments(self):
|
||||
return Section.objects.filter(doc__work_id=self.pk, type=Section.TYPE_INSTRUMENT).values_list('tag', flat=True)
|
||||
return Section.objects.filter(doc__work_id=self.pk).values_list('tag', flat=True)
|
||||
|
||||
def unassigned_instruments(self):
|
||||
assigned = set(self.assigned_instruments())
|
||||
return [ x for x in self.orchestration.as_list() if not x[0] in assigned ]
|
||||
|
||||
def music_tags(self):
|
||||
tags = dict(self.orchestration.as_list())
|
||||
for section in Section.objects.filter(doc__work_id=self.pk):
|
||||
tags.setdefault(section.tag, section.name)
|
||||
|
||||
return tags.items()
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.composer})"
|
||||
|
||||
@@ -304,17 +324,23 @@ class Document(models.Model):
|
||||
DOCTYPE_PDF = 1
|
||||
DOCTYPE_AUDIO = 2
|
||||
DOCTYPE_VIDEO = 3
|
||||
DOCTYPE_SOURCE = 4
|
||||
DOCTYPE_MISC= 4
|
||||
|
||||
DOCTYPES = (
|
||||
(DOCTYPE_PDF, 'PDF'),
|
||||
(DOCTYPE_AUDIO, 'Audio'),
|
||||
(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")
|
||||
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)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
version = models.CharField(max_length=30, blank=True)
|
||||
@@ -322,6 +348,9 @@ class Document(models.Model):
|
||||
def delete(self, *args, **kwargs):
|
||||
self.upload.delete(save=False)
|
||||
return super().delete(*args, **kwargs)
|
||||
|
||||
def filename(self):
|
||||
return os.path.basename(self.upload.name)
|
||||
|
||||
def __str__(self):
|
||||
return self.upload.name
|
||||
@@ -330,23 +359,7 @@ class Section(models.Model):
|
||||
"""
|
||||
Section is a tagged portion of a Document
|
||||
"""
|
||||
|
||||
TYPE_INSTRUMENT = 1
|
||||
TYPE_MOVEMENT = 2
|
||||
TYPE_EXCERPT = 3
|
||||
|
||||
SECTION_TYPES = (
|
||||
(TYPE_INSTRUMENT, "Instrument"),
|
||||
(TYPE_MOVEMENT, "Movement"),
|
||||
(TYPE_EXCERPT, "Excerpt"),
|
||||
)
|
||||
|
||||
SECTION_CLASSES = {
|
||||
TYPE_INSTRUMENT: 'info',
|
||||
TYPE_MOVEMENT: 'success',
|
||||
TYPE_EXCERPT: 'warning',
|
||||
}
|
||||
|
||||
|
||||
PAGE_AUTO = 0
|
||||
PAGE_LEFT = 1
|
||||
PAGE_RIGHT = 2
|
||||
@@ -358,33 +371,30 @@ class Section(models.Model):
|
||||
)
|
||||
|
||||
|
||||
type = models.SmallIntegerField(choices=SECTION_TYPES)
|
||||
doc = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="sections")
|
||||
tag = models.CharField(max_length=50, blank=True)
|
||||
ordinal = models.IntegerField(default=0)
|
||||
start = models.SmallIntegerField(null=True, blank=True)
|
||||
end = models.SmallIntegerField(null=True, blank=True)
|
||||
page = models.SmallIntegerField(default=PAGE_AUTO, choices=PAGE_PREFERENCE) # NOT CURRENTLY USED
|
||||
|
||||
class Meta:
|
||||
ordering = ['type', 'ordinal', 'doc', 'start', 'pk']
|
||||
ordering = ['doc', 'start', 'pk']
|
||||
|
||||
@property
|
||||
def music_tag(self):
|
||||
return MusicTag.from_tag(self.tag)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
if self.type == self.TYPE_INSTRUMENT:
|
||||
instr = Instrument.from_tag(self.tag)
|
||||
if self.ordinal:
|
||||
return f'{instr} {self.ordinal}'
|
||||
return str(instr)
|
||||
return f"{self.ordinal} - {self.tag}"
|
||||
return str(self.music_tag)
|
||||
|
||||
@property
|
||||
def bulma_class(self):
|
||||
return self.SECTION_CLASSES[self.type]
|
||||
return "success" if self.music_tag.is_general else 'info'
|
||||
|
||||
@property
|
||||
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
|
||||
def pagerange(self):
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
|
||||
from collections import namedtuple
|
||||
|
||||
# taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_Instruments
|
||||
# Place any extra abbreviations at the top
|
||||
GENERAL = """
|
||||
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
|
||||
cb Double bass
|
||||
mall Mallet percussion
|
||||
|
||||
cb Double Bass
|
||||
mall Mallet Percussion
|
||||
vln Violin
|
||||
vla Viola
|
||||
|
||||
acc Accordion
|
||||
afl Alto flute
|
||||
@@ -25,7 +36,7 @@ bgtr Bass guitar
|
||||
bjo Banjo
|
||||
bn Bassoon
|
||||
bob Bass oboe (Baritone oboe)
|
||||
br Brass instruments
|
||||
br Brass MusicTags
|
||||
bryt Baryton
|
||||
bstcl Basset clarinet
|
||||
bsthn Basset horn
|
||||
@@ -52,7 +63,7 @@ dom Domra
|
||||
dulc Dulcimer
|
||||
egtr Electric guitar
|
||||
eh English horn (Cor anglais)
|
||||
elec Electronic Instruments
|
||||
elec Electronic MusicTags
|
||||
epf Electric piano
|
||||
eq Equal voices
|
||||
erhu Erhu
|
||||
@@ -72,7 +83,7 @@ heck Heckelphone
|
||||
hn Horn
|
||||
hp Harp
|
||||
hpd Harpsichord
|
||||
kbd Keyboard instrument
|
||||
kbd Keyboard MusicTag
|
||||
lute Lute
|
||||
lyre Lyre
|
||||
mand Mandolin
|
||||
@@ -114,7 +125,7 @@ skbt Sackbut
|
||||
sop Soprano (voice)
|
||||
srp Serpent
|
||||
stpt Slide trumpet
|
||||
str String instruments
|
||||
str String MusicTags
|
||||
sxh Saxhorn
|
||||
syn Synthesizer
|
||||
tba Tuba
|
||||
@@ -138,48 +149,45 @@ vn Violin
|
||||
vuv Vuvuzela
|
||||
vv Voices (multiple soloists)
|
||||
wag Wagner tuba
|
||||
ww Woodwind instruments
|
||||
ww Woodwind MusicTags
|
||||
xiao Xiao
|
||||
xyl Xylophone
|
||||
zith Zither
|
||||
"""
|
||||
|
||||
ORCHESTRATIONS = {
|
||||
'SATB': ('S', 'A', 'T', 'B'),
|
||||
'String Quartet': ('Vln1', 'Vln2', 'Vla', 'Vc'),
|
||||
'String Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb'),
|
||||
'Chamber Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb',
|
||||
'Fl1', 'Fl2', 'Cl1', 'Cl2', 'Hn1', 'Hn2',
|
||||
'Tpt1', 'Tpt2', 'Tbn1', 'Tbn2', 'Tuba',
|
||||
'Timp', 'Drum', 'Perc'),
|
||||
'Custom': (),
|
||||
}
|
||||
MUSIC_TAGS = []
|
||||
GENERAL_TAGS = set()
|
||||
for i, abbreviations in enumerate((GENERAL, INSTRUMENTS)):
|
||||
for line in abbreviations.split('\n'):
|
||||
parts = line.strip().split(maxsplit=1)
|
||||
if len(parts) < 2: continue
|
||||
name, _, _ = parts[1].partition('(')
|
||||
MUSIC_TAGS.append((parts[0], name))
|
||||
if i == 0:
|
||||
GENERAL_TAGS.add(parts[0])
|
||||
|
||||
|
||||
INSTRUMENTS = []
|
||||
for line in ABBREVIATIONS.split('\n'):
|
||||
parts = line.strip().split(maxsplit=1)
|
||||
if len(parts) < 2: continue
|
||||
name, _, _ = parts[1].partition('(')
|
||||
INSTRUMENTS.append((parts[0], name))
|
||||
MUSIC_NAME_BY_TAG = dict(MUSIC_TAGS)
|
||||
MUSIC_TAG_BY_NAME = dict( ( (x[1].lower(), x[0]) for x in MUSIC_TAGS ) )
|
||||
|
||||
INSTRUMENT_NAMES = dict(INSTRUMENTS)
|
||||
INSTRUMENT_TAGS = dict( ( (x[1].lower(), x[0]) for x in INSTRUMENTS ) )
|
||||
|
||||
class Instrument(namedtuple('Instrument', ('name', 'variant'), defaults=[None])):
|
||||
class MusicTag(namedtuple('MusicTag', ('name', 'variant'), defaults=[None])):
|
||||
|
||||
@classmethod
|
||||
def from_tag(cls, tag):
|
||||
"""
|
||||
>>> Instrument.from_tag('vn-1')
|
||||
Instrument(name='Violin', variant='1')
|
||||
>>> Instrument.from_tag('db')
|
||||
Instrument(name='Double Bass', variant=None)
|
||||
>>> Instrument.from_tag('Jaws Harp')
|
||||
Instrument(name='Jaws Harp', variant=None)
|
||||
>>> MusicTag.from_tag('vn-1')
|
||||
MusicTag(name='Violin', variant='1')
|
||||
>>> MusicTag.from_tag('db')
|
||||
MusicTag(name='Double Bass', variant=None)
|
||||
>>> MusicTag.from_tag('Jaws Harp')
|
||||
MusicTag(name='Jaws Harp', variant=None)
|
||||
>>> MusicTag.from_tag('mvmt-2')
|
||||
MusicTag(name='Movement', variant='2')
|
||||
>>> MusicTag.from_tag('pce-A2')
|
||||
MusicTag(name='Piece', variant='A2')
|
||||
"""
|
||||
abbr, _, variant = tag.partition('-')
|
||||
name = INSTRUMENT_NAMES.get(abbr.lower(), abbr)
|
||||
name = MUSIC_NAME_BY_TAG.get(abbr.lower(), abbr)
|
||||
|
||||
if variant:
|
||||
return cls(name, variant)
|
||||
@@ -188,31 +196,70 @@ class Instrument(namedtuple('Instrument', ('name', 'variant'), defaults=[None]))
|
||||
@property
|
||||
def tag(self):
|
||||
l = self.name.lower()
|
||||
return INSTRUMENT_TAGS.get(l, l)
|
||||
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):
|
||||
"""
|
||||
>>> Instrument('Violin', 1).abbreviate()
|
||||
>>> MusicTag('Violin', 1).abbreviate()
|
||||
'vn-1'
|
||||
>>> Instrument('Double Bass').abbreviate()
|
||||
>>> MusicTag('Double Bass').abbreviate()
|
||||
'db'
|
||||
"""
|
||||
tag = INSTRUMENT_TAGS.get(self.name.lower())
|
||||
tag = MUSIC_TAG_BY_NAME.get(self.name.lower())
|
||||
if self.variant:
|
||||
tag = f"{tag}-{self.variant}"
|
||||
return tag
|
||||
|
||||
def __str__(self):
|
||||
"""
|
||||
>>> str(Instrument('Violin', 1))
|
||||
>>> str(MusicTag('Violin', 1))
|
||||
'Violin 1'
|
||||
>>> str(Instrument('Double Bass'))
|
||||
>>> str(MusicTag('Double Bass'))
|
||||
'Double Bass'
|
||||
"""
|
||||
if self.variant:
|
||||
return f"{self.name} {self.variant}"
|
||||
return self.name
|
||||
|
||||
import re
|
||||
PATTERNS = [re.compile('([A-Za-z]+)[_\- ]*(\d+)'), re.compile('([A-Za-z]+)()')]
|
||||
|
||||
def auto_tag(filename):
|
||||
'''
|
||||
|
||||
>>> auto_tag('Ode to Joy - Violin 1.pdf')
|
||||
MusicTag(name='Violin', variant=1)
|
||||
>>> auto_tag('Ode to Joy_Cello.pdf')
|
||||
MusicTag(name='Cello', variant=None)
|
||||
>>> auto_tag('Ode to Joy violin - 1.pdf')
|
||||
MusicTag(name='Violin', variant=1)
|
||||
>>> auto_tag('Ode to Joy - vla.pdf')
|
||||
MusicTag(name='Viola', variant=None)
|
||||
>>> auto_tag('Ode to Joy - fl-2 (piccolo).pdf')
|
||||
MusicTag(name='Flute', variant=2)
|
||||
'''
|
||||
|
||||
for pattern in PATTERNS:
|
||||
for inst, ordinal in pattern.findall(filename):
|
||||
inst = inst.lower()
|
||||
ordinal = int(ordinal) if ordinal else None
|
||||
if inst in MUSIC_TAG_BY_NAME:
|
||||
return MusicTag(inst.title(), ordinal)
|
||||
if inst in MUSIC_NAME_BY_TAG:
|
||||
return MusicTag(MUSIC_NAME_BY_TAG[inst], ordinal)
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import doctest
|
||||
print(doctest.testmod())
|
||||
@@ -24,12 +24,12 @@ def extract_and_concat(items):
|
||||
if count == 0:
|
||||
continue
|
||||
|
||||
if start is None:
|
||||
if not start:
|
||||
sections.append(source)
|
||||
|
||||
else:
|
||||
|
||||
if end is None:
|
||||
if not end:
|
||||
end = start
|
||||
|
||||
dest = os.path.join(d.name, f'section_{i}.pdf')
|
||||
|
||||
@@ -3,6 +3,19 @@
|
||||
{% block page %}
|
||||
<h3 class="title">Library collections for {% firstof request.user.first_name request.user.username %}</h3>
|
||||
|
||||
<div class="block">
|
||||
<form method="GET" action="{% url 'work_list' %}">
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded">
|
||||
<input class="input" name="filter" type="text" placeholder="Find a work" value="{{ request.GET.filter }}"/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<a class="button" href="?"><i class="fas fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline">
|
||||
{% for collection in object_list %}
|
||||
<div class="column is-half">
|
||||
|
||||
@@ -75,11 +75,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<ul id="unassigned-area">
|
||||
{% for tag, inst in document.work.unassigned_instruments %}
|
||||
{% for tag, inst in document.work.music_tags %}
|
||||
<li class="is-clickable" onclick="assignInstrument('{{tag}}', this)")>{{ inst }}</li>
|
||||
{% endfor %}
|
||||
<li><a onclick="document.getElementById('add-modal').classList.add('is-active')">Add instrument</a></li>
|
||||
</ul>
|
||||
<a onclick="document.getElementById('add-modal').classList.add('is-active')">Add Tag</a>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<div class="has-text-centered">
|
||||
@@ -122,7 +122,7 @@
|
||||
<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>
|
||||
<button class="button is-primary" onclick="addInstrument();">Add</button>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -301,12 +301,19 @@
|
||||
variant.value = '';
|
||||
name.value = '';
|
||||
|
||||
let li = document.createElement('li');
|
||||
li.classList.add("is-clickable");
|
||||
li.addEventListener('click', () => assignInstrument(tag, li));
|
||||
li.innerHTML = get_instrument(tag);
|
||||
document.getElementById('unassigned-area').appendChild(li);
|
||||
|
||||
addTag(tag, pageNum, pageNum);
|
||||
closeAddModal();
|
||||
}
|
||||
|
||||
function assignInstrument(tag, el) {
|
||||
addTag(tag, pageNum, pageNum);
|
||||
el.remove();
|
||||
//el.remove();
|
||||
}
|
||||
|
||||
function addTag(tag, start, end) {
|
||||
@@ -334,11 +341,6 @@
|
||||
del.className = "icon is-action";
|
||||
del.innerHTML = '<i class="fas fa-trash-alt" title="Remove this tag"></i>';
|
||||
del.addEventListener('click', () => {
|
||||
let li = document.createElement('li');
|
||||
li.classList.add("is-clickable");
|
||||
li.addEventListener('click', () => assignInstrument(tag, li));
|
||||
li.innerHTML = get_instrument(el.dataset.tag);
|
||||
document.getElementById('unassigned-area').appendChild(li);
|
||||
el.remove();
|
||||
dirty=true;
|
||||
});
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
</td>
|
||||
<td class="has-text-right" style="white-space: nowrap;">
|
||||
{% if request.is_admin %}
|
||||
{% if doc.doctype == 1 %}
|
||||
<a href="{% url 'document_annotate' collection.pk doc.pk %}"><i class="fas fa-tags"
|
||||
title="Manage Tags"></i></a>
|
||||
{% endif %}
|
||||
<a href="{% url 'document_delete' collection.pk doc.pk %}"><i class="fas fa-trash-alt" title="Delete Document"></i></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
{% extends "interface/project_base.html" %}
|
||||
|
||||
{% block admin %}
|
||||
<a class="button is-link" href="{% url 'item_list_manage' project.pk %}">
|
||||
<span class="icon"><i class="fas fa-list"></i></span>
|
||||
<span>Change items</span>
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block page %}
|
||||
<form action="" method="post" target="_blank">
|
||||
{% csrf_token %}
|
||||
<div>
|
||||
<div class="field is-grouped is-grouped-centered">
|
||||
<div class="field has-addons control">
|
||||
<div class="field is-grouped is-grouped-centered is-grouped-multiline">
|
||||
<div class="field has-addons control has-addons-centered is-expanded">
|
||||
|
||||
<span class="control">
|
||||
<a class="button is-static">Your Instrument</a>
|
||||
<span class="control is-hidden-mobile">
|
||||
<a class="button is-static">Instrument</a>
|
||||
</span>
|
||||
|
||||
<span class="control">
|
||||
@@ -47,12 +40,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table is-striped is-fullwidth">
|
||||
<table class="table is-striped mx-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th/>
|
||||
<th>Piece</th>
|
||||
<th>Running time</th>
|
||||
<th class="is-hidden-mobile">Running time</th>
|
||||
<th>Part</th>
|
||||
<th/>
|
||||
</tr>
|
||||
@@ -63,25 +56,27 @@
|
||||
<td>{{ forloop.counter }}.</td>
|
||||
<td>
|
||||
{% if request.is_admin %}
|
||||
<a href="{% url 'work_detail' item.work.pk %}">{{ item.work.name }}</a>
|
||||
<a href="{% url 'work_detail' item.work.collection.pk item.work.pk %}">{{ item.work.name }}</a>
|
||||
{% else %}
|
||||
{{ item.work.name }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% firstof item.work.running_time "------" %}</td>
|
||||
<td class="is-hidden-mobile">{% firstof item.work.running_time "------" %}</td>
|
||||
<td class="select-cell">
|
||||
<input type="hidden" name="works" value="{{ item.work.pk }}"/>
|
||||
<span class="select is-small">
|
||||
<select name="instruments">
|
||||
<span class="select is-small" style="width: 100%">
|
||||
<select name="instruments" id="part-{{ item.work.pk }}" style="width: 100%">
|
||||
<option value='-'>None</option>
|
||||
{% for part in item.work.digital_parts %}
|
||||
<option value='{{ part.tag }}'>{{ part.instrument }}</option>
|
||||
{% for tag, name in item.work.digital_parts %}
|
||||
<option value='{{ tag }}'>{{ name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href=""><i class="fas fa-download"></i></a>
|
||||
<span class="is-action" onclick="downloadPart({{ item.work.collection_id }}, {{ item.work.pk }})">
|
||||
<i class="fas fa-download" title="Download Part"></i>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -90,11 +85,13 @@
|
||||
<tr>
|
||||
<td/>
|
||||
<td/>
|
||||
<td>{{ running_time }}</td>
|
||||
<td>{% firstof running_time "------" %}</td>
|
||||
<td/>
|
||||
<td>
|
||||
<!--
|
||||
<button class="button is-link is-small" type="submit"><span class="icon"><i class="fas fa-copy"></i></span><span>Single combined PDF</span></button>
|
||||
<a class="button is-link is-small"><span class="icon"><i class="fas fa-archive"></i></span><span>Individual files (zipped)</span></a>
|
||||
-->
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -154,6 +151,17 @@ function selectParts(inst, part) {
|
||||
|
||||
}
|
||||
|
||||
function downloadPart(collection, work) {
|
||||
let part = document.getElementById("part-" + work).value;
|
||||
if (part == '-') {
|
||||
alert("No part selected!");
|
||||
return;
|
||||
}
|
||||
console.log(part);
|
||||
let url = "/collections/" + collection + "/works/" + work + "/download?tag=" + part;
|
||||
window.location = url;
|
||||
}
|
||||
|
||||
document.getElementById("instrument-name").value = localStorage.getItem('instrument-name', 'All');
|
||||
document.getElementById("part-preference").value = localStorage.getItem('part-preference', 'Any');
|
||||
updateParts();
|
||||
|
||||
@@ -1,12 +1,50 @@
|
||||
{% with project.items.count as items %}
|
||||
{% if items %}
|
||||
<p>
|
||||
There {{ items|pluralize:"is,are" }} currently {{ items}} item{{ items|pluralize }} for this project.<br/>
|
||||
Click the '<a href="{% url 'item_list' project=project.pk %}">My Music</a>' button below to access your parts...
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
There are no items listed yet for this project - please check back later...
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<div class="panel">
|
||||
<p class="panel-heading">
|
||||
Items
|
||||
</p>
|
||||
|
||||
{% for item in project.items.all %}
|
||||
{% with work=item.work %}
|
||||
<a class="panel-block" onclick="showDownloadOptions('{% url 'work_parts' work.collection_id work.pk %}')">
|
||||
{{ item.work.name }}
|
||||
</a>
|
||||
{% endwith %}
|
||||
{% empty %}
|
||||
<span class="panel-block">
|
||||
There are no items listed yet for this project - please check back later...
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="modal" id="download-modal">
|
||||
<div class="modal-background" onclick="closeDownloadModal()"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Parts</p>
|
||||
<button class="delete" aria-label="close" onclick="closeDownloadModal()"></button>
|
||||
</header>
|
||||
<section class="modal-card-body" id="download-target">
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
function showDownloadOptions(url) {
|
||||
let target = document.getElementById("download-target");
|
||||
target.innerHTML = "Loading available parts...";
|
||||
|
||||
document.getElementById("download-modal").classList.add('is-active');
|
||||
fetch(url).then((data) => {
|
||||
console.log(data);
|
||||
data.text().then((text) => {
|
||||
target.innerHTML = text;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closeDownloadModal() {
|
||||
document.getElementById("download-modal").classList.remove('is-active');
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -2,6 +2,9 @@
|
||||
<ul class="menu-list">
|
||||
{% if project %}
|
||||
<li><a href="{% url 'item_list' project=project.pk %}">My Music</a></li>
|
||||
{% endif %}
|
||||
{% if collection %}
|
||||
|
||||
{% endif %}
|
||||
{% if request.user.is_authenticated %}
|
||||
<li><a href="{% url 'work_list' %}">Library</a></li>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
</table>
|
||||
|
||||
{% if work.parent %}
|
||||
<p>From <a href="{% url 'work_detail' collection.pk work.parent.pk %}">{{ work.parent.name }} - {{ work.parent.composer }}</a>
|
||||
<p>From <a href="{% url 'work_detail' work.parent.collection.pk work.parent.pk %}">{{ work.parent.name }} - {{ work.parent.composer }}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
<h3>Related</h3>
|
||||
<ul>
|
||||
{% for related in work.related_works.all %}
|
||||
<li><a href="{% url 'work_detail' related.pk %}">{{ related.name }} - {{ related.composer }}</a></li>
|
||||
<li><a href="{% url 'work_detail' related.collection.pk related.pk %}">{{ related.name }} - {{ related.composer }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
@@ -87,15 +87,17 @@
|
||||
Digital Parts
|
||||
</h4>
|
||||
<div class="tags">
|
||||
{% if work.digital_parts %}
|
||||
<a class="tag is-danger" href="{% url 'work_partset' collection.pk work.pk %}">Full Set</a>
|
||||
{% endif %}
|
||||
{% for section in work.digital_parts %}
|
||||
<a class="tag is-info" href="{% url 'part_download' collection.pk section.pk section.filename %}"
|
||||
target="section_{{ section.pk }}" rel="">{{ section.name }}</a>
|
||||
{% empty %}
|
||||
<p class="is-italic">No digital parts available</p>
|
||||
{% endfor %}
|
||||
{% with sections=work.digital_parts %}
|
||||
{% if sections %}
|
||||
<a class="tag is-danger" href="{% url 'work_partset' collection.pk work.pk %}">Full Set</a>
|
||||
{% endif %}
|
||||
{% for tag, name in sections %}
|
||||
<a class="tag is-info" href="{% url 'work_download' collection.pk work.pk %}?tag={{ tag }}"
|
||||
target="section_{{ tag }}" rel="">{{ name }}</a>
|
||||
{% empty %}
|
||||
<p class="is-italic">No digital parts available</p>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,7 +131,7 @@
|
||||
{% if request.is_admin %}
|
||||
<div class="column is-one-quarter">
|
||||
<h4 class="is-size-5">Upload files</h4>
|
||||
<form action="{% url 'document_add' collection.pk object.pk %}" class="dropzone" id="doc-upload">
|
||||
<form action="{% url 'document_add' collection.pk object.pk %}" class="dropzone" id="doc-upload" style="-moz-user-select: none">
|
||||
{% csrf_token %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -29,10 +29,7 @@
|
||||
<th>Work</th>
|
||||
<th>Composer</th>
|
||||
<th class="is-hidden-mobile">Edition</th>
|
||||
<th class="is-hidden-touch">Collection</th>
|
||||
{% if request.is_admin %}
|
||||
<th class="is-hidden-mobile">Copies</th>
|
||||
{% endif %}
|
||||
{% if not collection %}<th class="is-hidden-touch">Collection</th>{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -41,10 +38,7 @@
|
||||
<td><a href="{% url 'work_detail' collection=work.collection.pk pk=work.pk %}">{{ work.name }}</a></td>
|
||||
<td title="{{ work.composer }}">{{ work.composer|truncatewords:3 }}</td>
|
||||
<td class="is-hidden-mobile" title="{{ work.edition }}">{{ work.edition|truncatewords:2 }}</td>
|
||||
<td class="is-hidden-touch">{{ work.collection.name }}</td>
|
||||
{% if request.is_admin %}
|
||||
<td class="is-hidden-mobile {{ work.is_available|yesno:'has-text-success,has-text-danger' }}">{{ work.available }}</td>
|
||||
{% endif %}
|
||||
{% if not collection %}<td class="is-hidden-touch">{{ work.collection.name }}</td>{% endif %}
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4">No works found</td></tr>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<h3 class="subtitle">{{ work.name }}</h3>
|
||||
<div class="block">
|
||||
{% for tag, name in work.digital_parts %}
|
||||
<a class="tag is-info" href="{% url 'work_download' work.collection_id work.pk %}?tag={{ tag }}">{{ name }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<h3 class="subtitle">Files</h3>
|
||||
<table class="table is-narrow is-fullwidth">
|
||||
<tbody>
|
||||
{% for doc in work.pdfs %}
|
||||
<tr>
|
||||
<td><a href="{{ doc.upload.url }}">{{ doc.filename }}</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td>There are no files available for this work</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
<a href="{% url 'work_detail' work.collection_id work.pk %}">More details...</a>
|
||||
</p>
|
||||
@@ -3,35 +3,51 @@
|
||||
|
||||
|
||||
{% block page %}
|
||||
<h3 class="subtitle"><a href="{% url 'work_detail' collection.pk work.pk %}">{{ work.name }}</a></h3>
|
||||
|
||||
<p class="block">
|
||||
You can generate a custom partset for printing - select the number of copies of each you want...
|
||||
</p>
|
||||
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-narrow">
|
||||
|
||||
<form action="" method="post" target="_blank">
|
||||
{% csrf_token %}
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
|
||||
<div class="admin-tools is-pulled-right">
|
||||
<button type="submit" class="button is-link">
|
||||
<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' collection.pk object.pk %}">
|
||||
<span>Cancel</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
<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-multiline is-mobile">
|
||||
{% for part in work.digital_parts %}
|
||||
<div class="column is-3 has-text-right">
|
||||
<span style="white-space: nowrap">{{ part.name }}</span>
|
||||
<input name="parts" type="hidden" value="{{ part.tag }}">
|
||||
<input class="input" name="copies" type="number" value="{% if part.tag == 'score' %}0{% else %}1{% endif %}" size="1">
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tag, name in work.digital_parts %}
|
||||
<tr>
|
||||
<td>{{ name }}</td>
|
||||
<td>
|
||||
<input name="parts" type="hidden" value="{{ tag }}">
|
||||
<input class="input is-small" name="copies" type="number" value="{% if tag == 'score' %}0{% else %}1{% endif %}">
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button class="button is-link">
|
||||
<span class="icon"><i class="fas fa-print"></i></span>
|
||||
<span>Print Set</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<a class="button is-link is-light" href="{% url 'work_detail' collection.pk object.pk %}">
|
||||
<span>Cancel</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
+150
-24
@@ -1,50 +1,176 @@
|
||||
from django.test import TestCase
|
||||
from interface.tests import AccessTestCase
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from interface.models import Ensemble, Project
|
||||
from byostorage.user import UserStorage
|
||||
from . import models
|
||||
from .views.api import WorkSerializer
|
||||
|
||||
import tempfile
|
||||
import json
|
||||
|
||||
class LibraryTestCase(AccessTestCase):
|
||||
|
||||
USERS = (
|
||||
{'username': 'admin', 'password': 'secret', 'is_superuser': True, 'is_staff': True},
|
||||
{'username': 'homer', 'password': 'maggie'},
|
||||
)
|
||||
|
||||
ENSEMBLES = (
|
||||
{'name': 'The Be Sharps', 'slug': 'be-sharps', 'admins': ['homer']},
|
||||
{'name': 'Lisa & the Bleeding Gums', 'slug': 'bleeding-gums'},
|
||||
{'name': 'Party Posse'},
|
||||
)
|
||||
|
||||
PROJECTS = (
|
||||
{'name': 'Baker St', 'ensemble': 'bleeding-gums', 'when': -12},
|
||||
{'name': 'Navy Recruitment Day', 'ensemble': 'party-posse', 'when': 6},
|
||||
{'name': 'Barbershop Contest', 'ensemble': 'be-sharps', 'when': 28},
|
||||
{'name': 'Open Mic Night', 'ensemble': 'bleeding-gums', 'when': 1 },
|
||||
)
|
||||
|
||||
COLLECTIONS = (
|
||||
{'name': 'Springfield Elementary Library', 'prefix': 'sel'},
|
||||
{'name': 'Neds Library', 'prefix': 'ned', 'admins': ['homer']},
|
||||
)
|
||||
|
||||
WORKS = (
|
||||
{'name': 'Baby on Board', 'collection': 'ned', 'docs': [{'upload': 'local:baby_on_board.pdf'}]},
|
||||
{'name': 'Star Spangled Banner', 'collection': 'sel'},
|
||||
)
|
||||
|
||||
PROTECTED_URLS = (
|
||||
'/collections/1',
|
||||
'/collections/1/add',
|
||||
'/collections/2/works/1',
|
||||
'/collections/2/works/1/edit',
|
||||
'/collections/2/works/1/partset',
|
||||
'/collections/2/works/1/add_to_project',
|
||||
'/collections/2/works/1/upload',
|
||||
'/collections/2/docs/1/annotate',
|
||||
|
||||
# Need to add storage before we can test these
|
||||
'/api/collections/2',
|
||||
'/api/collections/2/works/1',
|
||||
|
||||
'/admin/library/collection/',
|
||||
'/admin/library/document/',
|
||||
'/admin/library/ensembleaccess/',
|
||||
'/admin/library/orchestration/',
|
||||
'/admin/library/projectitem/',
|
||||
'/admin/library/work/',
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
super().setUpTestData()
|
||||
|
||||
cls.temp_dir = tempfile.TemporaryDirectory()
|
||||
cls.storage = UserStorage.objects.create(name='local', storage='django.core.files.storage.FileSystemStorage',
|
||||
settings_data=json.dumps({'location': cls.temp_dir.name, 'base_url': 'file://' + cls.temp_dir.name}))
|
||||
cls.collections = {}
|
||||
for details in cls.COLLECTIONS:
|
||||
admins = details.pop('admins', [])
|
||||
obj = models.Collection.objects.create(storage=cls.storage, **details)
|
||||
for admin in admins:
|
||||
obj.administrators.add(cls.users[admin])
|
||||
cls.collections[details['prefix']] = obj
|
||||
|
||||
cls.works = {}
|
||||
for details in cls.WORKS:
|
||||
collection = cls.collections[details.pop('collection')]
|
||||
#details.setdefault('docs', [])
|
||||
#details.setdefault('meta_info', [])
|
||||
#s = WorkSerializer(data=details)
|
||||
#assert s.is_valid(), s.errors
|
||||
#s.save(collection_id=collection.pk)
|
||||
docs = details.pop('docs', [])
|
||||
obj = models.Work.objects.create(collection=collection, **details)
|
||||
for doc in docs:
|
||||
obj.docs.create(**doc)
|
||||
cls.works[details['name']] = obj
|
||||
|
||||
class IntegrationTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.homer = User.objects.create(username='homer')
|
||||
self.ned = User.objects.create(username="ned")
|
||||
self.lisa = User.objects.create(username="lisa")
|
||||
self.dewey = User.objects.create(username="dewey")
|
||||
pass
|
||||
|
||||
self.be_sharps = self.homer.ensembles.create(name='Be Sharps', code="barbershop")
|
||||
self.sesd = self.dewey.ensembles.create(name="Springfield Elementary School Band", code="sax")
|
||||
|
||||
self.sel = self.lisa.collections.create(name="Springfield Elementary Library")
|
||||
self.flanders = self.ned.collections.create(name="Neds Shed")
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.temp_dir.cleanup()
|
||||
|
||||
|
||||
def test_integration(self):
|
||||
pass
|
||||
|
||||
def test_superuser_access(self):
|
||||
self.login('admin', 'secret')
|
||||
self.assertAccess({
|
||||
'/collections': True,
|
||||
'/collections/1': True,
|
||||
'/collections/2/works/1': True,
|
||||
})
|
||||
|
||||
def test_administrator_access(self):
|
||||
self.login('homer', 'maggie')
|
||||
self.assertAccess({
|
||||
'/collections': True,
|
||||
'/collections/1': False,
|
||||
'/collections/2': True,
|
||||
'/collections/2/works/1': True,
|
||||
})
|
||||
|
||||
def test_link_access(self):
|
||||
self.assertAccess({
|
||||
'/collections': True,
|
||||
'/collections/1': False,
|
||||
'/collections/2': False,
|
||||
'/collections/2/works/1': False,
|
||||
})
|
||||
|
||||
self.authorize(models.Collection, pk=2)
|
||||
self.assertAccess({
|
||||
'/collections': True,
|
||||
'/collections/1': False,
|
||||
'/collections/2': True,
|
||||
'/collections/2/works/1': True,
|
||||
})
|
||||
|
||||
def test_anon_access(self):
|
||||
self.assertAccess({
|
||||
'/collections': True,
|
||||
'/collections/1': False,
|
||||
'/collections/2': False,
|
||||
'/collections/2/works/1': False,
|
||||
})
|
||||
|
||||
def test_export_and_import(self):
|
||||
self.login('admin', 'secret')
|
||||
data = self.client.get('/api/collections/1/works/2', HTTP_ACCEPT="application/json").json()
|
||||
response = self.client.post('/api/collections/2/import', data, "application/json")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
def test_movement_from_large_work(self):
|
||||
'''
|
||||
Will be common to store a work which has several movements, but the project is only going to play one.
|
||||
This also should give us the ability to store an anthology as one Work have Project reference 'no:23'
|
||||
'''
|
||||
|
||||
work = self.sel.works.create(name="Some Quartet", composer="Beethoven")
|
||||
for g in ('vl1', 'vl2', 'vla', 'vc'):
|
||||
work = self.collections['sel'].works.create(name="Some Quartet", composer="Beethoven")
|
||||
for g in ('vl-1', 'vl-2', 'vla', 'vc'):
|
||||
doc = work.docs.create(upload=f'sel/beethoven/some_quartet/some_quartet_{g}.pdf')
|
||||
doc.sections.create(tag='mvmt:1', start=1, end=3)
|
||||
doc.sections.create(tag='mvmt:2', start=4, end=8)
|
||||
doc.sections.create(tag='mvmt:3', start=9, end=12)
|
||||
doc.sections.create(tag=f'inst:{g}')
|
||||
doc.sections.create(tag='mvmt-1', start=1, end=3)
|
||||
doc.sections.create(tag='mvmt-2', start=4, end=8)
|
||||
doc.sections.create(tag='mvmt-3', start=9, end=12)
|
||||
doc.sections.create(tag=g)
|
||||
|
||||
# no tags - get nothing (should it be everything?)
|
||||
self.assertEqual(work.extract(), [])
|
||||
self.assertEqual(work.list_sections(), [])
|
||||
|
||||
# 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
|
||||
result = work.extract('mvmt:2')
|
||||
result = work.list_sections('mvmt-2')
|
||||
self.assertEqual(len(result), 4)
|
||||
|
||||
# multiple tags - returns the overlapping portion of all documents that have all tags
|
||||
self.assertEqual(work.extract('inst:vl1', 'mvmt:2'), [('sel/beethoven/some_quartet/some_quartet_vl1.pdf', 4, 8)])
|
||||
self.assertEqual(work.extract('inst:vl1', 'inst:vl2'), [])
|
||||
self.assertEqual(work.list_sections('vl-1', 'mvmt-2'), [('sel/beethoven/some_quartet/some_quartet_vl-1.pdf', 4, 8)])
|
||||
self.assertEqual(work.list_sections('vl-1', 'vl-2'), [])
|
||||
+7
-5
@@ -16,7 +16,7 @@ urlpatterns = [
|
||||
path('projects/<int:project>/items/manage', views.ProjectItemManageView.as_view(), name="item_list_manage"),
|
||||
path('projects/<int:project>/items/append', views.ProjectItemAddView.as_view(), name="item_list_append"),
|
||||
|
||||
path('library', views.WorkListView.as_view(), name="work_list"),
|
||||
path('library', views.LibraryWorkListView.as_view(), name="work_list"),
|
||||
|
||||
path('collections', views.CollectionListView.as_view(), name="collection_list"),
|
||||
path('collections/<int:collection>', views.CollectionWorkListView.as_view(), name="collection_work_list"),
|
||||
@@ -25,8 +25,10 @@ urlpatterns = [
|
||||
path('collections/<int:collection>/works/<int:pk>', views.WorkDetailView.as_view(), name="work_detail"),
|
||||
path('collections/<int:collection>/works/<int:pk>/edit', views.WorkUpdateView.as_view(), name="work_edit"),
|
||||
path('collections/<int:collection>/works/<int:pk>/partset', views.WorkPartSetView.as_view(), name="work_partset"),
|
||||
path('collections/<int:collection>/works/<int:pk>/parts', views.WorkPartsView.as_view(), name="work_parts"),
|
||||
path('collections/<int:collection>/works/<int:pk>/add_to_project', views.WorkAddToProject.as_view(), name="work_add_to_project"),
|
||||
path('collections/<int:collection>/works/<int:pk>/upload', views.WorkAddDocumentView.as_view(), name="document_add"),
|
||||
path('collections/<int:collection>/works/<int:pk>/download', views.WorkDownloadView.as_view(), name="work_download"),
|
||||
|
||||
path('collections/<int:collection>/docs/<int:pk>/delete', views.DocumentDeleteView.as_view(), name="document_delete"),
|
||||
path('collections/<int:collection>/docs/<int:pk>/download', views.DocumentDownloadView.as_view(), name="document_download"),
|
||||
@@ -35,8 +37,8 @@ urlpatterns = [
|
||||
path('collections/<int:collection>/download/<int:section>/<str:filename>', views.PartDownloadView.as_view(), name="part_download"),
|
||||
|
||||
#path('api/', include(router.urls))
|
||||
path('api/library/collections/<int:pk>/export', api.CollectionExportView.as_view(), name="collection_export"),
|
||||
path('api/library/works/<int:pk>/export', api.WorkExportView.as_view(), name="work_export"),
|
||||
path('api/library/collections/<int:pk>/import', api.WorkImportView.as_view(), name="work_import"),
|
||||
path('api/library/collections/<int:pk>/bulk_import', api.CollectionImportView.as_view(), name="collection_import"),
|
||||
path('api/collections/<int:pk>', api.CollectionExportView.as_view(), name="collection_export"),
|
||||
path('api/collections/<int:collection>/works/<int:pk>', api.WorkExportView.as_view(), name="work_export"),
|
||||
path('api/collections/<int:collection>/import', api.WorkImportView.as_view(), name="work_import"),
|
||||
path('api/collections/<int:collection>/bulk_import', api.CollectionImportView.as_view(), name="collection_import"),
|
||||
]
|
||||
|
||||
+108
-58
@@ -9,6 +9,8 @@ from django.db import transaction
|
||||
from django.utils.timezone import now
|
||||
from django.urls import reverse
|
||||
from django.template.loader import render_to_string
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
|
||||
import json
|
||||
import os.path
|
||||
@@ -17,14 +19,12 @@ import re
|
||||
from interface.views import EnsembleMixin, ProjectMixin, AuthorizedResourceMixin
|
||||
from interface.models import Project
|
||||
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.pdf_utils import extract_pages, extract_and_concat
|
||||
|
||||
"""
|
||||
|
||||
"""
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ProjectItemListView(ProjectMixin, ListView):
|
||||
template_name = "library/item_list.html"
|
||||
@@ -32,9 +32,7 @@ class ProjectItemListView(ProjectMixin, ListView):
|
||||
|
||||
def post(self, request, **kwargs):
|
||||
|
||||
project = self.get_project()
|
||||
|
||||
project_works = project.works.all()
|
||||
project_works = self.project.works.all()
|
||||
|
||||
instruments = request.POST.getlist('instruments')
|
||||
works = request.POST.getlist('works')
|
||||
@@ -60,7 +58,7 @@ class ProjectItemListView(ProjectMixin, ListView):
|
||||
|
||||
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['Content-Disposition'] = f'inline; filename="{download_name}"'
|
||||
@@ -72,14 +70,10 @@ class ProjectItemListView(ProjectMixin, ListView):
|
||||
|
||||
def get_context_data(self, **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['part'] = self.request.session.get('part', '0')
|
||||
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
|
||||
|
||||
|
||||
@@ -119,6 +113,9 @@ class ProjectItemAddView(ProjectMixin, UpdateView):
|
||||
""" COLLECTION VIEWS """
|
||||
|
||||
class CollectionMixin(AuthorizedResourceMixin):
|
||||
|
||||
collection = None
|
||||
|
||||
def is_authorized(self):
|
||||
collection_id = self.kwargs['collection']
|
||||
self.collection = get_object_or_404(models.Collection, pk=collection_id)
|
||||
@@ -129,7 +126,10 @@ class CollectionMixin(AuthorizedResourceMixin):
|
||||
if self.collection.has_administrator(self.request.user):
|
||||
self.request.is_admin = True
|
||||
return True
|
||||
|
||||
|
||||
if self.is_authorized_key('collection', collection_id, self.collection.nonce):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
@@ -146,7 +146,7 @@ class CollectionListView(ListView):
|
||||
paginate_by = 20
|
||||
|
||||
def get_queryset(self):
|
||||
collections = models.Collection.objects.all()
|
||||
collections = models.Collection.objects.order_by('name')
|
||||
|
||||
if self.request.user.is_anonymous:
|
||||
return models.Collection.objects.none()
|
||||
@@ -158,10 +158,6 @@ class CollectionListView(ListView):
|
||||
|
||||
class WorkListView(CollectionMixin, ListView):
|
||||
paginate_by = 20
|
||||
|
||||
def get_works(self):
|
||||
collections = CollectionMixin.get_queryset(self)
|
||||
return Work.objects.filter(collection__in=collections).select_related('collection')
|
||||
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
data = super(WorkListView, self).get_context_data(*args, **kwargs)
|
||||
@@ -180,16 +176,37 @@ class WorkListView(CollectionMixin, ListView):
|
||||
else:
|
||||
works = works.filter(Q(name__contains=q) | Q(composer__contains=q) | Q(meta_info__value__contains=q))
|
||||
|
||||
return works.order_by('name', 'composer', 'edition', 'pk')
|
||||
return works.order_by('name', 'composer', 'edition', 'pk').distinct()
|
||||
|
||||
class LibraryWorkListView(WorkListView):
|
||||
|
||||
def is_authorized(self):
|
||||
return True
|
||||
|
||||
def get_works(self):
|
||||
collections = models.Collection.objects.all()
|
||||
|
||||
if not self.request.user.is_superuser:
|
||||
collections = collections.filter(administrators=self.request.user)
|
||||
|
||||
return Work.objects.filter(collection__in=collections).select_related('collection')
|
||||
|
||||
class CollectionWorkListView(WorkListView):
|
||||
|
||||
def request_denied(self):
|
||||
if 'auth' in self.request.GET:
|
||||
if self.request.GET['auth'] != self.collection.auth():
|
||||
raise SuspiciousOperation("Bad collection link")
|
||||
self.add_authorized_key('collection', self.collection.pk, self.collection.nonce)
|
||||
return HttpResponseRedirect(self.request.path)
|
||||
return super().request_denied()
|
||||
|
||||
def get_works(self):
|
||||
works = Work.objects.filter(collection=self.kwargs['collection'])
|
||||
|
||||
if self.request.is_admin:
|
||||
loan_count = Count('project_items', Q(project_items__checkout__lte=now(), project_items__returned=None))
|
||||
works = works.annotate(loan_count=loan_count)
|
||||
#if self.request.is_admin:
|
||||
# loan_count = Count('project_items', Q(project_items__checkout__lte=now(), project_items__returned=None))
|
||||
# works = works.annotate(loan_count=loan_count)
|
||||
return works
|
||||
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
@@ -230,16 +247,16 @@ class WorkDetailView(CollectionMixin, DetailView):
|
||||
model = models.Work
|
||||
|
||||
class WorkUpdateView(CollectionMixin, UpdateView):
|
||||
#fields = ['name', 'composer', 'edition', 'code', 'orchestration', 'licence', 'max_projects', 'running_time', 'notes']
|
||||
model = models.Work
|
||||
form_class = forms.WorkCreateForm
|
||||
template_name = 'interface/default_form.html'
|
||||
|
||||
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(ProjectMixin, FormView):
|
||||
class WorkAddToProject(CollectionMixin, FormView):
|
||||
admin_required = True
|
||||
form_class = forms.ProjectSelectForm
|
||||
template_name = "interface/default_form.html"
|
||||
@@ -250,12 +267,16 @@ class WorkAddToProject(ProjectMixin, FormView):
|
||||
|
||||
def get_form(self):
|
||||
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()
|
||||
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
|
||||
|
||||
def form_valid(self, form):
|
||||
@@ -264,7 +285,9 @@ class WorkAddToProject(ProjectMixin, FormView):
|
||||
work.project_items.create(project=project, approved_by=self.request.user, checkout=now())
|
||||
return redirect('item_list', project=project.pk)
|
||||
|
||||
|
||||
class WorkPartsView(CollectionMixin, DetailView):
|
||||
model = models.Work
|
||||
template_name = "library/work_parts_fragment.html"
|
||||
|
||||
class WorkPartSetView(CollectionMixin, DetailView):
|
||||
template_name = "library/work_partset.html"
|
||||
@@ -281,8 +304,8 @@ class WorkPartSetView(CollectionMixin, DetailView):
|
||||
for i, tag in enumerate(parts):
|
||||
c = int(copies[i])
|
||||
if c > 0:
|
||||
part = models.Section.objects.select_related('doc').get(tag=tag, doc__work=work)
|
||||
sections.append((part.doc.upload.path, part.name, part.start, part.end, c))
|
||||
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)
|
||||
|
||||
@@ -299,6 +322,38 @@ class WorkPartSetView(CollectionMixin, DetailView):
|
||||
works = works.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id)
|
||||
return works
|
||||
|
||||
class WorkDownloadView(CollectionMixin, SingleObjectMixin, View):
|
||||
model = models.Work
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
|
||||
tags = request.GET.getlist('tag')
|
||||
|
||||
if not tags:
|
||||
raise Http404("No tags given")
|
||||
|
||||
sections = list(self.object.tagged_sections(*tags))
|
||||
print(sections)
|
||||
|
||||
if len(sections) == 0:
|
||||
raise Http404("No matching sections")
|
||||
|
||||
if len(sections) == 1 and sections[0].start == 0:
|
||||
# bypass extraction and redirect to the url
|
||||
logger.debug("Redirecting to url")
|
||||
return redirect(sections[0].upload.url)
|
||||
|
||||
result = extract_and_concat([ (s.upload.path, s.upload.name, s.start, s.end, 1) for s in sections ])
|
||||
|
||||
tag_names = " - ".join([ str(MusicTag.from_tag(tag)) for tag in tags ])
|
||||
|
||||
download_name = f'{self.object.name} - {tag_names}.pdf'
|
||||
|
||||
response = FileResponse(result, content_type="application/pdf")
|
||||
response['Content-Disposition'] = f'inline; filename="{download_name}"'
|
||||
return response
|
||||
|
||||
class WorkAddDocumentView(CollectionMixin, CreateView):
|
||||
template_name = "interface/default_form.html"
|
||||
model = Document
|
||||
@@ -314,23 +369,21 @@ class WorkAddDocumentView(CollectionMixin, CreateView):
|
||||
return super().form_invalid(form)
|
||||
|
||||
def form_valid(self, form):
|
||||
orig_name, ext = os.path.splitext(form.cleaned_data['upload'].name)
|
||||
logger.info("Uploaded: %s", orig_name)
|
||||
|
||||
doc = form.save(commit=False)
|
||||
doc.doctype = models.Document.DOCTYPE_MAP.get(ext.lower(), models.Document.DOCTYPE_MISC)
|
||||
doc.work_id = self.kwargs['pk']
|
||||
doc.save()
|
||||
|
||||
# auto tag the document
|
||||
|
||||
name, _ = os.path.splitext(os.path.basename(doc.upload.name))
|
||||
parts = re.split(r'[^A-Za-z]+', name)
|
||||
parts.reverse()
|
||||
for word in parts:
|
||||
try:
|
||||
tag = INSTRUMENT_TAGS[word.lower()]
|
||||
doc.sections.create(tag=tag)
|
||||
break
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
#name, ext = os.path.splitext(os.path.basename(doc.upload.name))
|
||||
if doc.doctype == models.Document.DOCTYPE_PDF:
|
||||
inst = auto_tag(orig_name)
|
||||
if inst:
|
||||
doc.sections.create(tag=inst.abbreviate())
|
||||
|
||||
if self.request.headers['Accept'] == 'application/json':
|
||||
filename = os.path.basename(doc.upload.name)
|
||||
@@ -348,12 +401,10 @@ class DocumentMixin(CollectionMixin):
|
||||
model = models.Document
|
||||
|
||||
def get_queryset(self):
|
||||
return models.Document.objects.filter(work__collection=self.collection)
|
||||
|
||||
# def get_queryset(self):
|
||||
# if self.request.is_admin:
|
||||
# return Document.objects.select_related('work')
|
||||
# return Document.objects.filter(work__ensemble=self.request.ensemble_id).select_related('work')
|
||||
qs = models.Document.objects.select_related('work')
|
||||
if self.request.is_admin:
|
||||
return qs
|
||||
return qs.filter(work__collection=self.collection)
|
||||
|
||||
class DocumentDetailView(DocumentMixin, DetailView):
|
||||
pass
|
||||
@@ -384,7 +435,7 @@ class DocumentAnnotateView(DocumentMixin, DetailView):
|
||||
for tag, start, end in data:
|
||||
#pages.sort()
|
||||
#end = pages[-1] if len(pages) > 1 else None
|
||||
o = self.object.sections.create(tag=tag, type=models.Section.TYPE_INSTRUMENT, start=start, end=end)
|
||||
o = self.object.sections.create(tag=tag, start=start, end=end)
|
||||
|
||||
return HttpResponse(status=204)
|
||||
|
||||
@@ -395,7 +446,7 @@ class DocumentAnnotateView(DocumentMixin, DetailView):
|
||||
for part in data['document'].sections.all():
|
||||
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
|
||||
|
||||
class DocumentDeleteView(DocumentMixin, DeleteView):
|
||||
@@ -415,6 +466,9 @@ class PartDownloadView(CollectionMixin, SingleObjectMixin, View):
|
||||
self.args = args
|
||||
self.object = self.get_object()
|
||||
|
||||
if self.object.start is None:
|
||||
return redirect(self.object.doc.upload.url)
|
||||
|
||||
result = extract_pages(self.object.doc.upload.path, self.object.doc.work.name, self.object.start, self.object.end)
|
||||
|
||||
#download_name = f'{self.object.doc.work.name}_{self.object.instrument}.pdf'
|
||||
@@ -423,10 +477,6 @@ class PartDownloadView(CollectionMixin, SingleObjectMixin, View):
|
||||
response['Content-Disposition'] = f'inline; filename="{self.args["filename"]}"'
|
||||
return response
|
||||
|
||||
def get_queryset(self):
|
||||
def get_object(self):
|
||||
|
||||
if self.request.is_admin:
|
||||
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')
|
||||
return Section.objects.filter(doc__work__collection=self.collection).select_related('doc', 'doc__work').get(pk=self.kwargs['section'])
|
||||
+26
-18
@@ -35,14 +35,16 @@ class WorkExportView(EnsembleMixin, WorkMixin, View):
|
||||
from interface.views import AuthorizedResourceMixin
|
||||
|
||||
from rest_framework import routers, serializers, viewsets
|
||||
from rest_framework.exceptions import APIException
|
||||
|
||||
from library.models import Collection, Work, Document, Section, WorkMeta
|
||||
|
||||
|
||||
import requests
|
||||
from io import BytesIO
|
||||
import tempfile
|
||||
import urllib
|
||||
import shutil
|
||||
import os.path
|
||||
|
||||
from django.db import transaction
|
||||
from django.core.files.uploadedfile import TemporaryUploadedFile
|
||||
@@ -67,25 +69,22 @@ class SectionSerializer(serializers.ModelSerializer):
|
||||
def to_representation(self, instance):
|
||||
start = instance.start 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):
|
||||
tag, section_type, start, end = data.split(":")
|
||||
try:
|
||||
start = int(start)
|
||||
except:
|
||||
start = 0
|
||||
try:
|
||||
end = int(end)
|
||||
except:
|
||||
end = 0
|
||||
return super().to_internal_value({'tag': tag, 'type': int(section_type), 'start': start, 'end': end})
|
||||
tag, start, end = data.split(":")
|
||||
start = int(start)
|
||||
end = int(end)
|
||||
if start < 1:
|
||||
start = None
|
||||
if end < 1:
|
||||
end = None
|
||||
return super().to_internal_value({'tag': tag, 'start': start, 'end': end})
|
||||
|
||||
class DocumentSerializer(serializers.ModelSerializer):
|
||||
|
||||
upload = serializers.URLField()
|
||||
sections = SectionSerializer(many=True)
|
||||
#doctype = serializers.CharField(source='get_doctype_display')
|
||||
|
||||
#def to_internal_value(self, data):
|
||||
# r = requests.get(data['upload'], stream=True)
|
||||
@@ -97,8 +96,7 @@ class DocumentSerializer(serializers.ModelSerializer):
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = super().to_representation(instance)
|
||||
if data['upload'][0] == '/':
|
||||
data['upload'] = 'http://localhost:8000' + (data['upload'])
|
||||
data['upload'] = instance.upload.url
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
@@ -136,8 +134,13 @@ class WorkSerializer(serializers.ModelSerializer):
|
||||
for d in docs:
|
||||
sections = d.pop('sections', [])
|
||||
|
||||
url = urllib.parse.urlparse(d['upload'])
|
||||
filename = os.path.basename(url.path)
|
||||
|
||||
r = requests.get(d['upload'], stream=True)
|
||||
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)
|
||||
r.close()
|
||||
d['upload'] = f
|
||||
@@ -171,19 +174,24 @@ class CollectionExportView(AuthorizedResourceMixin, generics.RetrieveAPIView):
|
||||
serializer_class = CollectionSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_superuser:
|
||||
return Collection.objects.all()
|
||||
return Collection.objects.filter(administrators=self.request.user)
|
||||
|
||||
class WorkExportView(AuthorizedResourceMixin, generics.RetrieveAPIView):
|
||||
serializer_class = WorkSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
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(AuthorizedResourceMixin, generics.CreateAPIView):
|
||||
serializer_class = WorkSerializer
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user