Author SHA1 Message Date
Tris Forster 04bf1ab1f3 Improved auto tagging 2023-03-27 10:34:20 +11:00
Tris Forster fb700b15d6 Added search bar to collection list 2023-03-04 17:12:58 +11:00
Tris Forster 0bd2596ebc Added search bar to collection list 2023-03-04 17:06:28 +11:00
Tris Forster 1f0a336ed4 Added search bar to collection list 2023-03-04 17:02:18 +11:00
Tris Forster 220163bca2 Added Number 2023-03-03 19:56:22 +11:00
Tris Forster d1328ae1b1 Many interface changes 2023-03-03 16:07:30 +11:00
Tris Forster 69f747d5b5 Fixed part selection dropdown 2023-03-02 16:25:38 +11:00
Tris Forster 2a6554471d List works on project page 2023-03-02 13:28:24 +11:00
Tris Forster 6edc612ed6 Fixed gitignore 2023-03-02 12:30:39 +11:00
Tris Forster f441940e8c Layout tweeks 2023-03-02 12:15:19 +11:00
Tris Forster b1eaf9c7bc Added document extractor 2023-03-02 10:05:26 +11:00
Tris Forster a892b0bc41 Got api work import working 2023-03-02 07:09:38 +11:00
Tris Forster f1757be96e Dont tag files that aren't PDFs 2023-03-01 14:42:05 +11:00
Tris Forster 95ee92cef6 Fixed related work links 2023-03-01 14:13:52 +11:00
Tris Forster b11cfcd6ea Added primitive doc type checking 2023-03-01 13:56:52 +11:00
Tris Forster 6e1b26440f Added coverage testing 2023-03-01 13:39:56 +11:00
Tris Forster 4268b66b27 Added module management 2023-03-01 13:15:09 +11:00
Tris Forster f22961ad44 Couple of little fixes 2023-03-01 12:41:42 +11:00
Tris Forster caeee16657 Extended PROTETED_URLS tests 2023-03-01 12:41:10 +11:00
Tris Forster 4344cb978b Fixed reference to section type 2023-03-01 11:25:38 +11:00
Tris Forster a3d8cf0a21 Fixed reference to section type 2023-03-01 11:21:06 +11:00
Tris Forster 70aa7ae3a3 Fixed reference to section type 2023-03-01 11:18:35 +11:00
Tris Forster f840ee3d8b Couple of interface tweeks 2023-02-28 16:56:22 +11:00
Tris Forster 7d4f959146 Fixing work edit 2023-02-23 20:33:38 +11:00
Tris Forster 1cb00ebc0b Fixing bulk importer 2023-02-23 20:29:04 +11:00
Tris Forster 928173976b Got my music working again 2023-02-23 19:29:20 +11:00
Tris Forster d7892d355d Moved project filtering to the right place - the queryset 2023-02-23 19:18:23 +11:00
35 changed files with 685 additions and 330 deletions
+11 -9
View File
@@ -1,13 +1,15 @@
__pycache__ __pycache__
*.pyc *.pyc
db.sqlite3 *.sqlite3
credentials.json
credentials credentials
local_settings.py local_settings.py
env .coverage
old /env
test.* /data
static /old
teststore /static
cache /teststore
local_storage /cache
media /local_storage
/media
+5
View File
@@ -1,6 +1,11 @@
PYTHON=env/bin/python PYTHON=env/bin/python
DROPZONE=5.7.0 DROPZONE=5.7.0
test:
coverage run --include "app/*" --omit "*/migrations/*" app/manage.py test app
coverage html
coverage report
dev-setup: dev-setup:
env/bin/pip install -r requirements.txt env/bin/pip install -r requirements.txt
env/bin/pip install -r dev-requirements.txt env/bin/pip install -r dev-requirements.txt
+4 -2
View File
@@ -25,14 +25,16 @@ class BaseForm(forms.Form):
class ProjectForm(forms.ModelForm, BaseForm): class ProjectForm(forms.ModelForm, BaseForm):
class Meta: class Meta:
model = models.Project model = models.Project
fields = ['name', 'description', 'event_date'] fields = ['name', 'description', 'modules', 'event_date']
#widgets = { #widgets = {
# 'event_date': forms.DateTimeInput(attrs={'type': 'date'}) # 'event_date': forms.DateTimeInput(attrs={'type': 'date'})
#} #}
modules = forms.MultipleChoiceField(choices=[ (x, x.title()) for x in models.settings.POLYPHONIC_MODULES ],
widget=forms.CheckboxSelectMultiple, required=False)
class ResourceForm(forms.ModelForm, BaseForm): class ResourceForm(forms.ModelForm, BaseForm):
class Meta: class Meta:
+40 -14
View File
@@ -41,6 +41,19 @@ def rough_date(d):
def generate_code(length=9): def generate_code(length=9):
return "".join([ random.choice('0123456789') for _ in range(length) ]) return "".join([ random.choice('0123456789') for _ in range(length) ])
class EnsembleQuerySet(models.QuerySet):
def for_user(self, user, ensemble_keys=[], project_keys=[]):
if user.is_superuser:
return self
f = models.Q(slug__in=ensemble_keys) | models.Q(projects__in=project_keys)
if user.is_authenticated:
f |= models.Q(admins=user.pk)
return self.filter(f).distinct()
class Ensemble(models.Model): class Ensemble(models.Model):
''' A group that plays together ''' A group that plays together
@@ -57,11 +70,13 @@ class Ensemble(models.Model):
nonce = models.SmallIntegerField(default=1, nonce = models.SmallIntegerField(default=1,
help_text="Increment this to reset the authentication links") help_text="Increment this to reset the authentication links")
objects = EnsembleQuerySet.as_manager()
class Meta: class Meta:
ordering = ('slug', ) ordering = ('slug', )
def active_projects(self): def active_projects(self):
return self.projects.filter(active=True, event_date__gte=timezone.now()) return self.projects.active().current()
def has_admin(self, user): def has_admin(self, user):
if not user.is_authenticated: if not user.is_authenticated:
@@ -84,6 +99,23 @@ class Ensemble(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
class ProjectQuerySet(models.QuerySet):
def current(self):
return self.filter(models.Q(event_date__gte=(timezone.now()-timezone.timedelta(7))) | models.Q(event_date=None))
def active(self):
return self.filter(active=True)
def for_user(self, user, project_keys=[], ensemble_keys=[]):
if user.is_superuser:
return self
f = models.Q(pk__in=project_keys) | models.Q(ensemble__slug__in=ensemble_keys)
if user.is_authenticated:
f |= models.Q(ensemble__admins=user.pk)
return self.filter(f)
class Project(models.Model): class Project(models.Model):
''' A Project linked to an ensemble ''' A Project linked to an ensemble
@@ -98,6 +130,8 @@ class Project(models.Model):
nonce = models.SmallIntegerField(default=1, nonce = models.SmallIntegerField(default=1,
help_text="Increment this to reset the authentication links") help_text="Increment this to reset the authentication links")
objects = ProjectQuerySet.as_manager()
class Meta: class Meta:
ordering = ['active', 'event_date'] ordering = ['active', 'event_date']
@@ -113,6 +147,8 @@ class Project(models.Model):
@property @property
def rough_date(self): def rough_date(self):
if not self.event_date:
return "No timescale"
in_past, s = rough_date(self.event_date) in_past, s = rough_date(self.event_date)
if in_past: if in_past:
return f"{s} ago" return f"{s} ago"
@@ -134,19 +170,6 @@ class Project(models.Model):
def auth(self): def auth(self):
return sign_data(f'{self.pk}-{self.nonce}', 12) return sign_data(f'{self.pk}-{self.nonce}', 12)
@classmethod
def for_user(cls, user, project_keys=[], ensemble_keys=[]):
projects = cls.objects.select_related('ensemble')
if user.is_superuser:
return projects
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 projects.filter(f)
def __str__(self): def __str__(self):
return self.name return self.name
@@ -156,6 +179,9 @@ class Module(models.Model):
name = models.SlugField(max_length=20, choices=[ (x, x.title()) for x in settings.POLYPHONIC_MODULES ]) name = models.SlugField(max_length=20, choices=[ (x, x.title()) for x in settings.POLYPHONIC_MODULES ])
project = models.ForeignKey(Project, related_name="modules", on_delete=models.CASCADE) project = models.ForeignKey(Project, related_name="modules", on_delete=models.CASCADE)
def __str__(self):
return self.name
def resource_key(resource, filename): def resource_key(resource, filename):
return f'{resource.project.folder}/resources/{filename}' return f'{resource.project.folder}/resources/{filename}'
Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

@@ -0,0 +1,29 @@
document.addEventListener('DOMContentLoaded', () => {
// Get all "navbar-burger" elements
const navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);
// Add a click event on each of them
navbarBurgers.forEach( el => {
el.addEventListener('click', () => {
// Get the target from the "data-target" attribute
const target = document.getElementById(el.dataset.target);
// Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu"
el.classList.toggle('is-active');
target.classList.toggle('is-hidden-touch');
});
});
// set all active links
const here = location.toString();
for (const el of document.getElementsByTagName('a')) {
if (el.href == here) {
el.classList.add('is-active');
}
}
});
+8 -5
View File
@@ -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="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css">
<link rel="stylesheet" href="{% static 'interface/css/polyphonic.css' %}"></link> <link rel="stylesheet" href="{% static 'interface/css/polyphonic.css' %}"></link>
<script src="{% static 'interface/js/interface.js' %}"></script> <script src="{% static 'interface/js/interface.js' %}"></script>
<script src="//unpkg.com/alpinejs" defer></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js" defer></script>
<script src="//kit.fontawesome.com/c837098e5b.js" crossorigin="anonymous" defer></script>
<title>{% block title %}Polyphonic{% endblock %}</title> <title>{% block title %}Polyphonic{% endblock %}</title>
{% block media %}{% endblock %} {% block media %}{% endblock %}
<style>{% block style %}{% endblock %}</style>
</head> </head>
<body> <body>
{% block navigation %} {% block navigation %}
<nav class="navbar" role="navigation"> <nav class="navbar" role="navigation">
<div class="navbar-brand has-text-primary"> <div class="navbar-brand has-text-primary">
<a class="navbar-item" href="/"> <a class="navbar-item" href="/">
<span class="icon fancy is-size-2 mx-4"><i class="fas fa-random"></i></span> <span class="icon fancy is-size-2 is-size-4-mobile mx-4"><i class="fas fa-random"></i></span>
<span class="fancy is-size-2">Polyphonic</span> <span class="fancy is-size-2 is-size-4-mobile">Polyphonic</span>
</a> </a>
<span class="navbar-item is-hidden-mobile fancy is-size-5">Musical Ensemble Manager</span> <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 id="navbarMain" class="navbar-menu">
<div class="navbar-end"> <div class="navbar-end">
<span class="navbar-item is-size-4">{% firstof ensemble project.ensemble %}</span> <span class="navbar-item is-size-5-touch is-size-4-tablet">{% firstof ensemble project.ensemble %}</span>
</div> </div>
</div> </div>
@@ -47,8 +52,6 @@
{% endblock %} {% endblock %}
<!-- late load scripts --> <!-- late load scripts -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="https://kit.fontawesome.com/c837098e5b.js" crossorigin="anonymous"></script>
{% block scripts %} {% block scripts %}
{% endblock %} {% endblock %}
</body> </body>
@@ -21,14 +21,16 @@
{% block page %} {% block page %}
<h3 class="title">Projects for {{ensemble.name }}</h3> <h3 class="title">Projects for {{ensemble.name }}</h3>
<p>{{ ensemble.details|markdown }}</p> <div class="content">
{{ ensemble.details|markdown }}
</div>
<h4>Administrators</h4> <div class="block">
<ul> Contacts:
{% for admin in ensemble.admins.all %} {% for admin in ensemble.admins.all %}
<li><a href="mailto:{{ admin.email }}">{% firstof admin.get_full_name admin.get_username %}</a></li> <a href="mailto:{{ admin.email }}" class="tag">{% firstof admin.get_full_name admin.get_username %}</a>
{% endfor %} {% endfor %}
</ul> </div>
{% include 'interface/project_items.html' %} {% include 'interface/project_items.html' %}
@@ -39,11 +41,10 @@
<p class="card-header-title">Admin Details</p> <p class="card-header-title">Admin Details</p>
</header> </header>
<div class="card-content"> <div class="card-content">
<ul>
<p> <li><a href="{{ ensemble_link }}">Ensemble Sharing Link</a></li>
Joining instructions for participants<br/><br/> <li><a href="{% url 'project_create' ensemble.pk %}">Add a new project</a></li>
URL: <a href="{{ ensemble_link }}">{{ ensemble_link }}</a><br/> </ul>
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -19,22 +19,18 @@
<div class="card"> <div class="card">
<div class="card-content"> <div class="card-content">
<div class="media"> <div class="media">
<div class="media-left"> <div class="media-left">
<figure class="image is-48x48"> <figure class="image is-48x48">
<img src="https://bulma.io/images/placeholders/96x96.png" alt="Placeholder image"> <img src="https://www.gravatar.com/avatar/{{ ensemble.email }}?d=mp" alt="Placeholder image">
</figure> </figure>
</div> </div>
<div class="media-content"> <div class="media-content" style="min-height: 60px">
<a href="{% url 'ensemble_detail' ensemble.slug %}"> <a href="{% url 'ensemble_detail' ensemble.slug %}">
<p class="title is-4">{{ ensemble.name }}</p> <p class="title is-4">{{ ensemble.name }}</p>
</a> </a>
</div> </div>
</div> </div>
{% if ensemble.details %}
<div class="content">
{{ ensemble.details | markdown }}
</div>
{% endif %}
</div> </div>
<div class="card-footer"> <div class="card-footer">
{% with projects=ensemble.active_projects.count %} {% with projects=ensemble.active_projects.count %}
@@ -2,9 +2,7 @@
{% block content %} {% block content %}
<div class="columns mx-2">
<div class="columns">
<div class="column is-narrow is-hidden-touch" id="projectMenu"> <div class="column is-narrow is-hidden-touch" id="projectMenu">
<div style="margin: auto 2em;"> <div style="margin: auto 2em;">
<aside class="menu"> <aside class="menu">
@@ -14,7 +12,7 @@
<li><a href="{% url 'ensemble_list' %}">Ensembles</a></li> <li><a href="{% url 'ensemble_list' %}">Ensembles</a></li>
<li><a href="{% url 'project_list' %}">Projects</a></li> <li><a href="{% url 'project_list' %}">Projects</a></li>
{% if request.user.is_authenticated %} {% 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> <li><a href="{% url 'collection_list' %}">Collections</a></li>
{% endif %} {% endif %}
</ul> </ul>
@@ -27,13 +25,20 @@
<li><a class="nav-link" href="{% url 'item_list' project=project.pk %}">My Music</a></li> <li><a class="nav-link" href="{% url 'item_list' project=project.pk %}">My Music</a></li>
{% endif %} {% endif %}
{% for page in project.wiki_pages.all %} {% 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> href="{% url 'wiki' project=project.id pk=page.id %}">{{ page.title }}</a></li>
{% endfor %} {% endfor %}
<li><a role="tab" href="{% url 'resource_list' project=project.pk %}">Resources</a></li> <li><a role="tab" href="{% url 'resource_list' project=project.pk %}">Resources</a></li>
</ul> </ul>
{% endif %} {% 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> <p class="menu-label">Admin</p>
<ul class="menu-list"> <ul class="menu-list">
{% if request.user.is_staff %} {% if request.user.is_staff %}
@@ -76,9 +81,9 @@
</div> </div>
{% endif %} {% endif %}
<section class="section"> <div class="block">
{% if request.is_admin %} {% if request.is_admin %}
<div class="admin-tools is-pulled-right"> <div class="admin-tools is-pulled-right is-hidden-mobile">
{% block admin %} {% block admin %}
{% endblock %} {% endblock %}
</div> </div>
@@ -87,7 +92,7 @@
{% block page %} {% block page %}
No content No content
{% endblock %} {% endblock %}
</section> </div>
</div> </div>
</div> </div>
@@ -14,60 +14,71 @@
{% endblock %} {% endblock %}
{% block page %} {% block page %}
<div class="box"> <div class="columns is-multiline">
{% if project.event_date %} <div class="column ">
<h3 class="subtitle is-centered"> <div class="box">
<b>{{ project.event_date|date:"l jS F Y, g:i A" }}</b>
{% if project.has_happened %} {% if project.event_date %}
({{ project.event_date|roughtimesince }} ago) <h3 class="subtitle is-centered">
{% else %} <b>{{ project.event_date|date:"l jS F Y, g:i A" }}</b>
(in {{ project.event_date|roughtimeuntil }}) {% if project.has_happened %}
{% endif %} ({{ project.event_date|roughtimesince }} ago)
</h3> {% else %}
{% endif %} (in {{ project.event_date|roughtimeuntil }})
{% endif %}
<div class="block"> </h3>
{% if project.description %}
<p class="content">{{ project.description|markdown }}</p>
{% else %}
<p>No description</p>
{% endif %} {% endif %}
<div class="block">
{% if project.description %}
<p class="content">{{ project.description|markdown }}</p>
{% else %}
<p>No description</p>
{% endif %}
</div>
{% if project.owner %}
<div class="block">
{% if project.owner.email %}
The project owner is <a href="mailto:{{ project.owner.email }}">{{ project.owner }}</a>
{% else %}
The project owner is {{ project.owner }}.
{% endif %}
</div>
{% endif %}
</div>
</div> </div>
{% if 'library' in modules %} {% if 'library' in modules %}
<div class="block"> <div class="column is-one-third">
{% include 'library/project_detail.html' %} {% include 'library/project_detail.html' %}
</div> </div>
{% endif %} {% endif %}
{% if 'submission' in modules %} {% if 'submission' in modules %}
<div class="block"> <div class="column">
{% include 'submissions/project_detail.html' %} {% include 'submissions/project_detail.html' %}
</div> </div>
{% endif %}
{% if project.owner %}
<div class="block">
{% if project.owner.email %}
The project owner is <a href="mailto:{{ project.owner.email }}">{{ project.owner }}</a>
{% else %}
The project owner is {{ project.owner }}.
{% endif %}
</div>
{% endif %}
{% if request.is_admin %}
<div class="block">
<a href="{{ project_link }}">Project Link</a>
</div>
{% endif %} {% endif %}
</div> </div>
{% if request.is_admin %}
<div class="box">
<h3 class="subtitle">Admin Actions</h3>
<ul>
<li><a href="{{ project_link }}">Project Link</a></li>
{% if 'library' in modules %}
<li><a href="{% url 'item_list_manage' project=project.pk %}">Manage items</a></li>
{% endif %}
</ul>
</div>
{% endif %}
<!--
<div> <div>
<a href="{% url 'forget_resource' 'project' project.pk %}">Forget this project</a> <a href="{% url 'forget_resource' 'project' project.pk %}">Forget this project</a>
</div> </div>
-->
{% endblock %} {% endblock %}
@@ -12,14 +12,17 @@
<p class="card-header-icon" style="color: black;">{{ project.rough_date }}</p> <p class="card-header-icon" style="color: black;">{{ project.rough_date }}</p>
</header> </header>
</a> </a>
<div class="card-content"> <div class="card-content" style="height: 100px; overflow: hidden">
<div class="content" style="height: 100px; overflow: hidden"> <div class="content">
{{ project.description | markdown }} {{ project.description | markdown }}
{% if not ensemble %}
<div class="has-text-centered"><i><small>With {{ project.ensemble }}</small></i></div>
{% endif %}
</div> </div>
</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>
</div> </div>
{% empty %} {% empty %}
@@ -9,6 +9,7 @@
{% endblock %} {% endblock %}
{% block page %} {% block page %}
<h3 class="subtitle">Resources</h3>
<div class="columns is-multiline"> <div class="columns is-multiline">
{% for resource in object_list %} {% for resource in object_list %}
{% with download=resource.file.url %} {% with download=resource.file.url %}
@@ -51,7 +52,7 @@
{% endwith %} {% endwith %}
{% empty %} {% empty %}
<div class="column"> <div class="column">
<p>There are resources for this project</p> <p>There are no resources for this project</p>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
+10
View File
@@ -12,6 +12,8 @@ class AccessTestCase(TestCase):
PROJECTS = () PROJECTS = ()
PROTECTED_URLS = ()
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@@ -39,6 +41,14 @@ class AccessTestCase(TestCase):
return 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): def login(self, user, passwd):
response = self.client.post('/login', {'username': user, 'password': passwd}) response = self.client.post('/login', {'username': user, 'password': passwd})
self.assertEqual(response.status_code, 302, f"Failed to login as {user}") self.assertEqual(response.status_code, 302, f"Failed to login as {user}")
+14
View File
@@ -26,6 +26,20 @@ class InterfaceAccessTestCase(AccessTestCase):
{'name': 'Current Repertoire', 'ensemble': 'be-sharps'}, {'name': 'Current Repertoire', 'ensemble': 'be-sharps'},
) )
PROTECTED_URLS = (
'/ensembles/be-sharps',
'/ensembles/be-sharps/new-project',
'/projects/3',
'/projects/3/resources',
'/projects/3/resources/add',
'/admin/interface/ensemble/',
'/admin/interface/project/',
'/admin/interface/resource/',
'/admin/interface/wikipage/',
)
def test_bad_login(self): def test_bad_login(self):
with self.assertRaisesMessage(self.failureException, 'Failed to login as admin'): with self.assertRaisesMessage(self.failureException, 'Failed to login as admin'):
self.login('admin', 'admin') self.login('admin', 'admin')
+37 -19
View File
@@ -35,7 +35,7 @@ class AuthorizedResourceMixin(object):
def is_authorized(self): def is_authorized(self):
"By default check if superuser or a signed request" "By default check if superuser or a signed request"
if self.request.is_admin: if self.request.is_admin:
logger.debug("is_authorized: superuser") #logger.debug("is_authorized: superuser")
return True return True
if 'sig' in self.request.GET: if 'sig' in self.request.GET:
@@ -51,12 +51,12 @@ class AuthorizedResourceMixin(object):
def is_authorized_key(self, resource, key, auth): def is_authorized_key(self, resource, key, auth):
current = self.get_authorized_keys(resource).get(str(key), None) current = self.get_authorized_keys(resource).get(str(key), None)
if current is None: if current is None:
logger.debug("is_authorized_key: %s %s not in session", resource, key) #logger.debug("is_authorized_key: %s %s not in session", resource, key)
return False return False
if auth == current: if auth == current:
return True return True
logger.info("Authorisation revoked") #logger.info("Authorisation revoked")
self.del_authorized_key(resource, key) self.del_authorized_key(resource, key)
return False return False
@@ -196,19 +196,22 @@ class EnsembleListView(AuthorizedResourceMixin, ListView):
return True return True
def get_queryset(self): 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: #if self.request.is_admin:
return ensembles # return ensembles
# limit to registered 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 # or ensembles where the user is admin
if self.request.user.is_authenticated: #if self.request.user.is_authenticated:
f |= Q(admins=self.request.user.pk) # f |= Q(admins=self.request.user.pk)
return ensembles.filter(f).distinct() #return ensembles.filter(f).distinct()
class EnsembleDetailView(EnsembleMixin, DetailView): class EnsembleDetailView(EnsembleMixin, DetailView):
@@ -232,12 +235,11 @@ class EnsembleDetailView(EnsembleMixin, DetailView):
if inactive: if inactive:
projects = projects.order_by('-pk') projects = projects.order_by('-pk')
else: else:
projects = projects.filter(active=True, event_date__gte=timezone.now()-timezone.timedelta(7)) projects = projects.active().current()
data['inactive'] = inactive data['inactive'] = inactive
data['object_list'] = projects data['object_list'] = projects
if self.request.is_admin: if self.request.is_admin:
#data['ensemble_link'] = signed_url(f'{self.request.path}?nonce={self.ensemble.nonce}')
data['ensemble_link'] = self.request.path + "?auth=" + self.ensemble.auth() data['ensemble_link'] = self.request.path + "?auth=" + self.ensemble.auth()
return data return data
@@ -254,14 +256,12 @@ class ProjectListView(ProjectMixin, ListView):
return True return True
def get_project_queryset(self): def get_project_queryset(self):
return models.Project.for_user(self.request.user, return models.Project.objects.for_user(self.request.user,
self.get_authorized_keys('project'), self.get_authorized_keys('project'),
self.get_authorized_keys('ensemble')) self.get_authorized_keys('ensemble'))
def get_queryset(self): def get_queryset(self):
qs = self.get_project_queryset() return self.get_project_queryset().current().active()
f = Q(event_date__gte=(timezone.now()-timezone.timedelta(7))) | Q(event_date=None)
return qs.filter(f, active=True)
class ProjectCreateView(EnsembleMixin, CreateView): class ProjectCreateView(EnsembleMixin, CreateView):
admin_required = True admin_required = True
@@ -270,11 +270,16 @@ class ProjectCreateView(EnsembleMixin, CreateView):
title = "Add a new project" title = "Add a new project"
form_class = forms.ProjectForm form_class = forms.ProjectForm
def form_valid(self, form): def form_valid(self, form):
self.object = form.save(commit=False) self.object = form.save(commit=False)
self.object.ensemble = self.ensemble self.object.ensemble = self.ensemble
self.object.owner = self.request.user self.object.owner = self.request.user
self.object.save() self.object.save()
for module in form.cleaned_data['modules']:
self.object.modules.create(name=module)
return redirect('project_detail', project=self.object.pk) return redirect('project_detail', project=self.object.pk)
class ProjectDetailView(ProjectMixin, DetailView): class ProjectDetailView(ProjectMixin, DetailView):
@@ -306,12 +311,25 @@ class ProjectUpdateView(ProjectMixin, UpdateView):
def get_object(self): def get_object(self):
return self.project return self.project
def get_initial(self):
data = super().get_initial()
data['modules'] = self.object.active_modules
print(data)
return data
def form_valid(self, form):
self.object = form.save()
current = set(self.object.active_modules)
desired = set(form.cleaned_data['modules'])
self.object.modules.exclude(name__in=desired).delete()
for module in desired-current:
self.object.modules.create(name=module)
return redirect('project_detail', self.kwargs['project'])
@property @property
def cancel_url(self): def cancel_url(self):
return self.get_success_url() return resolve_url('project_detail', self.kwargs['project'])
def get_success_url(self):
return resolve_url('project_detail', project=self.kwargs['project'])
# Old Makefile from submission module # Old Makefile from submission module
#class ProjectMakefileView(EnsembleMixin, DetailView): #class ProjectMakefileView(EnsembleMixin, DetailView):
+1 -1
View File
@@ -34,7 +34,7 @@ admin.site.register(models.Work, WorkAdmin)
class SectionInline(admin.TabularInline): class SectionInline(admin.TabularInline):
model = models.Section model = models.Section
fields = ['type', 'tag', 'ordinal', 'start', 'end', 'page'] fields = ['tag', 'start', 'end', 'page']
class DocumentAdmin(admin.ModelAdmin): class DocumentAdmin(admin.ModelAdmin):
list_display = ['work', '__str__'] list_display = ['work', '__str__']
+6 -2
View File
@@ -25,5 +25,9 @@ class PlaylistAddForm(forms.Form):
def save(self): def save(self):
self.instance.works.add(self.cleaned_data['work']) self.instance.works.add(self.cleaned_data['work'])
class ProjectSelectForm(forms.Form): class ProjectEnsembleChoiceField(forms.ModelChoiceField):
project = forms.ModelChoiceField(queryset=Project.objects.all()) def label_from_instance(self, obj):
return f"{obj.ensemble.name} - {obj.name}"
class ProjectSelectForm(BaseForm):
project = ProjectEnsembleChoiceField(queryset=Project.objects.all())
+33 -15
View File
@@ -9,6 +9,7 @@ from django.core.files.storage import get_storage_class
from django.db.models import Q, Count, Min, Max from django.db.models import Q, Count, Min, Max
import re import re
import os.path
from byostorage.user import BYOStorage from byostorage.user import BYOStorage
from byostorage.cached import CachedStorage from byostorage.cached import CachedStorage
@@ -36,8 +37,6 @@ class Orchestration(models.Model):
Stores a list of instrument codes as a single entry (space delimited). Stores a list of instrument codes as a single entry (space delimited).
Can be global or ensemble specific Can be global or ensemble specific
""" """
# 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) collection = models.ForeignKey('Collection', on_delete=models.CASCADE, related_name="custom_orchestrations", null=True, blank=True)
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
instruments = models.TextField() instruments = models.TextField()
@@ -204,19 +203,25 @@ class Work(models.Model):
def folder(self): def folder(self):
return f"{slugify(self.composer)}/{slugify(self.name)}-{self.pk:04d}" return f"{slugify(self.composer)}/{slugify(self.name)}-{self.pk:04d}"
def extract(self, *tags): def tagged_sections(self, *tags):
qs = self.docs.filter(sections__tag__in=tags) qs = self.docs.filter(sections__tag__in=tags)
qs = qs.annotate(Count('sections'), end=Min('sections__end'), start=Max('sections__start')) \ qs = qs.annotate(Count('sections'), end=Min('sections__end'), start=Max('sections__start')) \
.filter(sections__count=len(tags)) .filter(sections__count=len(tags))
return qs
return list(qs.values_list('upload', 'start', 'end'))
def list_sections(self, *tags):
return list(self.tagged_sections(*tags).values_list('upload', 'start', 'end'))
@property @property
def digital_parts(self): def digital_parts(self):
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()) 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 @property
def physical_parts(self): def physical_parts(self):
@@ -234,10 +239,6 @@ class Work(models.Model):
def meta(self): def meta(self):
return self.meta_info.exclude(name='tag') return self.meta_info.exclude(name='tag')
@property
def active_projects(self):
return self.projects.filter(active=True)
@property @property
def current_loans(self): def current_loans(self):
return self.project_items.filter(checkout__lte=now(), returned=None).select_related('project') return self.project_items.filter(checkout__lte=now(), returned=None).select_related('project')
@@ -276,12 +277,20 @@ class Work(models.Model):
return f"{composer[:4]}-{work}-{self.pk:05d}".upper() return f"{composer[:4]}-{work}-{self.pk:05d}".upper()
def assigned_instruments(self): 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): def unassigned_instruments(self):
assigned = set(self.assigned_instruments()) assigned = set(self.assigned_instruments())
return [ x for x in self.orchestration.as_list() if not x[0] in assigned ] return [ x for x in self.orchestration.as_list() if not x[0] in assigned ]
def music_tags(self):
tags = dict(self.orchestration.as_list())
for section in Section.objects.filter(doc__work_id=self.pk):
tags.setdefault(section.tag, section.name)
return tags.items()
def __str__(self): def __str__(self):
return f"{self.name} ({self.composer})" return f"{self.name} ({self.composer})"
@@ -315,15 +324,21 @@ class Document(models.Model):
DOCTYPE_PDF = 1 DOCTYPE_PDF = 1
DOCTYPE_AUDIO = 2 DOCTYPE_AUDIO = 2
DOCTYPE_VIDEO = 3 DOCTYPE_VIDEO = 3
DOCTYPE_SOURCE = 4 DOCTYPE_MISC= 4
DOCTYPES = ( DOCTYPES = (
(DOCTYPE_PDF, 'PDF'), (DOCTYPE_PDF, 'PDF'),
(DOCTYPE_AUDIO, 'Audio'), (DOCTYPE_AUDIO, 'Audio'),
(DOCTYPE_VIDEO, 'Video'), (DOCTYPE_VIDEO, 'Video'),
(DOCTYPE_SOURCE, 'Source'), (DOCTYPE_MISC, 'Misc'),
) )
DOCTYPE_MAP = {
'.pdf': DOCTYPE_PDF,
'.mp3': DOCTYPE_AUDIO,
'.mp4': DOCTYPE_VIDEO,
}
work = models.ForeignKey('Work', on_delete=models.CASCADE, related_name="docs") work = models.ForeignKey('Work', on_delete=models.CASCADE, related_name="docs")
doctype = models.PositiveSmallIntegerField(choices=DOCTYPES, default=DOCTYPE_PDF) doctype = models.PositiveSmallIntegerField(choices=DOCTYPES, default=DOCTYPE_PDF)
upload = models.FileField(upload_to=doc_upload_filename, storage=library_storage) upload = models.FileField(upload_to=doc_upload_filename, storage=library_storage)
@@ -333,6 +348,9 @@ class Document(models.Model):
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
self.upload.delete(save=False) self.upload.delete(save=False)
return super().delete(*args, **kwargs) return super().delete(*args, **kwargs)
def filename(self):
return os.path.basename(self.upload.name)
def __str__(self): def __str__(self):
return self.upload.name return self.upload.name
+35 -3
View File
@@ -6,16 +6,19 @@ mvmt Movement
ex Excerpt ex Excerpt
sec Section sec Section
pce Piece pce Piece
no Number
""" """
# taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_MusicTags # taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_MusicTags
# Abbreviations at the top will take precidence in reverse lookups # Include any aliases at the top
INSTRUMENTS = """ INSTRUMENTS = """
score Score score Score
cb Double bass cb Double Bass
mall Mallet percussion mall Mallet Percussion
vln Violin
vla Viola
acc Accordion acc Accordion
afl Alto flute afl Alto flute
@@ -228,6 +231,35 @@ class MusicTag(namedtuple('MusicTag', ('name', 'variant'), defaults=[None])):
return f"{self.name} {self.variant}" return f"{self.name} {self.variant}"
return self.name return self.name
import re
PATTERNS = [re.compile('([A-Za-z]+)[_\- ]*(\d+)'), re.compile('([A-Za-z]+)()')]
def auto_tag(filename):
'''
>>> auto_tag('Ode to Joy - Violin 1.pdf')
MusicTag(name='Violin', variant=1)
>>> auto_tag('Ode to Joy_Cello.pdf')
MusicTag(name='Cello', variant=None)
>>> auto_tag('Ode to Joy violin - 1.pdf')
MusicTag(name='Violin', variant=1)
>>> auto_tag('Ode to Joy - vla.pdf')
MusicTag(name='Viola', variant=None)
>>> auto_tag('Ode to Joy - fl-2 (piccolo).pdf')
MusicTag(name='Flute', variant=2)
'''
for pattern in PATTERNS:
for inst, ordinal in pattern.findall(filename):
inst = inst.lower()
ordinal = int(ordinal) if ordinal else None
if inst in MUSIC_TAG_BY_NAME:
return MusicTag(inst.title(), ordinal)
if inst in MUSIC_NAME_BY_TAG:
return MusicTag(MUSIC_NAME_BY_TAG[inst], ordinal)
if __name__ == "__main__": if __name__ == "__main__":
import doctest import doctest
print(doctest.testmod()) print(doctest.testmod())
+2 -2
View File
@@ -24,12 +24,12 @@ def extract_and_concat(items):
if count == 0: if count == 0:
continue continue
if start is None: if not start:
sections.append(source) sections.append(source)
else: else:
if end is None: if not end:
end = start end = start
dest = os.path.join(d.name, f'section_{i}.pdf') dest = os.path.join(d.name, f'section_{i}.pdf')
@@ -3,6 +3,19 @@
{% block page %} {% block page %}
<h3 class="title">Library collections for {% firstof request.user.first_name request.user.username %}</h3> <h3 class="title">Library collections for {% firstof request.user.first_name request.user.username %}</h3>
<div class="block">
<form method="GET" action="{% url 'work_list' %}">
<div class="field has-addons">
<div class="control is-expanded">
<input class="input" name="filter" type="text" placeholder="Find a work" value="{{ request.GET.filter }}"/>
</div>
<div class="control">
<a class="button" href="?"><i class="fas fa-times"></i></a>
</div>
</div>
</form>
</div>
<div class="columns is-multiline"> <div class="columns is-multiline">
{% for collection in object_list %} {% for collection in object_list %}
<div class="column is-half"> <div class="column is-half">
@@ -75,11 +75,11 @@
</div> </div>
</div> </div>
<ul id="unassigned-area"> <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> <li class="is-clickable" onclick="assignInstrument('{{tag}}', this)")>{{ inst }}</li>
{% endfor %} {% endfor %}
<li><a onclick="document.getElementById('add-modal').classList.add('is-active')">Add instrument</a></li>
</ul> </ul>
<a onclick="document.getElementById('add-modal').classList.add('is-active')">Add Tag</a>
</div> </div>
<div class="column is-narrow"> <div class="column is-narrow">
<div class="has-text-centered"> <div class="has-text-centered">
@@ -122,7 +122,7 @@
<input type="number" class="input" max="4" min="1" size="3" id="add-instrument-variant"/> <input type="number" class="input" max="4" min="1" size="3" id="add-instrument-variant"/>
</span> </span>
<span class="control"> <span class="control">
<button class="button is-primary" onclick="addInstrument()">Add</button> <button class="button is-primary" onclick="addInstrument();">Add</button>
</span> </span>
</div> </div>
</section> </section>
@@ -301,12 +301,19 @@
variant.value = ''; variant.value = '';
name.value = ''; name.value = '';
let li = document.createElement('li');
li.classList.add("is-clickable");
li.addEventListener('click', () => assignInstrument(tag, li));
li.innerHTML = get_instrument(tag);
document.getElementById('unassigned-area').appendChild(li);
addTag(tag, pageNum, pageNum); addTag(tag, pageNum, pageNum);
closeAddModal();
} }
function assignInstrument(tag, el) { function assignInstrument(tag, el) {
addTag(tag, pageNum, pageNum); addTag(tag, pageNum, pageNum);
el.remove(); //el.remove();
} }
function addTag(tag, start, end) { function addTag(tag, start, end) {
@@ -334,11 +341,6 @@
del.className = "icon is-action"; del.className = "icon is-action";
del.innerHTML = '<i class="fas fa-trash-alt" title="Remove this tag"></i>'; del.innerHTML = '<i class="fas fa-trash-alt" title="Remove this tag"></i>';
del.addEventListener('click', () => { 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(); el.remove();
dirty=true; dirty=true;
}); });
@@ -9,8 +9,10 @@
</td> </td>
<td class="has-text-right" style="white-space: nowrap;"> <td class="has-text-right" style="white-space: nowrap;">
{% if request.is_admin %} {% if request.is_admin %}
{% if doc.doctype == 1 %}
<a href="{% url 'document_annotate' collection.pk doc.pk %}"><i class="fas fa-tags" <a href="{% url 'document_annotate' collection.pk doc.pk %}"><i class="fas fa-tags"
title="Manage Tags"></i></a> 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> <a href="{% url 'document_delete' collection.pk doc.pk %}"><i class="fas fa-trash-alt" title="Delete Document"></i></a>
{% endif %} {% endif %}
</td> </td>
+29 -21
View File
@@ -1,21 +1,14 @@
{% extends "interface/project_base.html" %} {% extends "interface/project_base.html" %}
{% block admin %}
<a class="button is-link" href="{% url 'item_list_manage' project.pk %}">
<span class="icon"><i class="fas fa-list"></i></span>
<span>Change items</span>
</a>
{% endblock %}
{% block page %} {% block page %}
<form action="" method="post" target="_blank"> <form action="" method="post" target="_blank">
{% csrf_token %} {% csrf_token %}
<div> <div>
<div class="field is-grouped is-grouped-centered"> <div class="field is-grouped is-grouped-centered is-grouped-multiline">
<div class="field has-addons control"> <div class="field has-addons control has-addons-centered is-expanded">
<span class="control"> <span class="control is-hidden-mobile">
<a class="button is-static">Your Instrument</a> <a class="button is-static">Instrument</a>
</span> </span>
<span class="control"> <span class="control">
@@ -47,12 +40,12 @@
</div> </div>
</div> </div>
<table class="table is-striped is-fullwidth"> <table class="table is-striped mx-auto">
<thead> <thead>
<tr> <tr>
<th/> <th/>
<th>Piece</th> <th>Piece</th>
<th>Running time</th> <th class="is-hidden-mobile">Running time</th>
<th>Part</th> <th>Part</th>
<th/> <th/>
</tr> </tr>
@@ -63,25 +56,27 @@
<td>{{ forloop.counter }}.</td> <td>{{ forloop.counter }}.</td>
<td> <td>
{% if request.is_admin %} {% if request.is_admin %}
<a href="{% url 'work_detail' item.work.pk %}">{{ item.work.name }}</a> <a href="{% url 'work_detail' item.work.collection.pk item.work.pk %}">{{ item.work.name }}</a>
{% else %} {% else %}
{{ item.work.name }} {{ item.work.name }}
{% endif %} {% endif %}
</td> </td>
<td>{% firstof item.work.running_time "------" %}</td> <td class="is-hidden-mobile">{% firstof item.work.running_time "------" %}</td>
<td class="select-cell"> <td class="select-cell">
<input type="hidden" name="works" value="{{ item.work.pk }}"/> <input type="hidden" name="works" value="{{ item.work.pk }}"/>
<span class="select is-small"> <span class="select is-small" style="width: 100%">
<select name="instruments"> <select name="instruments" id="part-{{ item.work.pk }}" style="width: 100%">
<option value='-'>None</option> <option value='-'>None</option>
{% for part in item.work.digital_parts %} {% for tag, name in item.work.digital_parts %}
<option value='{{ part.tag }}'>{{ part.instrument }}</option> <option value='{{ tag }}'>{{ name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</span> </span>
</td> </td>
<td> <td>
<a href=""><i class="fas fa-download"></i></a> <span class="is-action" onclick="downloadPart({{ item.work.collection_id }}, {{ item.work.pk }})">
<i class="fas fa-download" title="Download Part"></i>
</span>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -90,11 +85,13 @@
<tr> <tr>
<td/> <td/>
<td/> <td/>
<td>{{ running_time }}</td> <td>{% firstof running_time "------" %}</td>
<td/> <td/>
<td> <td>
<!--
<button class="button is-link is-small" type="submit"><span class="icon"><i class="fas fa-copy"></i></span><span>Single combined PDF</span></button> <button class="button is-link is-small" type="submit"><span class="icon"><i class="fas fa-copy"></i></span><span>Single combined PDF</span></button>
<a class="button is-link is-small"><span class="icon"><i class="fas fa-archive"></i></span><span>Individual files (zipped)</span></a> <a class="button is-link is-small"><span class="icon"><i class="fas fa-archive"></i></span><span>Individual files (zipped)</span></a>
-->
</td> </td>
</tr> </tr>
</tfoot> </tfoot>
@@ -154,6 +151,17 @@ function selectParts(inst, part) {
} }
function downloadPart(collection, work) {
let part = document.getElementById("part-" + work).value;
if (part == '-') {
alert("No part selected!");
return;
}
console.log(part);
let url = "/collections/" + collection + "/works/" + work + "/download?tag=" + part;
window.location = url;
}
document.getElementById("instrument-name").value = localStorage.getItem('instrument-name', 'All'); document.getElementById("instrument-name").value = localStorage.getItem('instrument-name', 'All');
document.getElementById("part-preference").value = localStorage.getItem('part-preference', 'Any'); document.getElementById("part-preference").value = localStorage.getItem('part-preference', 'Any');
updateParts(); updateParts();
@@ -1,12 +1,50 @@
{% with project.items.count as items %} <div class="panel">
{% if items %} <p class="panel-heading">
<p> Items
There {{ items|pluralize:"is,are" }} currently {{ items}} item{{ items|pluralize }} for this project.<br/> </p>
Click the '<a href="{% url 'item_list' project=project.pk %}">My Music</a>' button below to access your parts...
</p> {% for item in project.items.all %}
{% else %} {% with work=item.work %}
<p> <a class="panel-block" onclick="showDownloadOptions('{% url 'work_parts' work.collection_id work.pk %}')">
There are no items listed yet for this project - please check back later... {{ item.work.name }}
</p> </a>
{% endif %} {% endwith %}
{% endwith %} {% empty %}
<span class="panel-block">
There are no items listed yet for this project - please check back later...
</span>
{% endfor %}
</div>
<div class="modal" id="download-modal">
<div class="modal-background" onclick="closeDownloadModal()"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Parts</p>
<button class="delete" aria-label="close" onclick="closeDownloadModal()"></button>
</header>
<section class="modal-card-body" id="download-target">
</section>
</div>
</div>
<script>
function showDownloadOptions(url) {
let target = document.getElementById("download-target");
target.innerHTML = "Loading available parts...";
document.getElementById("download-modal").classList.add('is-active');
fetch(url).then((data) => {
console.log(data);
data.text().then((text) => {
target.innerHTML = text;
});
});
}
function closeDownloadModal() {
document.getElementById("download-modal").classList.remove('is-active');
}
</script>
@@ -2,6 +2,9 @@
<ul class="menu-list"> <ul class="menu-list">
{% if project %} {% if project %}
<li><a href="{% url 'item_list' project=project.pk %}">My Music</a></li> <li><a href="{% url 'item_list' project=project.pk %}">My Music</a></li>
{% endif %}
{% if collection %}
{% endif %} {% endif %}
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<li><a href="{% url 'work_list' %}">Library</a></li> <li><a href="{% url 'work_list' %}">Library</a></li>
+14 -12
View File
@@ -49,7 +49,7 @@
</table> </table>
{% if work.parent %} {% 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> </p>
{% endif %} {% endif %}
@@ -57,7 +57,7 @@
<h3>Related</h3> <h3>Related</h3>
<ul> <ul>
{% for related in work.related_works.all %} {% for related in work.related_works.all %}
<li><a href="{% url 'work_detail' related.pk %}">{{ related.name }} - {{ related.composer }}</a></li> <li><a href="{% url 'work_detail' related.collection.pk related.pk %}">{{ related.name }} - {{ related.composer }}</a></li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
@@ -87,15 +87,17 @@
Digital Parts Digital Parts
</h4> </h4>
<div class="tags"> <div class="tags">
{% if work.digital_parts %} {% with sections=work.digital_parts %}
<a class="tag is-danger" href="{% url 'work_partset' collection.pk work.pk %}">Full Set</a> {% if sections %}
{% endif %} <a class="tag is-danger" href="{% url 'work_partset' collection.pk work.pk %}">Full Set</a>
{% for section in work.digital_parts %} {% endif %}
<a class="tag is-info" href="{% url 'part_download' collection.pk section.pk section.filename %}" {% for tag, name in sections %}
target="section_{{ section.pk }}" rel="">{{ section.name }}</a> <a class="tag is-info" href="{% url 'work_download' collection.pk work.pk %}?tag={{ tag }}"
{% empty %} target="section_{{ tag }}" rel="">{{ name }}</a>
<p class="is-italic">No digital parts available</p> {% empty %}
{% endfor %} <p class="is-italic">No digital parts available</p>
{% endfor %}
{% endwith %}
</div> </div>
</div> </div>
</div> </div>
@@ -129,7 +131,7 @@
{% if request.is_admin %} {% if request.is_admin %}
<div class="column is-one-quarter"> <div class="column is-one-quarter">
<h4 class="is-size-5">Upload files</h4> <h4 class="is-size-5">Upload files</h4>
<form action="{% url 'document_add' 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 %} {% csrf_token %}
</form> </form>
</div> </div>
+2 -8
View File
@@ -29,10 +29,7 @@
<th>Work</th> <th>Work</th>
<th>Composer</th> <th>Composer</th>
<th class="is-hidden-mobile">Edition</th> <th class="is-hidden-mobile">Edition</th>
<th class="is-hidden-touch">Collection</th> {% if not collection %}<th class="is-hidden-touch">Collection</th>{% endif %}
{% if request.is_admin %}
<th class="is-hidden-mobile">Copies</th>
{% endif %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -41,10 +38,7 @@
<td><a href="{% url 'work_detail' collection=work.collection.pk pk=work.pk %}">{{ work.name }}</a></td> <td><a href="{% url 'work_detail' collection=work.collection.pk pk=work.pk %}">{{ work.name }}</a></td>
<td title="{{ work.composer }}">{{ work.composer|truncatewords:3 }}</td> <td title="{{ work.composer }}">{{ work.composer|truncatewords:3 }}</td>
<td class="is-hidden-mobile" title="{{ work.edition }}">{{ work.edition|truncatewords:2 }}</td> <td class="is-hidden-mobile" title="{{ work.edition }}">{{ work.edition|truncatewords:2 }}</td>
<td class="is-hidden-touch">{{ work.collection.name }}</td> {% if not collection %}<td class="is-hidden-touch">{{ work.collection.name }}</td>{% endif %}
{% if request.is_admin %}
<td class="is-hidden-mobile {{ work.is_available|yesno:'has-text-success,has-text-danger' }}">{{ work.available }}</td>
{% endif %}
</tr> </tr>
{% empty %} {% empty %}
<tr><td colspan="4">No works found</td></tr> <tr><td colspan="4">No works found</td></tr>
@@ -0,0 +1,25 @@
<h3 class="subtitle">{{ work.name }}</h3>
<div class="block">
{% for tag, name in work.digital_parts %}
<a class="tag is-info" href="{% url 'work_download' work.collection_id work.pk %}?tag={{ tag }}">{{ name }}</a>
{% endfor %}
</div>
<h3 class="subtitle">Files</h3>
<table class="table is-narrow is-fullwidth">
<tbody>
{% for doc in work.pdfs %}
<tr>
<td><a href="{{ doc.upload.url }}">{{ doc.filename }}</a></td>
</tr>
{% empty %}
<tr>
<td>There are no files available for this work</td>
</tr>
{% endfor %}
</tbody>
</table>
<p>
<a href="{% url 'work_detail' work.collection_id work.pk %}">More details...</a>
</p>
+40 -24
View File
@@ -3,35 +3,51 @@
{% block page %} {% block page %}
<h3 class="subtitle"><a href="{% url 'work_detail' collection.pk work.pk %}">{{ work.name }}</a></h3>
<p class="block">
You can generate a custom partset for printing - select the number of copies of each you want...
</p>
<div class="columns is-centered">
<div class="column is-narrow">
<form action="" method="post" target="_blank"> <form action="" method="post" target="_blank">
{% csrf_token %} {% csrf_token %}
<table class="table">
<thead>
<div class="admin-tools is-pulled-right"> </thead>
<button type="submit" class="button is-link"> <tbody>
<span class="icon"><i class="fas fa-print"></i></span> {% for tag, name in work.digital_parts %}
<span>Print Set</span> <tr>
</button> <td>{{ name }}</td>
<a class="button is-link is-light" href="{% url 'work_detail' collection.pk object.pk %}"> <td>
<span>Cancel</span> <input name="parts" type="hidden" value="{{ tag }}">
</a> <input class="input is-small" name="copies" type="number" value="{% if tag == 'score' %}0{% else %}1{% endif %}">
</div> </td>
</tr>
<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>
{% endfor %} {% endfor %}
</tbody>
</table>
<div class="field is-grouped">
<div class="control">
<button class="button is-link">
<span class="icon"><i class="fas fa-print"></i></span>
<span>Print Set</span>
</button>
</div>
<div class="control">
<a class="button is-link is-light" href="{% url 'work_detail' collection.pk object.pk %}">
<span>Cancel</span>
</a>
</div>
</div> </div>
</form> </form>
</div>
</div>
{% endblock %} {% endblock %}
+51 -28
View File
@@ -1,8 +1,11 @@
from interface.tests import AccessTestCase from interface.tests import AccessTestCase
from django.contrib.auth.models import User from byostorage.user import UserStorage
from interface.models import Ensemble, Project
from . import models from . import models
from .views.api import WorkSerializer
import tempfile
import json
class LibraryTestCase(AccessTestCase): class LibraryTestCase(AccessTestCase):
@@ -30,7 +33,8 @@ class LibraryTestCase(AccessTestCase):
) )
WORKS = ( WORKS = (
{'name': 'Baby on Board', 'collection': 'ned'}, {'name': 'Baby on Board', 'collection': 'ned', 'docs': [{'upload': 'local:baby_on_board.pdf'}]},
{'name': 'Star Spangled Banner', 'collection': 'sel'},
) )
PROTECTED_URLS = ( PROTECTED_URLS = (
@@ -41,47 +45,61 @@ class LibraryTestCase(AccessTestCase):
'/collections/2/works/1/partset', '/collections/2/works/1/partset',
'/collections/2/works/1/add_to_project', '/collections/2/works/1/add_to_project',
'/collections/2/works/1/upload', '/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 @classmethod
def setUpTestData(cls): def setUpTestData(cls):
super().setUpTestData() 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 = {} cls.collections = {}
for details in cls.COLLECTIONS: for details in cls.COLLECTIONS:
admins = details.pop('admins', []) admins = details.pop('admins', [])
obj = models.Collection.objects.create(**details) obj = models.Collection.objects.create(storage=cls.storage, **details)
for admin in admins: for admin in admins:
obj.administrators.add(cls.users[admin]) obj.administrators.add(cls.users[admin])
cls.collections[details['prefix']] = obj cls.collections[details['prefix']] = obj
cls.works = {} cls.works = {}
for details in cls.WORKS: for details in cls.WORKS:
collection = details.pop('collection') collection = cls.collections[details.pop('collection')]
cls.works[details['name']] = models.Work.objects.create(collection=cls.collections[collection], **details) #details.setdefault('docs', [])
#details.setdefault('meta_info', [])
#s = WorkSerializer(data=details)
#assert s.is_valid(), s.errors
#s.save(collection_id=collection.pk)
docs = details.pop('docs', [])
obj = models.Work.objects.create(collection=collection, **details)
for doc in docs:
obj.docs.create(**doc)
cls.works[details['name']] = obj
def oldSetUp(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")
self.be_sharps = self.homer.ensembles.create(name='Be Sharps', code="barbershop") def setUp(self):
self.sesd = self.dewey.ensembles.create(name="Springfield Elementary School Band", code="sax") pass
self.sel = self.lisa.collections.create(name="Springfield Elementary Library") @classmethod
self.flanders = self.ned.collections.create(name="Neds Shed") def tearDownClass(cls):
cls.temp_dir.cleanup()
def test_integration(self): def test_integration(self):
pass pass
def test_get_views(self):
self.assertAccess({ x: False for x in self.PROTECTED_URLS })
self.login('admin', 'secret')
self.assertAccess({ x: True for x in self.PROTECTED_URLS })
def test_superuser_access(self): def test_superuser_access(self):
self.login('admin', 'secret') self.login('admin', 'secret')
self.assertAccess({ self.assertAccess({
@@ -122,7 +140,12 @@ class LibraryTestCase(AccessTestCase):
'/collections/2': False, '/collections/2': False,
'/collections/2/works/1': False, '/collections/2/works/1': False,
}) })
def test_export_and_import(self):
self.login('admin', 'secret')
data = self.client.get('/api/collections/1/works/2', HTTP_ACCEPT="application/json").json()
response = self.client.post('/api/collections/2/import', data, "application/json")
self.assertEqual(response.status_code, 201)
def test_movement_from_large_work(self): def test_movement_from_large_work(self):
''' '''
@@ -139,15 +162,15 @@ class LibraryTestCase(AccessTestCase):
doc.sections.create(tag=g) doc.sections.create(tag=g)
# no tags - get nothing (should it be everything?) # no tags - get nothing (should it be everything?)
self.assertEqual(work.extract(), []) self.assertEqual(work.list_sections(), [])
# single tag - should get just that range # single tag - should get just that range
self.assertEqual(work.extract('vl-1'), [('sel/beethoven/some_quartet/some_quartet_vl-1.pdf', None, None)]) self.assertEqual(work.list_sections('vl-1'), [('sel/beethoven/some_quartet/some_quartet_vl-1.pdf', None, None)])
# single tag - returns all documents with that range # single tag - returns all documents with that range
result = work.extract('mvmt-2') result = work.list_sections('mvmt-2')
self.assertEqual(len(result), 4) self.assertEqual(len(result), 4)
# multiple tags - returns the overlapping portion of all documents that have all tags # multiple tags - returns the overlapping portion of all documents that have all tags
self.assertEqual(work.extract('vl-1', 'mvmt-2'), [('sel/beethoven/some_quartet/some_quartet_vl-1.pdf', 4, 8)]) self.assertEqual(work.list_sections('vl-1', 'mvmt-2'), [('sel/beethoven/some_quartet/some_quartet_vl-1.pdf', 4, 8)])
self.assertEqual(work.extract('vl-1', 'vl-2'), []) self.assertEqual(work.list_sections('vl-1', 'vl-2'), [])
+7 -5
View File
@@ -16,7 +16,7 @@ urlpatterns = [
path('projects/<int:project>/items/manage', views.ProjectItemManageView.as_view(), name="item_list_manage"), path('projects/<int:project>/items/manage', views.ProjectItemManageView.as_view(), name="item_list_manage"),
path('projects/<int:project>/items/append', views.ProjectItemAddView.as_view(), name="item_list_append"), path('projects/<int:project>/items/append', views.ProjectItemAddView.as_view(), name="item_list_append"),
path('library', 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', views.CollectionListView.as_view(), name="collection_list"),
path('collections/<int:collection>', views.CollectionWorkListView.as_view(), name="collection_work_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>', 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>/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>/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>/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>/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>/delete', views.DocumentDeleteView.as_view(), name="document_delete"),
path('collections/<int:collection>/docs/<int:pk>/download', views.DocumentDownloadView.as_view(), name="document_download"), path('collections/<int:collection>/docs/<int:pk>/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('collections/<int:collection>/download/<int:section>/<str:filename>', views.PartDownloadView.as_view(), name="part_download"),
#path('api/', include(router.urls)) #path('api/', include(router.urls))
path('api/library/collections/<int:pk>/export', api.CollectionExportView.as_view(), name="collection_export"), path('api/collections/<int:pk>', api.CollectionExportView.as_view(), name="collection_export"),
path('api/library/works/<int:pk>/export', api.WorkExportView.as_view(), name="work_export"), path('api/collections/<int:collection>/works/<int:pk>', api.WorkExportView.as_view(), name="work_export"),
path('api/library/collections/<int:pk>/import', api.WorkImportView.as_view(), name="work_import"), path('api/collections/<int:collection>/import', api.WorkImportView.as_view(), name="work_import"),
path('api/library/collections/<int:pk>/bulk_import', api.CollectionImportView.as_view(), name="collection_import"), path('api/collections/<int:collection>/bulk_import', api.CollectionImportView.as_view(), name="collection_import"),
] ]
+90 -51
View File
@@ -19,10 +19,12 @@ import re
from interface.views import EnsembleMixin, ProjectMixin, AuthorizedResourceMixin from interface.views import EnsembleMixin, ProjectMixin, AuthorizedResourceMixin
from interface.models import Project from interface.models import Project
from library.models import Collection, Work, Document, Section from library.models import Collection, Work, Document, Section
from library.music_tags import MUSIC_TAGS from library.music_tags import MUSIC_TAGS, MusicTag, auto_tag
from library import forms, models from library import forms, models
from library.pdf_utils import extract_pages, extract_and_concat from library.pdf_utils import extract_pages, extract_and_concat
import logging
logger = logging.getLogger(__name__)
class ProjectItemListView(ProjectMixin, ListView): class ProjectItemListView(ProjectMixin, ListView):
template_name = "library/item_list.html" template_name = "library/item_list.html"
@@ -30,9 +32,7 @@ class ProjectItemListView(ProjectMixin, ListView):
def post(self, request, **kwargs): def post(self, request, **kwargs):
project = self.get_project() project_works = self.project.works.all()
project_works = project.works.all()
instruments = request.POST.getlist('instruments') instruments = request.POST.getlist('instruments')
works = request.POST.getlist('works') works = request.POST.getlist('works')
@@ -58,7 +58,7 @@ class ProjectItemListView(ProjectMixin, ListView):
result = extract_and_concat(sections) result = extract_and_concat(sections)
download_name = f'{project.name}.pdf' download_name = f'{self.project.name}.pdf'
response = FileResponse(result, content_type="application/pdf") response = FileResponse(result, content_type="application/pdf")
response['Content-Disposition'] = f'inline; filename="{download_name}"' response['Content-Disposition'] = f'inline; filename="{download_name}"'
@@ -74,10 +74,6 @@ class ProjectItemListView(ProjectMixin, ListView):
data['instrument'] = self.request.session.get('instrument', 'Score') data['instrument'] = self.request.session.get('instrument', 'Score')
data['part'] = self.request.session.get('part', '0') data['part'] = self.request.session.get('part', '0')
data['running_time'] = self.get_queryset().aggregate(Sum('work__running_time'))['work__running_time__sum'] data['running_time'] = self.get_queryset().aggregate(Sum('work__running_time'))['work__running_time__sum']
#if running_time:
# data['running_time'] = "{0:d}:{1:02d}".format(int(running_time / 60), running_time % 60)
#else:
# data['running_time'] = "-:--"
return data return data
@@ -117,6 +113,9 @@ class ProjectItemAddView(ProjectMixin, UpdateView):
""" COLLECTION VIEWS """ """ COLLECTION VIEWS """
class CollectionMixin(AuthorizedResourceMixin): class CollectionMixin(AuthorizedResourceMixin):
collection = None
def is_authorized(self): def is_authorized(self):
collection_id = self.kwargs['collection'] collection_id = self.kwargs['collection']
self.collection = get_object_or_404(models.Collection, pk=collection_id) self.collection = get_object_or_404(models.Collection, pk=collection_id)
@@ -147,7 +146,7 @@ class CollectionListView(ListView):
paginate_by = 20 paginate_by = 20
def get_queryset(self): def get_queryset(self):
collections = models.Collection.objects.all() collections = models.Collection.objects.order_by('name')
if self.request.user.is_anonymous: if self.request.user.is_anonymous:
return models.Collection.objects.none() return models.Collection.objects.none()
@@ -159,18 +158,6 @@ class CollectionListView(ListView):
class WorkListView(CollectionMixin, ListView): class WorkListView(CollectionMixin, ListView):
paginate_by = 20 paginate_by = 20
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):
collections = CollectionMixin.get_queryset(self)
return Work.objects.filter(collection__in=collections).select_related('collection')
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
data = super(WorkListView, self).get_context_data(*args, **kwargs) data = super(WorkListView, self).get_context_data(*args, **kwargs)
@@ -189,16 +176,37 @@ class WorkListView(CollectionMixin, ListView):
else: else:
works = works.filter(Q(name__contains=q) | Q(composer__contains=q) | Q(meta_info__value__contains=q)) works = works.filter(Q(name__contains=q) | Q(composer__contains=q) | Q(meta_info__value__contains=q))
return works.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): 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): def get_works(self):
works = Work.objects.filter(collection=self.kwargs['collection']) works = Work.objects.filter(collection=self.kwargs['collection'])
if self.request.is_admin: #if self.request.is_admin:
loan_count = Count('project_items', Q(project_items__checkout__lte=now(), project_items__returned=None)) # loan_count = Count('project_items', Q(project_items__checkout__lte=now(), project_items__returned=None))
works = works.annotate(loan_count=loan_count) # works = works.annotate(loan_count=loan_count)
return works return works
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
@@ -244,7 +252,7 @@ class WorkUpdateView(CollectionMixin, UpdateView):
template_name = 'interface/default_form.html' template_name = 'interface/default_form.html'
def get_success_url(self): def get_success_url(self):
return resolve_url('work_detail', self.kwargs['pk']) return resolve_url('work_detail', self.collection.pk, self.kwargs['pk'])
@@ -259,16 +267,16 @@ class WorkAddToProject(CollectionMixin, FormView):
def get_form(self): def get_form(self):
f = super(WorkAddToProject, self).get_form() f = super(WorkAddToProject, self).get_form()
qs = f.fields['project'].queryset qs = f.fields['project'].queryset.select_related('ensemble')
# Limit to projects for ensembles where we are an admin and they haven't occured yet # Limit to projects for ensembles where we are an admin and they haven't occured yet
qs = qs.filter(ensemble__admins=self.request.user, event_date__gt=now()) qs = qs.for_user(self.request.user).current()
# dont show projects already added to # dont show projects already added to
work = self.get_object() work = self.get_object()
qs = qs.exclude(pk__in=work.projects.all()) qs = qs.exclude(pk__in=work.projects.all())
f.fields['project'].queryset = qs f.fields['project'].queryset = qs.order_by('ensemble__name', 'name')
return f return f
def form_valid(self, form): def form_valid(self, form):
@@ -277,7 +285,9 @@ class WorkAddToProject(CollectionMixin, FormView):
work.project_items.create(project=project, approved_by=self.request.user, checkout=now()) work.project_items.create(project=project, approved_by=self.request.user, checkout=now())
return redirect('item_list', project=project.pk) return redirect('item_list', project=project.pk)
class WorkPartsView(CollectionMixin, DetailView):
model = models.Work
template_name = "library/work_parts_fragment.html"
class WorkPartSetView(CollectionMixin, DetailView): class WorkPartSetView(CollectionMixin, DetailView):
template_name = "library/work_partset.html" template_name = "library/work_partset.html"
@@ -294,8 +304,8 @@ class WorkPartSetView(CollectionMixin, DetailView):
for i, tag in enumerate(parts): for i, tag in enumerate(parts):
c = int(copies[i]) c = int(copies[i])
if c > 0: if c > 0:
part = models.Section.objects.select_related('doc').get(tag=tag, doc__work=work) 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)) sections.append((part.doc.upload.path, part.name, part.start, part.end, c))
result = extract_and_concat(sections) result = extract_and_concat(sections)
@@ -312,6 +322,38 @@ class WorkPartSetView(CollectionMixin, DetailView):
works = works.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id) works = works.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id)
return works return works
class 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): class WorkAddDocumentView(CollectionMixin, CreateView):
template_name = "interface/default_form.html" template_name = "interface/default_form.html"
model = Document model = Document
@@ -327,23 +369,21 @@ class WorkAddDocumentView(CollectionMixin, CreateView):
return super().form_invalid(form) return super().form_invalid(form)
def form_valid(self, form): def form_valid(self, form):
orig_name, ext = os.path.splitext(form.cleaned_data['upload'].name)
logger.info("Uploaded: %s", orig_name)
doc = form.save(commit=False) doc = form.save(commit=False)
doc.doctype = models.Document.DOCTYPE_MAP.get(ext.lower(), models.Document.DOCTYPE_MISC)
doc.work_id = self.kwargs['pk'] doc.work_id = self.kwargs['pk']
doc.save() doc.save()
# auto tag the document # auto tag the document
name, _ = os.path.splitext(os.path.basename(doc.upload.name)) #name, ext = os.path.splitext(os.path.basename(doc.upload.name))
parts = re.split(r'[^A-Za-z]+', name) if doc.doctype == models.Document.DOCTYPE_PDF:
parts.reverse() inst = auto_tag(orig_name)
for word in parts: if inst:
try: doc.sections.create(tag=inst.abbreviate())
tag = INSTRUMENT_TAGS[word.lower()]
doc.sections.create(tag=tag)
break
except KeyError:
pass
if self.request.headers['Accept'] == 'application/json': if self.request.headers['Accept'] == 'application/json':
filename = os.path.basename(doc.upload.name) filename = os.path.basename(doc.upload.name)
@@ -395,7 +435,7 @@ class DocumentAnnotateView(DocumentMixin, DetailView):
for tag, start, end in data: for tag, start, end in data:
#pages.sort() #pages.sort()
#end = pages[-1] if len(pages) > 1 else None #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) return HttpResponse(status=204)
@@ -426,6 +466,9 @@ class PartDownloadView(CollectionMixin, SingleObjectMixin, View):
self.args = args self.args = args
self.object = self.get_object() self.object = self.get_object()
if self.object.start is None:
return redirect(self.object.doc.upload.url)
result = extract_pages(self.object.doc.upload.path, self.object.doc.work.name, self.object.start, self.object.end) result = extract_pages(self.object.doc.upload.path, self.object.doc.work.name, self.object.start, self.object.end)
#download_name = f'{self.object.doc.work.name}_{self.object.instrument}.pdf' #download_name = f'{self.object.doc.work.name}_{self.object.instrument}.pdf'
@@ -434,10 +477,6 @@ class PartDownloadView(CollectionMixin, SingleObjectMixin, View):
response['Content-Disposition'] = f'inline; filename="{self.args["filename"]}"' response['Content-Disposition'] = f'inline; filename="{self.args["filename"]}"'
return response return response
def get_queryset(self): def get_object(self):
if self.request.is_admin: return Section.objects.filter(doc__work__collection=self.collection).select_related('doc', 'doc__work').get(pk=self.kwargs['section'])
parts = Section.objects.all()
else:
parts = Section.objects.filter(doc__work__collection__allowed_ensembles__ensemble=self.request.ensemble_id)
return parts.select_related('doc', 'doc__work')
+26 -18
View File
@@ -35,14 +35,16 @@ class WorkExportView(EnsembleMixin, WorkMixin, View):
from interface.views import AuthorizedResourceMixin from interface.views import AuthorizedResourceMixin
from rest_framework import routers, serializers, viewsets from rest_framework import routers, serializers, viewsets
from rest_framework.exceptions import APIException
from library.models import Collection, Work, Document, Section, WorkMeta from library.models import Collection, Work, Document, Section, WorkMeta
import requests import requests
from io import BytesIO from io import BytesIO
import tempfile import urllib
import shutil import shutil
import os.path
from django.db import transaction from django.db import transaction
from django.core.files.uploadedfile import TemporaryUploadedFile from django.core.files.uploadedfile import TemporaryUploadedFile
@@ -67,25 +69,22 @@ class SectionSerializer(serializers.ModelSerializer):
def to_representation(self, instance): def to_representation(self, instance):
start = instance.start or 0 start = instance.start or 0
end = instance.end or 0 end = instance.end or 0
return f"{instance.tag}:{instance.type}:{start}:{end}" return f"{instance.tag}:{start}:{end}"
def to_internal_value(self, data): def to_internal_value(self, data):
tag, section_type, start, end = data.split(":") tag, start, end = data.split(":")
try: start = int(start)
start = int(start) end = int(end)
except: if start < 1:
start = 0 start = None
try: if end < 1:
end = int(end) end = None
except: return super().to_internal_value({'tag': tag, 'start': start, 'end': end})
end = 0
return super().to_internal_value({'tag': tag, 'type': int(section_type), 'start': start, 'end': end})
class DocumentSerializer(serializers.ModelSerializer): class DocumentSerializer(serializers.ModelSerializer):
upload = serializers.URLField() upload = serializers.URLField()
sections = SectionSerializer(many=True) sections = SectionSerializer(many=True)
#doctype = serializers.CharField(source='get_doctype_display')
#def to_internal_value(self, data): #def to_internal_value(self, data):
# r = requests.get(data['upload'], stream=True) # r = requests.get(data['upload'], stream=True)
@@ -97,8 +96,7 @@ class DocumentSerializer(serializers.ModelSerializer):
def to_representation(self, instance): def to_representation(self, instance):
data = super().to_representation(instance) data = super().to_representation(instance)
if data['upload'][0] == '/': data['upload'] = instance.upload.url
data['upload'] = 'http://localhost:8000' + (data['upload'])
return data return data
def create(self, validated_data): def create(self, validated_data):
@@ -136,8 +134,13 @@ class WorkSerializer(serializers.ModelSerializer):
for d in docs: for d in docs:
sections = d.pop('sections', []) sections = d.pop('sections', [])
url = urllib.parse.urlparse(d['upload'])
filename = os.path.basename(url.path)
r = requests.get(d['upload'], stream=True) r = requests.get(d['upload'], stream=True)
f = TemporaryUploadedFile(d['upload'], r.headers['content-type'], r.headers['content-length'], r.encoding) if r.status_code != 200:
raise APIException("Failed to download file")
f = TemporaryUploadedFile(filename, r.headers['content-type'], r.headers.get('content-length'), r.encoding)
shutil.copyfileobj(r.raw, f.file) shutil.copyfileobj(r.raw, f.file)
r.close() r.close()
d['upload'] = f d['upload'] = f
@@ -171,19 +174,24 @@ class CollectionExportView(AuthorizedResourceMixin, generics.RetrieveAPIView):
serializer_class = CollectionSerializer serializer_class = CollectionSerializer
def get_queryset(self): def get_queryset(self):
if self.request.user.is_superuser:
return Collection.objects.all()
return Collection.objects.filter(administrators=self.request.user) return Collection.objects.filter(administrators=self.request.user)
class WorkExportView(AuthorizedResourceMixin, generics.RetrieveAPIView): class WorkExportView(AuthorizedResourceMixin, generics.RetrieveAPIView):
serializer_class = WorkSerializer serializer_class = WorkSerializer
def get_queryset(self): def get_queryset(self):
return Work.objects.filter(collection__administrators=self.request.user) works = Work.objects.filter(collection=self.kwargs['collection'])
if self.request.user.is_superuser:
return works
return works.filter(collection__administrators=self.request.user)
class WorkImportView(AuthorizedResourceMixin, generics.CreateAPIView): class WorkImportView(AuthorizedResourceMixin, generics.CreateAPIView):
serializer_class = WorkSerializer serializer_class = WorkSerializer
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(collection_id=self.kwargs['pk']) serializer.save(collection_id=self.kwargs['collection'])
class CollectionImportView(AuthorizedResourceMixin, generics.CreateAPIView): class CollectionImportView(AuthorizedResourceMixin, generics.CreateAPIView):
serializer_class = CollectionSerializer serializer_class = CollectionSerializer