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

View File

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

View File

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

View File

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

View File

@ -21,7 +21,7 @@
{% if project.has_happened %}
({{ project.event_date|roughtimesince }} ago)
{% else %}
(in {{ project.event_date|roughtimeuntil }}...)
(in {{ project.event_date|roughtimeuntil }})
{% endif %}
</h3>
{% endif %}
@ -65,4 +65,9 @@
{% endif %}
</div>
<div>
<a href="{% url 'forget_resource' 'project' project.pk %}">Forget this project</a>
</div>
{% 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
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')
now = timezone.now()
@ -75,7 +75,7 @@ class AccessTestCase(TestCase):
'/ensembles/party-posse/new-project': False,
})
self.authorize('ensemble_detail', ensemble='bleeding-gums')
self.authorize(models.Ensemble, slug='bleeding-gums')
self.assertAccess({
'/ensembles/the-be-sharps': True,
'/ensembles/bleeding-gums': True,
@ -105,7 +105,7 @@ class AccessTestCase(TestCase):
'/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')
self.assertObjectList(response, ['Open Mic Night', 'Baby on Board'])
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')
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')
response = self.client.get('/ensembles')
@ -133,9 +134,9 @@ class AccessTestCase(TestCase):
self.assertObjectList(response, ['Navy Recruitment Day'])
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('/ensembles'), [])
self.assertObjectList(self.client.get('/ensembles'), ['Lisa and the Bleeding Gums'])
self.assertAccess({
'/projects/4': True,
@ -153,9 +154,10 @@ class AccessTestCase(TestCase):
'/ensembles/unknown': False,
})
def authorize(self, url, **kwargs):
response = self.client.get(utils.signed_url(url, **kwargs))
self.assertEqual(response.status_code, 200)
def authorize(self, model, **kwargs):
object = model.objects.get(**kwargs)
response = self.client.get(f'{object.get_absolute_url()}?auth={object.auth()}')
self.assertEqual(response.status_code, 302)
def assertAccess(self, urls):
for url, expected in urls.items():

View File

@ -10,13 +10,11 @@ urlpatterns = [
path('login', auth_views.LoginView.as_view(), name='login'),
path('logout', auth_views.LogoutView.as_view(), name='logout'),
#path('register/<group>/<int:pk>', views.register, name='register'),
#path('deregister/<group>/<int:pk>', views.deregister, name='deregister'),
path('forget/<resource>/<key>', views.ForgetResourceView.as_view(), name="forget_resource"),
path('ensembles', views.EnsembleListView.as_view(), name="ensemble_list"),
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/<int:pk>/forget', views.EnsembleForgetView.as_view(), name='ensemble_forget'),
path('projects', views.ProjectListView.as_view(), name="project_list"),
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()
import logging
logger = logging.getLogger(__name__)
def sign_data(data, l=None):
sig = signer.sign(data)
p = len(data) + 1
if l:
l += p
return sig[p:l]
def signed_url(name, **kwargs):
"""
>>> signed_url('foo/bar')
"""
url = resolve_url(name, **kwargs)
sig = signer.sign(url)
return sig.replace(":", "?auth=")
sep = "&" if "?" in url else "?"
return sig.replace(":", f"{sep}auth=")
def check_signed_url(url, auth):
sig = signer.sign(url)
if sig[len(url)+1:] != auth:
sig = "_HIDDEN_"
def check_signed_url(full_path):
p = full_path.rfind('auth')
url = full_path[:p-1]
logger.debug("check_signed_url: %s", url)
signed = signed_url(url)
if signed != full_path:
logger.debug("Mismatch: %s != %s", full_path, signed)
signed = "_HIDDEN_"
raise SuspiciousOperation("Bad auth code")
if __name__ == '__main__':

View File

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

View File

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

View File

@ -8,14 +8,14 @@
<div class="column is-half">
<div class="card">
<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>
</a>
</header>
<div class="card-content">
<p>
{% if collection.location %}{{ collection.location }},{% endif %}
{{ collection.works.count }} items.
{{ collection.works.count }} item{{ collection.works.count|pluralize }}.
</p>
<p>
{% for tag in collection.tags %}

View File

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

View File

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

View File

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

View File

@ -13,13 +13,18 @@ import json
import os.path
import re
from interface.views import EnsembleMixin, ProjectMixin
from interface.views import EnsembleMixin, ProjectMixin, AuthorizedResourceMixin
from interface.models import Project
from library.models import Collection, Work, Document, Section
from library.imslp import INSTRUMENT_TAGS, INSTRUMENTS
from library import forms, models
from library.pdf_utils import extract_pages, extract_and_concat
"""
"""
class ProjectItemListView(ProjectMixin, ListView):
template_name = "library/item_list.html"
model = models.ProjectItem
@ -76,6 +81,7 @@ class ProjectItemListView(ProjectMixin, ListView):
# data['running_time'] = "-:--"
return data
class ProjectItemManageView(ProjectMixin, ListView):
template_name = "library/item_list_manage.html"
model = models.ProjectItem
@ -109,25 +115,60 @@ class ProjectItemAddView(ProjectMixin, UpdateView):
def get_object(self):
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):
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
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):
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
def get_queryset(self):
works = self.get_works().order_by('name')
works = self.get_works()
q = self.request.GET.get('filter')
if q:
@ -142,7 +183,7 @@ class WorkListView(EnsembleMixin, ListView):
class CollectionWorkListView(WorkListView):
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))
works = works.annotate(loan_count=loan_count)
@ -150,11 +191,10 @@ class CollectionWorkListView(WorkListView):
def get_context_data(self, *args, **kwargs):
data = super(CollectionWorkListView, self).get_context_data(*args, **kwargs)
data['title'] = Collection.objects.get(pk=self.kwargs['pk']).name
data['collection_id'] = self.kwargs['pk']
data['title'] = self.collection.name
return data
class WorkAddView(EnsembleMixin, FormView):
class WorkAddView(CollectionMixin, FormView):
template_name = "interface/default_form.html"
form_class = forms.WorkCreateForm
@ -185,10 +225,10 @@ class WorkMixin(object):
return Work.objects.filter(collection__allowed_ensembles__ensemble=self.request.ensemble_id)
class WorkDetailView(EnsembleMixin, WorkMixin, DetailView):
class WorkDetailView(CollectionMixin, WorkMixin, DetailView):
pass
class WorkUpdateView(EnsembleMixin, WorkMixin, UpdateView):
class WorkUpdateView(CollectionMixin, WorkMixin, UpdateView):
fields = ['name', 'composer', 'edition', 'code', 'licence', 'max_projects', 'running_time', 'notes']
template_name = 'interface/default_form.html'