Finally got authentication to somewhere I'm happy

This commit is contained in:
Tris Forster 2023-02-10 12:01:32 +11:00
parent bc9f292a2e
commit b85440d25c
20 changed files with 415 additions and 171 deletions

View File

@ -3,7 +3,7 @@ from django.contrib import admin
from . import models from . import models
class EnsembleAdmin(admin.ModelAdmin): class EnsembleAdmin(admin.ModelAdmin):
list_display = ['name', 'ensemble_code', 'slug'] list_display = ['name', 'slug']
class ModuleInline(admin.StackedInline): class ModuleInline(admin.StackedInline):
model = models.Module model = models.Module

View File

@ -0,0 +1,27 @@
# Generated by Django 3.2.7 on 2023-02-08 22:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('interface', '0002_auto_20230202_0804'),
]
operations = [
migrations.AlterModelOptions(
name='ensemble',
options={'ordering': ('slug',)},
),
migrations.AddField(
model_name='ensemble',
name='auth',
field=models.SmallIntegerField(default=1, help_text='Increment this to reset the authentication links'),
),
migrations.AddField(
model_name='project',
name='auth',
field=models.SmallIntegerField(default=1, help_text='Increment this to reset the authentication links'),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.7 on 2023-02-09 22:38
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('interface', '0003_auto_20230209_0910'),
]
operations = [
migrations.RenameField(
model_name='ensemble',
old_name='auth',
new_name='nonce',
),
migrations.RenameField(
model_name='project',
old_name='auth',
new_name='nonce',
),
]

View File

@ -11,6 +11,7 @@ import random
from urllib.parse import urlparse from urllib.parse import urlparse
import os.path import os.path
from .utils import sign_data
MEDIA_TYPES = [ MEDIA_TYPES = [
('audio', "Audio"), ('audio', "Audio"),
@ -48,15 +49,13 @@ class Ensemble(models.Model):
help_text="Display name") help_text="Display name")
slug = models.SlugField(max_length=100, editable=False, unique=True, slug = models.SlugField(max_length=100, editable=False, unique=True,
help_text="Short name for the ensemble - used for folders") help_text="Short name for the ensemble - used for folders")
#code = models.CharField(max_length=9, default=generate_code,
# help_text="Ensemble registration code")
#passphrase = models.CharField(max_length=100,
# help_text="Used to register ensembles")
admins = models.ManyToManyField('auth.User', related_name='ensembles') admins = models.ManyToManyField('auth.User', related_name='ensembles')
details = models.TextField(blank=True, details = models.TextField(blank=True,
help_text="Description of the ensemble (markdown)") help_text="Description of the ensemble (markdown)")
storage = models.ForeignKey('byostorage.UserStorage', null=True, on_delete=models.SET_NULL, storage = models.ForeignKey('byostorage.UserStorage', null=True, on_delete=models.SET_NULL,
help_text="Default storage for this ensemble") help_text="Default storage for this ensemble")
nonce = models.SmallIntegerField(default=1,
help_text="Increment this to reset the authentication links")
class Meta: class Meta:
ordering = ('slug', ) ordering = ('slug', )
@ -64,10 +63,6 @@ class Ensemble(models.Model):
def active_projects(self): def active_projects(self):
return self.projects.filter(active=True, event_date__gte=timezone.now()) return self.projects.filter(active=True, event_date__gte=timezone.now())
def ensemble_code(self):
code = str(self.code)
return "{}-{}-{}".format(code[:3], code[3:6], code[6:])
def has_admin(self, user): def has_admin(self, user):
if not user.is_authenticated: if not user.is_authenticated:
return False return False
@ -80,6 +75,12 @@ class Ensemble(models.Model):
self.slug = slugify(self.name) self.slug = slugify(self.name)
super(Ensemble, self).save(**kwargs) super(Ensemble, self).save(**kwargs)
def get_absolute_url(self):
return resolve_url('ensemble_detail', ensemble=self.slug)
def auth(self):
return sign_data(f'{self.pk}-{self.nonce}', 12)
def __str__(self): def __str__(self):
return self.name return self.name
@ -94,6 +95,8 @@ class Project(models.Model):
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
event_date =models.DateTimeField(null=True, blank=True) event_date =models.DateTimeField(null=True, blank=True)
owner = models.CharField(max_length=255, blank=True) owner = models.CharField(max_length=255, blank=True)
nonce = models.SmallIntegerField(default=1,
help_text="Increment this to reset the authentication links")
class Meta: class Meta:
ordering = ['active', 'event_date'] ordering = ['active', 'event_date']
@ -125,6 +128,12 @@ class Project(models.Model):
def active_modules(self): def active_modules(self):
return self.modules.values_list('name', flat=True) return self.modules.values_list('name', flat=True)
def get_absolute_url(self):
return resolve_url('project_detail', project=self.pk)
def auth(self):
return sign_data(f'{self.pk}-{self.nonce}', 12)
def __str__(self): def __str__(self):
return self.name return self.name

View File

@ -21,7 +21,7 @@
<span class="icon fancy is-size-2 mx-4"><i class="fas fa-random"></i></span> <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="fancy is-size-2">Polyphonic</span>
</a> </a>
<span class="navbar-item is-hidden-mobile fancy is-size-5">Music Ensemble Manager</span> <span class="navbar-item is-hidden-mobile fancy is-size-5">Musical Ensemble Manager</span>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="projectMenu"> <a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="projectMenu">

View File

@ -32,6 +32,7 @@
{% include 'interface/project_items.html' %} {% include 'interface/project_items.html' %}
{% if request.is_admin %}
<div class=""> <div class="">
<div class="card"> <div class="card">
<header class="card header"> <header class="card header">
@ -46,4 +47,10 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
<div>
<a href="{% url 'forget_resource' 'ensemble' ensemble.slug %}">Forget this ensemble</a>
</div>
{% endblock %} {% endblock %}

View File

@ -13,41 +13,31 @@
<ul class="menu-list"> <ul class="menu-list">
<li><a href="{% url 'ensemble_list' %}">Ensembles</a></li> <li><a href="{% url 'ensemble_list' %}">Ensembles</a></li>
<li><a href="{% url 'project_list' %}">Projects</a></li> <li><a href="{% url 'project_list' %}">Projects</a></li>
{% 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> </ul>
{% if project %} {% if project %}
<p class="menu-label">This Project</p> <p class="menu-label">This Project</p>
<ul class="menu-list"> <ul class="menu-list">
<li><a role="tab" href="{% url 'project_detail' project=project.id %}">Project Info</a></li> <li><a role="tab" href="{% url 'project_detail' project=project.id %}">Project Info</a></li>
{% for page in project.wiki_pages.all %} {% if 'library' in modules %}
<li><a class="nav-link {% if page.id == wiki_id %}active{% endif %}" <li><a class="nav-link" href="{% url 'item_list' project=project.pk %}">My Music</a></li>
href="{% url 'wiki' project=project.id pk=page.id %}">{{ page.title }}</a></li>
{% endfor %}
<li><a role="tab" href="{% url 'resource_list' project=project.pk %}">Resources</a></li>
{% if 'library' in modules %}
<li><a href="{% url 'item_list' project=project.id %}">My Music</a></li>
{% endif %}
{% if 'submission' in modules %}
<!--a role="tab" href="">Record a submission</a-->
{% if request.is_admin %}
<li><a role="tab" class="admin-link" href="{% url 'submission_list' project=project.id %}">Submissions</a></li>
{% endif %} {% endif %}
{% for page in project.wiki_pages.all %}
<li><a role="tab" href="{% url 'submission_create' project=project.id %}">Send a File</a></li> <li><a class="nav-link
{% endif %} href="{% url 'wiki' project=project.id pk=page.id %}">{{ page.title }}</a></li>
</ul> {% endfor %}
<li><a role="tab" href="{% url 'resource_list' project=project.pk %}">Resources</a></li>
</ul>
{% endif %} {% endif %}
<p class="menu-label">Admin</p> <p class="menu-label">Admin</p>
<ul class="menu-list"> <ul class="menu-list">
{% if request.is_admin or request.user.is_superuser %} {% if request.user.is_staff %}
<li><a class="admin-link" href="{% url 'collection_list' %}">Collections</a></li> <li><a href="/admin" target="polyphonic_admin" rel="noopener noreferrer">Django Admin</a></li>
{% endif %}
{% if request.user.is_superuser %}
<li><a class="admin-link" href="/admin" target="polyphonic_admin" rel="noopener noreferrer">Django Admin</a></li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -21,7 +21,7 @@
{% if project.has_happened %} {% if project.has_happened %}
({{ project.event_date|roughtimesince }} ago) ({{ project.event_date|roughtimesince }} ago)
{% else %} {% else %}
(in {{ project.event_date|roughtimeuntil }}...) (in {{ project.event_date|roughtimeuntil }})
{% endif %} {% endif %}
</h3> </h3>
{% endif %} {% endif %}
@ -65,4 +65,9 @@
{% endif %} {% endif %}
</div> </div>
<div>
<a href="{% url 'forget_resource' 'project' project.pk %}">Forget this project</a>
</div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,41 @@
{% load md2 %}
<div class="columns is-multiline">
{% for project in object_list %}
<div class="column is-half-tablet is-one-third-widescreen">
<div class="card">
<a class="" href="{% url 'project_detail' project=project.id %}">
<header class="card-header{% if not project.active %} has-background-light{% endif %}">
<p class="card-header-title">{% if not ensemble %}{{ project.ensemble }}{% endif%} {{ project.name }}</p>
<p class="card-header-icon" style="color: black;">{{ project.rough_date }}</p>
</header>
</a>
<div class="card-content">
<div class="content">
{{ project.description | markdown }}
</div>
<p><small>
{% if project.deadline %}In {{ project.deadline|timeuntil }}<br/>{% endif %}
{% if project.submissions.count %}{{ project.submissions.count }} submissions<br/>{% endif %}
</small></p>
</div>
<div class="card-footer">
{% if 'library' in project.active_modules %}
{% with project.works.count as c %}
<a class="card-footer-item" href="{% url 'item_list' project=project.pk %}">
{{ c }} work{{ c | pluralize }}
</a>
{% endwith %}
{% endif %}
</div>
</div>
</div>
{% empty %}
<div class="hero">
<div class="hero-body">
<p class="title">No projects currently planned</p>
<p class="subtitle">Go put your feet up!</p>
</div>
</div>
{% endfor %}
</div>

View File

@ -0,0 +1,8 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% block page %}
<h3 class="title">My Projects</h3>
{% include 'interface/project_items.html' %}
{% endblock %}

View File

@ -10,7 +10,7 @@ class AccessTestCase(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
admin = User.objects.create_user(username='admin', password='foobar', is_superuser=True) admin = User.objects.create_user(username='admin', password='foobar', is_superuser=True, is_staff=True)
homer = User.objects.create_user(username='homer', password='maggie') homer = User.objects.create_user(username='homer', password='maggie')
now = timezone.now() now = timezone.now()
@ -75,7 +75,7 @@ class AccessTestCase(TestCase):
'/ensembles/party-posse/new-project': False, '/ensembles/party-posse/new-project': False,
}) })
self.authorize('ensemble_detail', ensemble='bleeding-gums') self.authorize(models.Ensemble, slug='bleeding-gums')
self.assertAccess({ self.assertAccess({
'/ensembles/the-be-sharps': True, '/ensembles/the-be-sharps': True,
'/ensembles/bleeding-gums': True, '/ensembles/bleeding-gums': True,
@ -105,7 +105,7 @@ class AccessTestCase(TestCase):
'/projects/4/resources/add': False, '/projects/4/resources/add': False,
}) })
self.client.get(utils.signed_url('project_detail', project=4)) self.authorize(models.Project, pk=4)
response = self.client.get('/projects') response = self.client.get('/projects')
self.assertObjectList(response, ['Open Mic Night', 'Baby on Board']) self.assertObjectList(response, ['Open Mic Night', 'Baby on Board'])
response = self.client.get('/projects/4') response = self.client.get('/projects/4')
@ -117,7 +117,8 @@ class AccessTestCase(TestCase):
self.assertContains(response, 'You don\'t currently have access to any ensembles') self.assertContains(response, 'You don\'t currently have access to any ensembles')
def test_anon_authorized_ensemble(self): def test_anon_authorized_ensemble(self):
response = self.client.get(utils.signed_url('ensemble_detail', ensemble='party-posse')) self.authorize(models.Ensemble, slug='party-posse')
response = self.client.get('/ensembles/party-posse')
self.assertContains(response, 'Party Posse') self.assertContains(response, 'Party Posse')
response = self.client.get('/ensembles') response = self.client.get('/ensembles')
@ -133,9 +134,9 @@ class AccessTestCase(TestCase):
self.assertObjectList(response, ['Navy Recruitment Day']) self.assertObjectList(response, ['Navy Recruitment Day'])
def test_anon_authorized_project(self): def test_anon_authorized_project(self):
self.authorize('project_detail', project=4) self.authorize(models.Project, pk=4)
self.assertObjectList(self.client.get('/projects'), ['Open Mic Night']) self.assertObjectList(self.client.get('/projects'), ['Open Mic Night'])
self.assertObjectList(self.client.get('/ensembles'), []) self.assertObjectList(self.client.get('/ensembles'), ['Lisa and the Bleeding Gums'])
self.assertAccess({ self.assertAccess({
'/projects/4': True, '/projects/4': True,
@ -153,9 +154,10 @@ class AccessTestCase(TestCase):
'/ensembles/unknown': False, '/ensembles/unknown': False,
}) })
def authorize(self, url, **kwargs): def authorize(self, model, **kwargs):
response = self.client.get(utils.signed_url(url, **kwargs)) object = model.objects.get(**kwargs)
self.assertEqual(response.status_code, 200) response = self.client.get(f'{object.get_absolute_url()}?auth={object.auth()}')
self.assertEqual(response.status_code, 302)
def assertAccess(self, urls): def assertAccess(self, urls):
for url, expected in urls.items(): for url, expected in urls.items():

View File

@ -10,13 +10,11 @@ urlpatterns = [
path('login', auth_views.LoginView.as_view(), name='login'), path('login', auth_views.LoginView.as_view(), name='login'),
path('logout', auth_views.LogoutView.as_view(), name='logout'), path('logout', auth_views.LogoutView.as_view(), name='logout'),
#path('register/<group>/<int:pk>', views.register, name='register'), path('forget/<resource>/<key>', views.ForgetResourceView.as_view(), name="forget_resource"),
#path('deregister/<group>/<int:pk>', views.deregister, name='deregister'),
path('ensembles', views.EnsembleListView.as_view(), name="ensemble_list"), path('ensembles', views.EnsembleListView.as_view(), name="ensemble_list"),
path('ensembles/<slug:ensemble>', views.EnsembleDetailView.as_view(), name='ensemble_detail'), path('ensembles/<slug:ensemble>', views.EnsembleDetailView.as_view(), name='ensemble_detail'),
path('ensembles/<slug:ensemble>/new-project', views.ProjectCreateView.as_view(), name="project_create"), path('ensembles/<slug:ensemble>/new-project', views.ProjectCreateView.as_view(), name="project_create"),
#path('ensembles/<int:pk>/forget', views.EnsembleForgetView.as_view(), name='ensemble_forget'),
path('projects', views.ProjectListView.as_view(), name="project_list"), path('projects', views.ProjectListView.as_view(), name="project_list"),
path('projects/<int:project>', views.ProjectDetailView.as_view(), name="project_detail"), path('projects/<int:project>', views.ProjectDetailView.as_view(), name="project_detail"),

View File

@ -4,18 +4,33 @@ from django.core.exceptions import SuspiciousOperation
signer = Signer() signer = Signer()
import logging
logger = logging.getLogger(__name__)
def sign_data(data, l=None):
sig = signer.sign(data)
p = len(data) + 1
if l:
l += p
return sig[p:l]
def signed_url(name, **kwargs): def signed_url(name, **kwargs):
""" """
>>> signed_url('foo/bar') >>> signed_url('foo/bar')
""" """
url = resolve_url(name, **kwargs) url = resolve_url(name, **kwargs)
sig = signer.sign(url) sig = signer.sign(url)
return sig.replace(":", "?auth=") sep = "&" if "?" in url else "?"
return sig.replace(":", f"{sep}auth=")
def check_signed_url(url, auth): def check_signed_url(full_path):
sig = signer.sign(url) p = full_path.rfind('auth')
if sig[len(url)+1:] != auth: url = full_path[:p-1]
sig = "_HIDDEN_" logger.debug("check_signed_url: %s", url)
signed = signed_url(url)
if signed != full_path:
logger.debug("Mismatch: %s != %s", full_path, signed)
signed = "_HIDDEN_"
raise SuspiciousOperation("Bad auth code") raise SuspiciousOperation("Bad auth code")
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -3,29 +3,30 @@
""" """
from django.shortcuts import render, get_object_or_404, redirect, resolve_url from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.views.generic import TemplateView, RedirectView from django.views.generic import RedirectView
from django.views.generic.detail import DetailView from django.views.generic.detail import DetailView
from django.views.generic.list import ListView from django.views.generic.list import ListView
from django.views.generic.edit import CreateView, UpdateView from django.views.generic.edit import CreateView, UpdateView
from django.core.exceptions import SuspiciousOperation, PermissionDenied from django.core.exceptions import SuspiciousOperation
from django.http import Http404, HttpResponseForbidden from django.http import Http404, HttpResponseRedirect
from django.contrib import auth
from django.db.models import Q from django.db.models import Q
from django.utils import timezone from django.utils import timezone
from markdown2 import markdown from markdown2 import markdown
from functools import cached_property
from . import models, forms from . import models, forms
from interface.utils import signed_url, check_signed_url from interface.utils import check_signed_url
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class AuthorizedResourceMixin(object): class AuthorizedResourceMixin(object):
""" """
Handles two parts of the permission system, signed urls and persistent authenticated resources Handles these parts of the permission system:
* signed urls
* persistent authenticated resources
* Admin enforcing
""" """
SESSION_KEY = 'authorized' SESSION_KEY = 'authorized'
@ -34,10 +35,11 @@ class AuthorizedResourceMixin(object):
def is_authorized(self): def is_authorized(self):
"By default check if superuser or a signed request" "By default check if superuser or a signed request"
if self.request.is_admin: if self.request.is_admin:
logger.debug("is_authorized: superuser")
return True return True
if 'auth' in self.request.GET: if 'sig' in self.request.GET:
check_signed_url(self.request.path, self.request.GET['auth']) check_signed_url(self.request.get_full_path())
self.on_signed_request() self.on_signed_request()
return True return True
@ -46,25 +48,43 @@ class AuthorizedResourceMixin(object):
def on_signed_request(self): def on_signed_request(self):
pass pass
def is_authorized_key(self, resource, key, auth):
current = self.get_authorized_keys(resource).get(str(key), None)
if current is None:
logger.debug("is_authorized_key: %s %s not in session", resource, key)
return False
if auth == current:
return True
logger.info("Authorisation revoked")
self.del_authorized_key(resource, key)
return False
def get_authorized_keys(self, resource): def get_authorized_keys(self, resource):
'Returns a set of authorized keys for this resource' 'Returns a set of authorized keys for this resource'
return set(self._authorized.get(resource, [])) return self._authorized.get(resource, {})
def add_authorized_key(self, resource, key): def add_authorized_key(self, resource, key, auth):
'Adds a key to the authorized list for this resource' 'Adds a key to the authorized list for this resource'
current = self.get_authorized_keys(resource) current = self.get_authorized_keys(resource)
current.add(key) current[str(key)] = auth
self._authorized[resource] = list(current) self._authorized[resource] = current
self.request.session[self.SESSION_KEY] = self._authorized self.request.session[self.SESSION_KEY] = self._authorized
def del_authorized_key(self, resource, key): def del_authorized_key(self, resource, key):
logger.info("Revoking authorization for %s %s", resource, key)
current = self.get_authorized_keys(resource) current = self.get_authorized_keys(resource)
current.discard(key) if current.pop(key, None) is None:
return False
if current: if current:
self._authorized[resource] = list(current) self._authorized[resource] = current
else: else:
self._authorized.pop(current) self._authorized.pop(resource)
self.request.session[self.SESSION_KEY] = self._authorized self.request.session[self.SESSION_KEY] = self._authorized
return True
def request_denied(self):
raise Http404("Either the given resource doesn't exist or you dont have access to it.")
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
@ -72,81 +92,83 @@ class AuthorizedResourceMixin(object):
request.is_admin = request.user.is_superuser request.is_admin = request.user.is_superuser
if not self.is_authorized(): if not self.is_authorized():
raise Http404("Either the given resource doesn't exist or you dont have access to it.") return self.request_denied()
if self.admin_required and not request.is_admin: if self.admin_required and not request.is_admin:
raise PermissionDenied("You must be an ensemble admin.") return self.request_denied()
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
class ForgetResourceView(AuthorizedResourceMixin, RedirectView):
def is_authorized(self):
return True
def get_redirect_url(self, resource, key):
self.del_authorized_key(resource, key)
return "/"
class EnsembleMixin(AuthorizedResourceMixin): class EnsembleMixin(AuthorizedResourceMixin):
ensemble_slug_kwarg = 'ensemble'
limited_project_access = False
def is_authorized(self): def is_authorized(self):
if 'forget' in self.request.GET: ensemble_slug = self.kwargs[self.ensemble_slug_kwarg]
self.del_authorized_key('ensemble', self.kwargs['ensemble']) self.ensemble = get_object_or_404(models.Ensemble, slug=ensemble_slug)
raise Http404("Access removed")
super().is_authorized() if super().is_authorized():
try:
self.ensemble = self.get_ensemble()
return True return True
except models.Ensemble.DoesNotExist:
return False
def get_ensemble(self): if self.ensemble.has_admin(self.request.user):
ensemble = self.get_queryset().get(slug=self.kwargs['ensemble']) self.request.is_admin = True
return True
self.request.is_admin = ensemble.has_admin(self.request.user) if self.is_authorized_key('ensemble', ensemble_slug, self.ensemble.nonce):
return True
return ensemble authorized = set([ int(x) for x in self.get_authorized_keys('project').keys() ])
projects = set(self.ensemble.projects.values_list('pk', flat=True))
logger.debug("is_authorized: %r & %r", authorized, projects)
if authorized & projects:
self.limited_project_access = True
logger.debug("is_authorized: allowing due to project link")
return True
return False
def get_object(self): def get_object(self):
return self.ensemble return self.ensemble
def get_queryset(self):
ensembles = models.Ensemble.objects.all()
if self.request.is_admin:
return ensembles
# limit to registered ensembles
f = Q(slug__in=self.get_authorized_keys('ensemble'))
# or ensembles where the user is admin
if self.request.user.is_authenticated:
f |= Q(admins=self.request.user.pk)
return ensembles.filter(f)
class ProjectMixin(AuthorizedResourceMixin): class ProjectMixin(AuthorizedResourceMixin):
project_kwarg = 'project'
def is_authorized(self): def is_authorized(self):
super().is_authorized() project_id = self.kwargs[self.project_kwarg]
try: self.project = get_object_or_404(models.Project.objects.select_related('ensemble'), pk=project_id)
self.project = self.get_project()
if super().is_authorized():
return True return True
except models.Project.DoesNotExist:
return False
def get_project(self):
project = self.get_project_queryset().get(pk=self.kwargs['project'])
self.request.is_admin = project.ensemble.has_admin(self.request.user)
return project
def get_project_queryset(self): # check if the current user is an admin on the ensemble
projects = models.Project.objects.select_related('ensemble') if self.project.ensemble.has_admin(self.request.user):
if self.request.is_admin: logger.debug("is_authorized: ensemble admin for project")
return projects self.request.is_admin = True
return True
f = Q(pk__in=self.get_authorized_keys('project')) | Q(ensemble__slug__in=self.get_authorized_keys('ensemble')) if self.is_authorized_key('ensemble', self.project.ensemble.pk, self.project.ensemble.nonce):
logger.debug('is_authorized: has ensemble link for project')
return True
if self.request.user.is_authenticated: if self.is_authorized_key('project', project_id, self.project.nonce):
f |= Q(ensemble__admins=self.request.user.pk) logger.debug('is_authorized: has project link')
else: return True
f &= Q(active=True)
return projects.filter(f) return False
# filter any generated querysets
def get_queryset(self): def get_queryset(self):
return super().get_queryset().filter(project=self.project) return super().get_queryset().filter(project=self.project)
@ -166,26 +188,55 @@ class CrispyFormMixin(object):
""" ENSEMBLE VIEWS """ """ ENSEMBLE VIEWS """
class EnsembleListView(EnsembleMixin, ListView): class EnsembleListView(AuthorizedResourceMixin, ListView):
model = models.Ensemble
def is_authorized(self): def is_authorized(self):
return True return True
def get_queryset(self):
ensembles = models.Ensemble.objects.all()
if self.request.is_admin:
return ensembles
# limit to registered ensembles
f = Q(slug__in=self.get_authorized_keys('ensemble').keys()) | Q(projects__in=self.get_authorized_keys('project').keys())
# or ensembles where the user is admin
if self.request.user.is_authenticated:
f |= Q(admins=self.request.user.pk)
return ensembles.filter(f).distinct()
class EnsembleDetailView(EnsembleMixin, DetailView): class EnsembleDetailView(EnsembleMixin, DetailView):
def on_signed_request(self): def request_denied(self):
self.add_authorized_key('ensemble', self.kwargs['ensemble']) if 'auth' in self.request.GET:
if self.request.GET['auth'] != self.ensemble.auth():
raise SuspiciousOperation("Bad ensemble link")
self.add_authorized_key('ensemble', self.ensemble.slug, self.ensemble.nonce)
return HttpResponseRedirect(resolve_url('ensemble_detail', self.ensemble.slug))
return super().request_denied()
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs) data = super().get_context_data(**kwargs)
data['inactive'] = 'inactive' in self.request.GET inactive = 'inactive' in self.request.GET and self.request.is_admin
if data['inactive']:
data['object_list'] = self.object.projects.all().order_by('-pk') projects = self.ensemble.projects.all()
if self.limited_project_access:
projects = projects.filter(pk__in=self.get_authorized_keys('project').keys())
if inactive:
projects = projects.order_by('-pk')
else: else:
data['object_list'] = self.object.active_projects() projects = projects.filter(active=True, event_date__gte=timezone.now()-timezone.timedelta(7))
data['inactive'] = inactive
data['object_list'] = projects
if self.request.is_admin: if self.request.is_admin:
data['ensemble_link'] = signed_url(self.request.path) #data['ensemble_link'] = signed_url(f'{self.request.path}?nonce={self.ensemble.nonce}')
data['ensemble_link'] = self.request.path + "?auth=" + self.ensemble.auth()
return data return data
""" PROJECT VIEWS """ """ PROJECT VIEWS """
@ -195,8 +246,21 @@ class ProjectListView(ProjectMixin, ListView):
def is_authorized(self): def is_authorized(self):
return True 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)
def get_queryset(self): def get_queryset(self):
return self.get_project_queryset().filter(active=True, event_date__gte=timezone.now()-timezone.timedelta(7)) qs = self.get_project_queryset()
return qs.filter(active=True, event_date__gte=timezone.now()-timezone.timedelta(7))
class ProjectCreateView(EnsembleMixin, CreateView): class ProjectCreateView(EnsembleMixin, CreateView):
admin_required = True admin_required = True
@ -207,22 +271,29 @@ class ProjectCreateView(EnsembleMixin, CreateView):
def form_valid(self, form): def form_valid(self, form):
self.object = form.save(commit=False) self.object = form.save(commit=False)
self.object.ensemble_id = self.kwargs['pk'] self.object.ensemble = self.ensemble
self.object.owner = self.request.user self.object.owner = self.request.user
self.object.save() self.object.save()
return redirect('project_detail', project=self.object.pk) return redirect('project_detail', project=self.object.pk)
class ProjectDetailView(ProjectMixin, DetailView): class ProjectDetailView(ProjectMixin, DetailView):
def on_signed_request(self): def request_denied(self):
self.add_authorized_key('project', self.kwargs['project']) if 'auth' in self.request.GET:
if self.request.GET['auth'] != self.project.auth():
raise SuspiciousOperation("Bad project link")
self.add_authorized_key('project', self.project.pk, self.project.nonce)
return HttpResponseRedirect(resolve_url('project_detail', self.project.pk))
return super().request_denied()
def get_object(self, queryset=None): def get_object(self, queryset=None):
return self.project return self.project
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs) data = super().get_context_data(**kwargs)
data['project_link'] = signed_url(self.request.path) if self.request.is_admin:
#data['project_link'] = signed_url(f'{self.request.path}?nonce={self.project.nonce}')
data['project_link'] = self.request.path + "?auth=" + self.project.auth()
return data return data
class ProjectUpdateView(ProjectMixin, UpdateView): class ProjectUpdateView(ProjectMixin, UpdateView):

View File

@ -64,7 +64,7 @@ class ProjectItem(models.Model):
approved_by = models.ForeignKey('auth.User', on_delete=models.CASCADE) approved_by = models.ForeignKey('auth.User', on_delete=models.CASCADE)
order = models.SmallIntegerField(default=0) order = models.SmallIntegerField(default=0)
section = models.CharField(max_length=100, blank=True) section = models.CharField(max_length=100, blank=True)
#version = models.CharField(max_length=30, blank=True, help_text="Limited to specific version tag")
class Meta: class Meta:
ordering = ['order', 'work'] ordering = ['order', 'work']
@ -101,6 +101,12 @@ class Collection(models.Model):
def genres(self): def genres(self):
return self.meta('genre') return self.meta('genre')
def has_administrator(self, user):
if not user.is_authenticated:
return False
if user.is_superuser:
return True
return user.pk in self.administrators.values_list('pk', flat=True)
def __str__(self): def __str__(self):
return self.name return self.name

View File

@ -8,14 +8,14 @@
<div class="column is-half"> <div class="column is-half">
<div class="card"> <div class="card">
<header class="card-header"> <header class="card-header">
<a class="" href="{% url 'collection_work_list' pk=collection.id %}"> <a class="" href="{% url 'collection_work_list' collection=collection.id %}">
<p class="card-header-title">{{ collection.name }}</p> <p class="card-header-title">{{ collection.name }}</p>
</a> </a>
</header> </header>
<div class="card-content"> <div class="card-content">
<p> <p>
{% if collection.location %}{{ collection.location }},{% endif %} {% if collection.location %}{{ collection.location }},{% endif %}
{{ collection.works.count }} items. {{ collection.works.count }} item{{ collection.works.count|pluralize }}.
</p> </p>
<p> <p>
{% for tag in collection.tags %} {% for tag in collection.tags %}

View File

@ -3,7 +3,8 @@
{% if project %} {% if project %}
<li><a href="{% url 'item_list' project=project.pk %}">My Music</a></li> <li><a href="{% url 'item_list' project=project.pk %}">My Music</a></li>
{% endif %} {% endif %}
{% if request.is_admin %} {% if request.user.is_authenticated %}
<li><a href="{% url 'work_list' %}">Library</a></li> <li><a href="{% url 'work_list' %}">Library</a></li>
<li><a href="{% url 'collection_list' %}">Collections</a></li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -29,8 +29,8 @@
<th>Work</th> <th>Work</th>
<th>Composer</th> <th>Composer</th>
<th class="is-hidden-mobile">Edition</th> <th class="is-hidden-mobile">Edition</th>
{% if request.is_admin %}
<th class="is-hidden-touch">Collection</th> <th class="is-hidden-touch">Collection</th>
{% if request.is_admin %}
<th class="is-hidden-mobile">Copies</th> <th class="is-hidden-mobile">Copies</th>
{% endif %} {% endif %}
</tr> </tr>
@ -38,11 +38,11 @@
<tbody> <tbody>
{% for work in object_list %} {% for work in object_list %}
<tr> <tr>
<td><a href="{% url 'work_detail' pk=work.pk %}">{{ work.name }}</a></td> <td><a href="{% url 'work_detail' collection=work.collection.pk pk=work.pk %}">{{ work.name }}</a></td>
<td title="{{ work.composer }}">{{ work.composer|truncatewords:3 }}</td> <td title="{{ work.composer }}">{{ work.composer|truncatewords:3 }}</td>
<td class="is-hidden-mobile" title="{{ work.edition }}">{{ work.edition|truncatewords:2 }}</td> <td class="is-hidden-mobile" title="{{ work.edition }}">{{ work.edition|truncatewords:2 }}</td>
{% if request.is_admin %}
<td class="is-hidden-touch">{{ work.collection.name }}</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> <td class="is-hidden-mobile {{ work.is_available|yesno:'has-text-success,has-text-danger' }}">{{ work.available }}</td>
{% endif %} {% endif %}
</tr> </tr>

View File

@ -16,21 +16,22 @@ urlpatterns = [
path('projects/<int:project>/items/manage', views.ProjectItemManageView.as_view(), name="item_list_manage"), path('projects/<int:project>/items/manage', views.ProjectItemManageView.as_view(), name="item_list_manage"),
path('projects/<int:project>/items/append', views.ProjectItemAddView.as_view(), name="item_list_append"), path('projects/<int:project>/items/append', views.ProjectItemAddView.as_view(), name="item_list_append"),
path('library/collections', views.CollectionListView.as_view(), name="collection_list"), path('library', views.WorkListView.as_view(), name="work_list"),
path('library/collections/<int:pk>', views.CollectionWorkListView.as_view(), name="collection_work_list"),
path('library/collections/<int:pk>/create', views.WorkAddView.as_view(), name="work_add"),
path('library/works', views.WorkListView.as_view(), name="work_list"), path('collections', views.CollectionListView.as_view(), name="collection_list"),
path('library/works/<int:pk>', views.WorkDetailView.as_view(), name="work_detail"), path('collections/<int:collection>', views.CollectionWorkListView.as_view(), name="collection_work_list"),
path('library/works/<int:pk>/edit', views.WorkUpdateView.as_view(), name="work_edit"), path('collections/<int:collection>/add', views.WorkAddView.as_view(), name="work_add"),
path('library/works/<int:pk>/partset', views.WorkPartSetView.as_view(), name="work_partset"),
path('library/works/<int:pk>/add_to_project', views.WorkAddToProject.as_view(), name="work_add_to_project"),
path('library/works/<int:pk>/upload', views.WorkAddDocumentView.as_view(), name="document_add"),
path('library/documents/<int:pk>/delete', views.DocumentDeleteView.as_view(), name="document_delete"), path('collections/<int:collection>/works/<int:pk>', views.WorkDetailView.as_view(), name="work_detail"),
path('library/documents/<int:pk>/download', views.DocumentDownloadView.as_view(), name="document_download"), path('collections/<int:collection>/works/<int:pk>/edit', views.WorkUpdateView.as_view(), name="work_edit"),
path('library/documents/<int:pk>/annotate', views.DocumentAnnotateView.as_view(), name="document_annotate"), path('collections/<int:collection>/works/<int:pk>/partset', views.WorkPartSetView.as_view(), name="work_partset"),
path('library/parts/<int:pk>/<str:filename>', views.PartDownloadView.as_view(), name="part_download"), path('collections/<int:collection>/works/<int:pk>/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>/docs/<int:pk>/delete', views.DocumentDeleteView.as_view(), name="document_delete"),
path('collections/<int:collection>/docs/<int:pk>/download', views.DocumentDownloadView.as_view(), name="document_download"),
path('collections/<int:collection>/docs/<int:pk>/annotate', views.DocumentAnnotateView.as_view(), name="document_annotate"),
path('collections/<int:collection>/docs/<int:pk>/<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/library/collections/<int:pk>/export', api.CollectionExportView.as_view(), name="collection_export"),

View File

@ -13,13 +13,18 @@ import json
import os.path import os.path
import re import re
from interface.views import EnsembleMixin, ProjectMixin from interface.views import EnsembleMixin, ProjectMixin, AuthorizedResourceMixin
from interface.models import Project from interface.models import Project
from library.models import Collection, Work, Document, Section from library.models import Collection, Work, Document, Section
from library.imslp import INSTRUMENT_TAGS, INSTRUMENTS from library.imslp import INSTRUMENT_TAGS, INSTRUMENTS
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
"""
"""
class ProjectItemListView(ProjectMixin, ListView): class ProjectItemListView(ProjectMixin, ListView):
template_name = "library/item_list.html" template_name = "library/item_list.html"
model = models.ProjectItem model = models.ProjectItem
@ -76,6 +81,7 @@ class ProjectItemListView(ProjectMixin, ListView):
# data['running_time'] = "-:--" # data['running_time'] = "-:--"
return data return data
class ProjectItemManageView(ProjectMixin, ListView): class ProjectItemManageView(ProjectMixin, ListView):
template_name = "library/item_list_manage.html" template_name = "library/item_list_manage.html"
model = models.ProjectItem model = models.ProjectItem
@ -109,25 +115,60 @@ class ProjectItemAddView(ProjectMixin, UpdateView):
def get_object(self): def get_object(self):
return self.get_project() return self.get_project()
class CollectionListView(EnsembleMixin, ListView): """ COLLECTION VIEWS """
class CollectionMixin(AuthorizedResourceMixin):
def is_authorized(self):
super().is_authorized()
try:
self.collection = self.get_collection()
return True
except (models.Collection.DoesNotExist, KeyError):
return False
def get_queryset(self): def get_queryset(self):
return Collection.objects.filter(administrators=self.request.user) return super().get_queryset().filter(collection=self.collection)
def get_collection(self):
collection = self.get_collection_queryset().get(pk=self.kwargs['collection'])
self.request.is_admin = collection.has_administrator(self.request.user)
return collection
class WorkListView(EnsembleMixin, ListView): def get_collection_queryset(self):
collections = models.Collection.objects.all()
if self.request.user.is_anonymous:
return models.Collection.objects.none()
if self.request.is_admin:
return collections
return collections.filter(Q(administrators=self.request.user) | Q(allowed_ensembles__ensemble__admins=self.request.user))
class CollectionListView(CollectionMixin, ListView):
paginate_by = 20
def is_authorized(self):
return True
def get_queryset(self):
return self.get_collection_queryset()
class WorkListView(CollectionMixin, ListView):
paginate_by = 20 paginate_by = 20
def get_works(self): def get_works(self):
return Work.objects.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id).order_by('name').distinct().select_related('collection') collections = CollectionMixin.get_queryset(self)
return Work.objects.filter(collection__in=collections).order_by('name').distinct().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)
data['title'] = f'Music available to {self.ensemble.name}' #data['title'] = f'Music available to {self.ensemble.name}'
data['title'] = "My Library"
return data return data
def get_queryset(self): def get_queryset(self):
works = self.get_works().order_by('name') works = self.get_works()
q = self.request.GET.get('filter') q = self.request.GET.get('filter')
if q: if q:
@ -142,7 +183,7 @@ class WorkListView(EnsembleMixin, ListView):
class CollectionWorkListView(WorkListView): class CollectionWorkListView(WorkListView):
def get_works(self): def get_works(self):
works = Work.objects.filter(collection=self.kwargs['pk']).distinct() works = Work.objects.filter(collection=self.kwargs['collection']).distinct()
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)
@ -150,11 +191,10 @@ class CollectionWorkListView(WorkListView):
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
data = super(CollectionWorkListView, self).get_context_data(*args, **kwargs) data = super(CollectionWorkListView, self).get_context_data(*args, **kwargs)
data['title'] = Collection.objects.get(pk=self.kwargs['pk']).name data['title'] = self.collection.name
data['collection_id'] = self.kwargs['pk']
return data return data
class WorkAddView(EnsembleMixin, FormView): class WorkAddView(CollectionMixin, FormView):
template_name = "interface/default_form.html" template_name = "interface/default_form.html"
form_class = forms.WorkCreateForm form_class = forms.WorkCreateForm
@ -185,10 +225,10 @@ class WorkMixin(object):
return Work.objects.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id) return Work.objects.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id)
class WorkDetailView(EnsembleMixin, WorkMixin, DetailView): class WorkDetailView(CollectionMixin, WorkMixin, DetailView):
pass pass
class WorkUpdateView(EnsembleMixin, WorkMixin, UpdateView): class WorkUpdateView(CollectionMixin, WorkMixin, UpdateView):
fields = ['name', 'composer', 'edition', 'code', 'licence', 'max_projects', 'running_time', 'notes'] fields = ['name', 'composer', 'edition', 'code', 'licence', 'max_projects', 'running_time', 'notes']
template_name = 'interface/default_form.html' template_name = 'interface/default_form.html'