Finally got authentication to somewhere I'm happy
This commit is contained in:
parent
bc9f292a2e
commit
b85440d25c
@ -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
|
||||
|
||||
27
app/interface/migrations/0003_auto_20230209_0910.py
Normal file
27
app/interface/migrations/0003_auto_20230209_0910.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
23
app/interface/migrations/0004_auto_20230210_0938.py
Normal file
23
app/interface/migrations/0004_auto_20230210_0938.py
Normal 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',
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 %}
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 %}
|
||||
|
||||
41
app/interface/templates/interface/project_items.html
Normal file
41
app/interface/templates/interface/project_items.html
Normal 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>
|
||||
8
app/interface/templates/interface/project_list.html
Normal file
8
app/interface/templates/interface/project_list.html
Normal 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 %}
|
||||
@ -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():
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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__':
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user