This commit is contained in:
Tris Forster 2025-08-29 16:40:36 +10:00
parent 4d964291b2
commit a1341d1edc

View File

@ -1,17 +1,15 @@
""" """ """
"""
# pyright: basic
from django.shortcuts import get_object_or_404, redirect, resolve_url from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.views.generic import RedirectView from django.views.generic import RedirectView
from django.views.generic.detail import DetailView, SingleObjectMixin from django.views.generic.detail import DetailView, SingleObjectMixin
from django.views.generic.list import ListView from django.views.generic.list import ListView
from django.views.generic.edit import CreateView, UpdateView, FormMixin from django.views.generic.edit import CreateView, UpdateView
from django.core.exceptions import SuspiciousOperation from django.core.exceptions import SuspiciousOperation
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
from django.db.models import Q from django.http.request import HttpRequest
from django.utils import timezone
from markdown2 import markdown from markdown2 import markdown
@ -19,8 +17,10 @@ from . import models, forms
from interface.utils import check_signed_url from interface.utils import check_signed_url
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class AuthorizedResourceMixin(object): class AuthorizedResourceMixin(object):
""" """
Handles these parts of the permission system: Handles these parts of the permission system:
@ -29,16 +29,20 @@ class AuthorizedResourceMixin(object):
* Admin enforcing * Admin enforcing
""" """
SESSION_KEY = 'authorized' request: HttpRequest
kwargs: dict
_authorized: dict
SESSION_KEY = "authorized"
admin_required = False admin_required = False
def is_authorized(self): def is_authorized(self):
"By default check if superuser or a signed request" "By default check if superuser or a signed request"
if self.request.is_admin: if self.request.is_admin: # type: ignore
#logger.debug("is_authorized: superuser") # logger.debug("is_authorized: superuser")
return True return True
if 'sig' in self.request.GET: if "sig" in self.request.GET:
check_signed_url(self.request.get_full_path()) check_signed_url(self.request.get_full_path())
self.on_signed_request() self.on_signed_request()
return True return True
@ -51,25 +55,25 @@ class AuthorizedResourceMixin(object):
def is_authorized_key(self, resource, key, auth): def is_authorized_key(self, resource, key, auth):
current = self.get_authorized_keys(resource).get(str(key), None) current = self.get_authorized_keys(resource).get(str(key), None)
if current is None: if current is None:
#logger.debug("is_authorized_key: %s %s not in session", resource, key) # logger.debug("is_authorized_key: %s %s not in session", resource, key)
return False return False
if auth == current: if auth == current:
return True return True
#logger.info("Authorisation revoked") # logger.info("Authorisation revoked")
self.del_authorized_key(resource, key) self.del_authorized_key(resource, key)
return False return False
def get_authorized_keys(self, resource): def get_authorized_keys(self, resource):
'Returns a set of authorized keys for this resource' "Returns a set of authorized keys for this resource"
return self._authorized.get(resource, {}) return self._authorized.get(resource, {})
def add_authorized_key(self, resource, key, auth): def add_authorized_key(self, resource, key, auth):
'Adds a key to the authorized list for this resource' "Adds a key to the authorized list for this resource"
current = self.get_authorized_keys(resource) current = self.get_authorized_keys(resource)
current[str(key)] = auth current[str(key)] = auth
self._authorized[resource] = current self._authorized[resource] = current
self.request.session[self.SESSION_KEY] = self._authorized self.request.session[self.SESSION_KEY] = self._authorized # type: ignore
def del_authorized_key(self, resource, key): def del_authorized_key(self, resource, key):
logger.info("Revoking authorization for %s %s", resource, key) logger.info("Revoking authorization for %s %s", resource, key)
@ -80,15 +84,16 @@ class AuthorizedResourceMixin(object):
self._authorized[resource] = current self._authorized[resource] = current
else: else:
self._authorized.pop(resource) self._authorized.pop(resource)
self.request.session[self.SESSION_KEY] = self._authorized self.request.session[self.SESSION_KEY] = self._authorized # type: ignore
return True return True
def request_denied(self): def request_denied(self):
raise Http404("Either the given resource doesn't exist or you dont have access to it.") raise Http404(
"Either the given resource doesn't exist or you dont have access to it."
)
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
self._authorized = request.session.get("authorized", {})
self._authorized = request.session.get('authorized', {})
request.is_admin = request.user.is_superuser request.is_admin = request.user.is_superuser
if not self.is_authorized(): if not self.is_authorized():
@ -97,12 +102,13 @@ class AuthorizedResourceMixin(object):
if self.admin_required and not request.is_admin: if self.admin_required and not request.is_admin:
return self.request_denied() return self.request_denied()
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs) # type: ignore
# TODO: RevokeResourceView - increment nonce # TODO: RevokeResourceView - increment nonce
class ForgetResourceView(AuthorizedResourceMixin, RedirectView):
class ForgetResourceView(AuthorizedResourceMixin, RedirectView):
def is_authorized(self): def is_authorized(self):
return True return True
@ -110,9 +116,9 @@ class ForgetResourceView(AuthorizedResourceMixin, RedirectView):
self.del_authorized_key(resource, key) self.del_authorized_key(resource, key)
return "/" return "/"
class EnsembleMixin(AuthorizedResourceMixin):
ensemble_slug_kwarg = 'ensemble' class EnsembleMixin(AuthorizedResourceMixin):
ensemble_slug_kwarg = "ensemble"
limited_project_access = False limited_project_access = False
def is_authorized(self): def is_authorized(self):
@ -122,15 +128,15 @@ class EnsembleMixin(AuthorizedResourceMixin):
if super().is_authorized(): if super().is_authorized():
return True return True
if self.ensemble.has_admin(self.request.user): if self.ensemble.has_admin(self.request.user): # type: ignore
self.request.is_admin = True self.request.is_admin = True # type: ignore
return True return True
if self.is_authorized_key('ensemble', ensemble_slug, self.ensemble.nonce): if self.is_authorized_key("ensemble", ensemble_slug, self.ensemble.nonce):
return True return True
authorized = set([ int(x) for x in self.get_authorized_keys('project').keys() ]) authorized = set([int(x) for x in self.get_authorized_keys("project").keys()])
projects = set(self.ensemble.projects.values_list('pk', flat=True)) projects = set(self.ensemble.projects.values_list("pk", flat=True))
logger.debug("is_authorized: %r & %r", authorized, projects) logger.debug("is_authorized: %r & %r", authorized, projects)
if authorized & projects: if authorized & projects:
self.limited_project_access = True self.limited_project_access = True
@ -138,131 +144,131 @@ class EnsembleMixin(AuthorizedResourceMixin):
return True return True
return False return False
def get_object(self): def get_object(self, queryset=None):
return self.ensemble return self.ensemble
class ProjectMixin(AuthorizedResourceMixin): class ProjectMixin(AuthorizedResourceMixin):
project_kwarg = "project"
project_kwarg = 'project'
def is_authorized(self): def is_authorized(self):
project_id = self.kwargs[self.project_kwarg] project_id = self.kwargs[self.project_kwarg]
self.project = get_object_or_404(models.Project.objects.select_related('ensemble'), pk=project_id) self.project = get_object_or_404(
models.Project.objects.select_related("ensemble"), pk=project_id
)
if super().is_authorized(): if super().is_authorized():
return True return True
# check if the current user is an admin on the ensemble # check if the current user is an admin on the ensemble
if self.project.ensemble.has_admin(self.request.user): if self.project.ensemble.has_admin(self.request.user): # type: ignore
logger.debug("is_authorized: ensemble admin for project") logger.debug("is_authorized: ensemble admin for project")
self.request.is_admin = True self.request.is_admin = True # type: ignore
return True return True
if self.is_authorized_key('ensemble', self.project.ensemble.pk, self.project.ensemble.nonce): if self.is_authorized_key(
logger.debug('is_authorized: has ensemble link for project') "ensemble", self.project.ensemble.pk, self.project.ensemble.nonce
):
logger.debug("is_authorized: has ensemble link for project")
return True return True
if self.is_authorized_key('project', project_id, self.project.nonce): if self.is_authorized_key("project", project_id, self.project.nonce):
logger.debug('is_authorized: has project link') logger.debug("is_authorized: has project link")
return True return True
return False return False
# filter any generated querysets # filter any generated querysets
def get_queryset(self): def get_queryset(self):
return super().get_queryset().filter(project=self.project) return super().get_queryset().filter(project=self.project) # type: ignore
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs) # type: ignore
if 'project' in self.kwargs: if "project" in self.kwargs:
context['project'] = self.project context["project"] = self.project
context['modules'] = self.project.active_modules context["modules"] = self.project.active_modules
return context return context
class CrispyFormMixin(object):
class CrispyFormMixin(object):
cancel_url = None cancel_url = None
def get_cancel_url(self): def get_cancel_url(self):
return self.cancel_url return self.cancel_url
""" ENSEMBLE VIEWS """ """ ENSEMBLE VIEWS """
class EnsembleListView(AuthorizedResourceMixin, ListView):
class EnsembleListView(AuthorizedResourceMixin, ListView):
def is_authorized(self): def is_authorized(self):
return True return True
def get_queryset(self): def get_queryset(self):
return models.Ensemble.objects.for_user(self.request.user, return models.Ensemble.objects.for_user(
self.get_authorized_keys('ensemble').keys(), self.request.user, # type: ignore
self.get_authorized_keys('project').keys()) self.get_authorized_keys("ensemble").keys(),
#ensembles = models.Ensemble.objects.all() self.get_authorized_keys("project").keys(),
)
#if self.request.is_admin:
# return ensembles
# limit to registered ensembles
#f = Q(slug__in=self.get_authorized_keys('ensemble').keys()) | Q(projects__in=self.get_authorized_keys('project').keys())
# or ensembles where the user is admin
#if self.request.user.is_authenticated:
# f |= Q(admins=self.request.user.pk)
#return ensembles.filter(f).distinct()
class EnsembleDetailView(EnsembleMixin, DetailView): class EnsembleDetailView(EnsembleMixin, DetailView):
def request_denied(self): def request_denied(self):
if 'auth' in self.request.GET: if "auth" in self.request.GET:
if self.request.GET['auth'] != self.ensemble.auth(): if self.request.GET["auth"] != self.ensemble.auth():
raise SuspiciousOperation("Bad ensemble link") raise SuspiciousOperation("Bad ensemble link")
self.add_authorized_key('ensemble', self.ensemble.slug, self.ensemble.nonce) self.add_authorized_key("ensemble", self.ensemble.slug, self.ensemble.nonce)
return HttpResponseRedirect(resolve_url('ensemble_detail', self.ensemble.slug)) return HttpResponseRedirect(
resolve_url("ensemble_detail", self.ensemble.slug)
)
return super().request_denied() return super().request_denied()
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs) data = super().get_context_data(**kwargs)
inactive = 'inactive' in self.request.GET and self.request.is_admin inactive = "inactive" in self.request.GET and self.request.is_admin # type: ignore
projects = self.ensemble.projects.all() projects = self.ensemble.projects.all()
if self.limited_project_access: if self.limited_project_access:
projects = projects.filter(pk__in=self.get_authorized_keys('project').keys()) projects = projects.filter(
pk__in=self.get_authorized_keys("project").keys()
)
if inactive: if inactive:
projects = projects.order_by('-pk') projects = projects.order_by("-pk")
else: else:
projects = projects.active().current() projects = projects.active().current()
data['inactive'] = inactive data["inactive"] = inactive
data['object_list'] = projects data["object_list"] = projects
if self.request.is_admin: if self.request.is_admin: # type: ignore
data['ensemble_link'] = self.request.path + "?auth=" + self.ensemble.auth() data["ensemble_link"] = self.request.path + "?auth=" + self.ensemble.auth()
return data return data
class EnsembleRevokeView(SingleObjectMixin, RedirectView):
class EnsembleRevokeView(SingleObjectMixin, RedirectView):
def get_redirect_url(self): def get_redirect_url(self):
return return
""" PROJECT VIEWS """ """ PROJECT VIEWS """
class ProjectListView(ProjectMixin, ListView):
class ProjectListView(ProjectMixin, ListView):
def is_authorized(self): def is_authorized(self):
return True return True
def get_project_queryset(self): def get_project_queryset(self):
return models.Project.objects.for_user(self.request.user, return models.Project.objects.for_user(
self.get_authorized_keys('project'), self.request.user, # type: ignore
self.get_authorized_keys('ensemble')) self.get_authorized_keys("project"),
self.get_authorized_keys("ensemble"),
)
def get_queryset(self): def get_queryset(self):
return self.get_project_queryset().current().active() return self.get_project_queryset().current().active()
class ProjectCreateView(EnsembleMixin, CreateView): class ProjectCreateView(EnsembleMixin, CreateView):
admin_required = True admin_required = True
model = models.Project model = models.Project
@ -270,26 +276,25 @@ class ProjectCreateView(EnsembleMixin, CreateView):
title = "Add a new project" title = "Add a new project"
form_class = forms.ProjectForm form_class = forms.ProjectForm
def form_valid(self, form): def form_valid(self, form):
self.object = form.save(commit=False) self.object = form.save(commit=False)
self.object.ensemble = self.ensemble self.object.ensemble = self.ensemble
self.object.owner = self.request.user self.object.owner = self.request.user # type: ignore
self.object.save() self.object.save()
for module in form.cleaned_data['modules']: for module in form.cleaned_data["modules"]:
self.object.modules.create(name=module) self.object.modules.create(name=module)
return redirect('project_detail', project=self.object.pk) return redirect("project_detail", project=self.object.pk)
class ProjectDetailView(ProjectMixin, DetailView): class ProjectDetailView(ProjectMixin, DetailView):
def request_denied(self): def request_denied(self):
if 'auth' in self.request.GET: if "auth" in self.request.GET:
if self.request.GET['auth'] != self.project.auth(): if self.request.GET["auth"] != self.project.auth():
raise SuspiciousOperation("Bad project link") raise SuspiciousOperation("Bad project link")
self.add_authorized_key('project', self.project.pk, self.project.nonce) self.add_authorized_key("project", self.project.pk, self.project.nonce)
return HttpResponseRedirect(resolve_url('project_detail', self.project.pk)) return HttpResponseRedirect(resolve_url("project_detail", self.project.pk))
return super().request_denied() return super().request_denied()
def get_object(self, queryset=None): def get_object(self, queryset=None):
@ -297,15 +302,16 @@ class ProjectDetailView(ProjectMixin, DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs) data = super().get_context_data(**kwargs)
if self.request.is_admin: if self.request.is_admin: # type: ignore
#data['project_link'] = signed_url(f'{self.request.path}?nonce={self.project.nonce}') # data['project_link'] = signed_url(f'{self.request.path}?nonce={self.project.nonce}')
data['project_link'] = self.request.path + "?auth=" + self.project.auth() data["project_link"] = self.request.path + "?auth=" + self.project.auth()
return data return data
class ProjectUpdateView(ProjectMixin, UpdateView): class ProjectUpdateView(ProjectMixin, UpdateView):
admin_required = True admin_required = True
template_name = "interface/default_form.html" template_name = "interface/default_form.html"
pk_url_kwarg = 'project' pk_url_kwarg = "project"
form_class = forms.ProjectForm form_class = forms.ProjectForm
def get_object(self): def get_object(self):
@ -313,26 +319,26 @@ class ProjectUpdateView(ProjectMixin, UpdateView):
def get_initial(self): def get_initial(self):
data = super().get_initial() data = super().get_initial()
data['modules'] = self.object.active_modules data["modules"] = self.object.active_modules
print(data) print(data)
return data return data
def form_valid(self, form): def form_valid(self, form):
self.object = form.save() self.object = form.save()
current = set(self.object.active_modules) current = set(self.object.active_modules)
desired = set(form.cleaned_data['modules']) desired = set(form.cleaned_data["modules"])
self.object.modules.exclude(name__in=desired).delete() self.object.modules.exclude(name__in=desired).delete()
for module in desired-current: for module in desired - current:
self.object.modules.create(name=module) self.object.modules.create(name=module)
return redirect('project_detail', self.kwargs['project']) return redirect("project_detail", self.kwargs["project"])
@property @property
def cancel_url(self): def cancel_url(self):
return resolve_url('project_detail', self.kwargs['project']) return resolve_url("project_detail", self.kwargs["project"])
# Old Makefile from submission module # Old Makefile from submission module
#class ProjectMakefileView(EnsembleMixin, DetailView): # class ProjectMakefileView(EnsembleMixin, DetailView):
# template_name = 'interface/project_submissions.mk' # template_name = 'interface/project_submissions.mk'
# content_type = 'text/plain' # content_type = 'text/plain'
# #
@ -359,29 +365,32 @@ class ProjectUpdateView(ProjectMixin, UpdateView):
""" WIKI VIEWS """ """ WIKI VIEWS """
class WikiView(ProjectMixin, DetailView): class WikiView(ProjectMixin, DetailView):
template_name = 'interface/wiki.html' template_name = "interface/wiki.html"
model = models.WikiPage model = models.WikiPage
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs) data = super().get_context_data(**kwargs)
data['wiki_html'] = markdown(self.object.markdown) data["wiki_html"] = markdown(self.object.markdown)
return data return data
class WikiCreateView(ProjectMixin, CreateView): class WikiCreateView(ProjectMixin, CreateView):
admin_required = True admin_required = True
model = models.WikiPage model = models.WikiPage
template_name = 'interface/default_form.html' template_name = "interface/default_form.html"
form_class = forms.WikiForm form_class = forms.WikiForm
def cancel_url(self): def cancel_url(self):
return resolve_url('project_detail', self.kwargs['project']) return resolve_url("project_detail", self.kwargs["project"])
def form_valid(self, form): def form_valid(self, form):
self.object = form.save(commit=False) self.object = form.save(commit=False)
self.object.project = self.project self.object.project = self.project
self.object.save() self.object.save()
return redirect('wiki', project=self.object.project_id, pk=self.object.pk) return redirect("wiki", project=self.object.project_id, pk=self.object.pk)
class WikiEditView(ProjectMixin, UpdateView): class WikiEditView(ProjectMixin, UpdateView):
admin_required = True admin_required = True
@ -389,46 +398,53 @@ class WikiEditView(ProjectMixin, UpdateView):
form_class = forms.WikiForm form_class = forms.WikiForm
def cancel_url(self): def cancel_url(self):
return resolve_url('wiki', self.kwargs['project'], self.kwargs['pk']) return resolve_url("wiki", self.kwargs["project"], self.kwargs["pk"])
""" RESOURCE VIEWS """ """ RESOURCE VIEWS """
class ResourceCreateView(ProjectMixin, CreateView): class ResourceCreateView(ProjectMixin, CreateView):
admin_required = True admin_required = True
model = models.Resource model = models.Resource
form_class = forms.ResourceForm form_class = forms.ResourceForm
template_name = 'interface/default_form.html' template_name = "interface/default_form.html"
title = "Add a new resource" title = "Add a new resource"
def form_valid(self, form): def form_valid(self, form):
self.object = form.save(commit=False) self.object = form.save(commit=False)
self.object.project = self.project self.object.project = self.project
self.object.save() self.object.save()
return redirect('resource_upload', project=self.object.project_id, pk=self.object.pk) return redirect(
"resource_upload", project=self.object.project_id, pk=self.object.pk
)
class ResourceUploadView(ProjectMixin, UpdateView): class ResourceUploadView(ProjectMixin, UpdateView):
admin_required = True admin_required = True
model = models.Resource model = models.Resource
fields = ['file'] fields = ["file"]
template_name = 'interface/default_form.html' template_name = "interface/default_form.html"
def get_success_url(self): def get_success_url(self):
return resolve_url('resource_list', project=self.kwargs['project']) return resolve_url("resource_list", project=self.kwargs["project"])
class ResourceListView(ProjectMixin, ListView): class ResourceListView(ProjectMixin, ListView):
model = models.Resource model = models.Resource
def get_queryset(self): def get_queryset(self):
qs = super().get_queryset() qs = super().get_queryset()
if not self.request.is_admin: if not self.request.is_admin: # type: ignore
qs = qs.filter(visible=True) qs = qs.filter(visible=True)
return qs return qs
class ResourceEditView(ProjectMixin, UpdateView): class ResourceEditView(ProjectMixin, UpdateView):
admin_required = True admin_required = True
model = models.Resource model = models.Resource
fields = ['name', 'description', 'visible'] fields = ["name", "description", "visible"]
template_name = 'interface/default_form.html' template_name = "interface/default_form.html"
def get_success_url(self): def get_success_url(self):
return resolve_url('resource_list', project=self.kwargs['project']) return resolve_url("resource_list", project=self.kwargs["project"])