diff --git a/app/interface/admin.py b/app/interface/admin.py index 53bcdc7..f18f0cd 100644 --- a/app/interface/admin.py +++ b/app/interface/admin.py @@ -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 diff --git a/app/interface/migrations/0003_auto_20230209_0910.py b/app/interface/migrations/0003_auto_20230209_0910.py new file mode 100644 index 0000000..9eb2b64 --- /dev/null +++ b/app/interface/migrations/0003_auto_20230209_0910.py @@ -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'), + ), + ] diff --git a/app/interface/migrations/0004_auto_20230210_0938.py b/app/interface/migrations/0004_auto_20230210_0938.py new file mode 100644 index 0000000..60581e1 --- /dev/null +++ b/app/interface/migrations/0004_auto_20230210_0938.py @@ -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', + ), + ] diff --git a/app/interface/models.py b/app/interface/models.py index ac9a60f..1de8115 100644 --- a/app/interface/models.py +++ b/app/interface/models.py @@ -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 diff --git a/app/interface/templates/base.html b/app/interface/templates/base.html index 5434f38..bd9469c 100644 --- a/app/interface/templates/base.html +++ b/app/interface/templates/base.html @@ -21,7 +21,7 @@ Polyphonic - Music Ensemble Manager + Musical Ensemble Manager
Forget this ensemble +
+ {% endblock %} \ No newline at end of file diff --git a/app/interface/templates/interface/project_base.html b/app/interface/templates/interface/project_base.html index 23de1f1..22db8e4 100644 --- a/app/interface/templates/interface/project_base.html +++ b/app/interface/templates/interface/project_base.html @@ -13,41 +13,31 @@ {% if project %} - {% endif %} diff --git a/app/interface/templates/interface/project_detail.html b/app/interface/templates/interface/project_detail.html index fc1290e..38e27ad 100644 --- a/app/interface/templates/interface/project_detail.html +++ b/app/interface/templates/interface/project_detail.html @@ -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 %} {% endif %} @@ -65,4 +65,9 @@ {% endif %} + +
+Forget this project +
+ {% endblock %} diff --git a/app/interface/templates/interface/project_items.html b/app/interface/templates/interface/project_items.html new file mode 100644 index 0000000..32faf4e --- /dev/null +++ b/app/interface/templates/interface/project_items.html @@ -0,0 +1,41 @@ +{% load md2 %} + +
+{% for project in object_list %} +
+
+ +
+

{% if not ensemble %}{{ project.ensemble }}{% endif%} {{ project.name }}

+

{{ project.rough_date }}

+
+
+
+
+ {{ project.description | markdown }} +
+

+ {% if project.deadline %}In {{ project.deadline|timeuntil }}
{% endif %} + {% if project.submissions.count %}{{ project.submissions.count }} submissions
{% endif %} +

+
+ +
+
+{% empty %} +
+
+

No projects currently planned

+

Go put your feet up!

+
+
+{% endfor %} +
\ No newline at end of file diff --git a/app/interface/templates/interface/project_list.html b/app/interface/templates/interface/project_list.html new file mode 100644 index 0000000..8dcd419 --- /dev/null +++ b/app/interface/templates/interface/project_list.html @@ -0,0 +1,8 @@ +{% extends "interface/project_base.html" %} +{% load md2 %} + +{% block page %} +

My Projects

+ +{% include 'interface/project_items.html' %} +{% endblock %} \ No newline at end of file diff --git a/app/interface/tests/test_access.py b/app/interface/tests/test_access.py index 67dad4e..991c266 100644 --- a/app/interface/tests/test_access.py +++ b/app/interface/tests/test_access.py @@ -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(): diff --git a/app/interface/urls.py b/app/interface/urls.py index de79b26..9a3cda0 100644 --- a/app/interface/urls.py +++ b/app/interface/urls.py @@ -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//', views.register, name='register'), - #path('deregister//', views.deregister, name='deregister'), + path('forget//', views.ForgetResourceView.as_view(), name="forget_resource"), path('ensembles', views.EnsembleListView.as_view(), name="ensemble_list"), path('ensembles/', views.EnsembleDetailView.as_view(), name='ensemble_detail'), path('ensembles//new-project', views.ProjectCreateView.as_view(), name="project_create"), - #path('ensembles//forget', views.EnsembleForgetView.as_view(), name='ensemble_forget'), path('projects', views.ProjectListView.as_view(), name="project_list"), path('projects/', views.ProjectDetailView.as_view(), name="project_detail"), diff --git a/app/interface/utils.py b/app/interface/utils.py index 0a91f1e..3abf392 100644 --- a/app/interface/utils.py +++ b/app/interface/utils.py @@ -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__': diff --git a/app/interface/views.py b/app/interface/views.py index cdc747a..1b84aa7 100644 --- a/app/interface/views.py +++ b/app/interface/views.py @@ -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): diff --git a/app/library/models.py b/app/library/models.py index e521970..3ce9d27 100644 --- a/app/library/models.py +++ b/app/library/models.py @@ -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 diff --git a/app/library/templates/library/collection_list.html b/app/library/templates/library/collection_list.html index 769c798..8ba768c 100644 --- a/app/library/templates/library/collection_list.html +++ b/app/library/templates/library/collection_list.html @@ -8,14 +8,14 @@
- +

{{ collection.name }}

{% if collection.location %}{{ collection.location }},{% endif %} - {{ collection.works.count }} items. + {{ collection.works.count }} item{{ collection.works.count|pluralize }}.

{% for tag in collection.tags %} diff --git a/app/library/templates/library/project_menu.html b/app/library/templates/library/project_menu.html index bbfffcd..4789d20 100644 --- a/app/library/templates/library/project_menu.html +++ b/app/library/templates/library/project_menu.html @@ -3,7 +3,8 @@ {% if project %}

  • My Music
  • {% endif %} -{% if request.is_admin %} +{% if request.user.is_authenticated %}
  • Library
  • +
  • Collections
  • {% endif %} \ No newline at end of file diff --git a/app/library/templates/library/work_list.html b/app/library/templates/library/work_list.html index c729c74..854c32f 100644 --- a/app/library/templates/library/work_list.html +++ b/app/library/templates/library/work_list.html @@ -29,8 +29,8 @@ Work Composer Edition - {% if request.is_admin %} Collection + {% if request.is_admin %} Copies {% endif %} @@ -38,11 +38,11 @@ {% for work in object_list %} - {{ work.name }} + {{ work.name }} {{ work.composer|truncatewords:3 }} {{ work.edition|truncatewords:2 }} - {% if request.is_admin %} {{ work.collection.name }} + {% if request.is_admin %} {{ work.available }} {% endif %} diff --git a/app/library/urls.py b/app/library/urls.py index 2decdd4..2d7c6f4 100644 --- a/app/library/urls.py +++ b/app/library/urls.py @@ -15,22 +15,23 @@ urlpatterns = [ path('projects//items', views.ProjectItemListView.as_view(), name="item_list"), path('projects//items/manage', views.ProjectItemManageView.as_view(), name="item_list_manage"), path('projects//items/append', views.ProjectItemAddView.as_view(), name="item_list_append"), - - path('library/collections', views.CollectionListView.as_view(), name="collection_list"), - path('library/collections/', views.CollectionWorkListView.as_view(), name="collection_work_list"), - path('library/collections//create', views.WorkAddView.as_view(), name="work_add"), - path('library/works', views.WorkListView.as_view(), name="work_list"), - path('library/works/', views.WorkDetailView.as_view(), name="work_detail"), - path('library/works//edit', views.WorkUpdateView.as_view(), name="work_edit"), - path('library/works//partset', views.WorkPartSetView.as_view(), name="work_partset"), - path('library/works//add_to_project', views.WorkAddToProject.as_view(), name="work_add_to_project"), - path('library/works//upload', views.WorkAddDocumentView.as_view(), name="document_add"), + path('library', views.WorkListView.as_view(), name="work_list"), - path('library/documents//delete', views.DocumentDeleteView.as_view(), name="document_delete"), - path('library/documents//download', views.DocumentDownloadView.as_view(), name="document_download"), - path('library/documents//annotate', views.DocumentAnnotateView.as_view(), name="document_annotate"), - path('library/parts//', views.PartDownloadView.as_view(), name="part_download"), + path('collections', views.CollectionListView.as_view(), name="collection_list"), + path('collections/', views.CollectionWorkListView.as_view(), name="collection_work_list"), + path('collections//add', views.WorkAddView.as_view(), name="work_add"), + + path('collections//works/', views.WorkDetailView.as_view(), name="work_detail"), + path('collections//works//edit', views.WorkUpdateView.as_view(), name="work_edit"), + path('collections//works//partset', views.WorkPartSetView.as_view(), name="work_partset"), + path('collections//works//add_to_project', views.WorkAddToProject.as_view(), name="work_add_to_project"), + path('collections//works//upload', views.WorkAddDocumentView.as_view(), name="document_add"), + + path('collections//docs//delete', views.DocumentDeleteView.as_view(), name="document_delete"), + path('collections//docs//download', views.DocumentDownloadView.as_view(), name="document_download"), + path('collections//docs//annotate', views.DocumentAnnotateView.as_view(), name="document_annotate"), + path('collections//docs//', views.PartDownloadView.as_view(), name="part_download"), #path('api/', include(router.urls)) path('api/library/collections//export', api.CollectionExportView.as_view(), name="collection_export"), diff --git a/app/library/views/__init__.py b/app/library/views/__init__.py index bfc85b7..203eaf2 100644 --- a/app/library/views/__init__.py +++ b/app/library/views/__init__.py @@ -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'