Major changes to permission system

This commit is contained in:
Tris Forster 2023-02-03 10:10:54 +11:00
parent 8a249de51c
commit bc9f292a2e
19 changed files with 649 additions and 409 deletions

View File

@ -1,29 +1,37 @@
from django import forms from django import forms
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit from crispy_forms.layout import Submit, HTML, Div
from crispy_bulma.layout import FormGroup
from . import models, fields from . import models, fields
class BaseForm(forms.Form): class BaseForm(forms.Form):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.helper = self.get_form_helper() self.helper = self.get_form_helper()
def get_form_helper(self): def get_form_helper(self):
helper = FormHelper(self) helper = FormHelper(self)
helper.add_input(Submit('submit', 'Submit', css_class='button is-link')) #helper.add_input(Submit('submit', 'Submit', css_class='button is-link'))
#helper.layout.subm append(HTML('<a class="button is-light">Cancel</a>'))
#print(helper.layout)
helper.layout.append(FormGroup(
Submit('submit', 'Save', css_class="button is-primary"),
HTML('{% if view.cancel_url %}<div class="control"><a href="{{ view.cancel_url }}" class="button is-light">Cancel</a></div>{% endif %}')
))
return helper return helper
class ProjectForm(forms.ModelForm, BaseForm): class ProjectForm(forms.ModelForm, BaseForm):
class Meta: class Meta:
model = models.Project model = models.Project
fields = ['name', 'description', 'event_date'] fields = ['name', 'description', 'event_date']
widgets = { #widgets = {
'event_date': forms.DateTimeInput(attrs={'type': 'date'}) # 'event_date': forms.DateTimeInput(attrs={'type': 'date'})
} #}
class ResourceForm(forms.ModelForm, BaseForm): class ResourceForm(forms.ModelForm, BaseForm):
@ -36,6 +44,12 @@ class ResourceForm(forms.ModelForm, BaseForm):
helper[3].wrap(fields.BulmaFileUpload) helper[3].wrap(fields.BulmaFileUpload)
return helper return helper
class WikiForm(forms.ModelForm, BaseForm):
class Meta:
model = models.WikiPage
fields = ['title', 'markdown']
class CodeForm(BaseForm): class CodeForm(BaseForm):
code = forms.CharField(max_length=14, code = forms.CharField(max_length=14,
widget=forms.TextInput(attrs={'placeholder': 'xxx-xxx-xxx', 'inputmode': 'numeric'})) widget=forms.TextInput(attrs={'placeholder': 'xxx-xxx-xxx', 'inputmode': 'numeric'}))

View File

@ -0,0 +1,25 @@
# Generated by Django 3.2.7 on 2023-02-01 21:04
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('interface', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='project',
options={'ordering': ['active', 'event_date']},
),
migrations.RemoveField(
model_name='ensemble',
name='code',
),
migrations.RemoveField(
model_name='ensemble',
name='passphrase',
),
]

View File

@ -8,8 +8,6 @@ from byostorage.user import BYOStorage
import random import random
from datetime import datetime
from urllib.parse import urlparse from urllib.parse import urlparse
import os.path import os.path
@ -27,8 +25,13 @@ def rough_date(d):
in_past = days < 0 in_past = days < 0
if in_past: if in_past:
days = abs(days) days = abs(days)
if days ==0: if days == 0:
return "today!" m = int((d-timezone.now()).seconds/60)
if m > 60:
return in_past, "{0:d} hours".format(int(m / 60))
return in_past, "{0:d} minutes!".format(int(m % 60))
if days >= 14:
return in_past, "{0:d} weeks".format(int(days/7))
if days >= 7: if days >= 7:
return in_past, "{0:d} weeks, {1:d} days".format(int(days / 7), int(days % 7)) return in_past, "{0:d} weeks, {1:d} days".format(int(days / 7), int(days % 7))
return in_past, f"{days} days" return in_past, f"{days} days"
@ -45,23 +48,33 @@ class Ensemble(models.Model):
help_text="Display name") help_text="Display name")
slug = models.SlugField(max_length=100, editable=False, unique=True, slug = models.SlugField(max_length=100, editable=False, unique=True,
help_text="Short name for the ensemble - used for folders") help_text="Short name for the ensemble - used for folders")
code = models.CharField(max_length=9, default=generate_code, #code = models.CharField(max_length=9, default=generate_code,
help_text="Ensemble registration code") # help_text="Ensemble registration code")
passphrase = models.CharField(max_length=100, #passphrase = models.CharField(max_length=100,
help_text="Used to register ensembles") # help_text="Used to register ensembles")
admins = models.ManyToManyField('auth.User', related_name='ensembles') admins = models.ManyToManyField('auth.User', related_name='ensembles')
details = models.TextField(blank=True, details = models.TextField(blank=True,
help_text="Description of the ensemble (markdown)") help_text="Description of the ensemble (markdown)")
storage = models.ForeignKey('byostorage.UserStorage', null=True, on_delete=models.SET_NULL, storage = models.ForeignKey('byostorage.UserStorage', null=True, on_delete=models.SET_NULL,
help_text="Default storage for this ensemble") help_text="Default storage for this ensemble")
class Meta:
ordering = ('slug', )
def active_projects(self): def active_projects(self):
return self.projects.filter(active=True).order_by('event_date') return self.projects.filter(active=True, event_date__gte=timezone.now())
def ensemble_code(self): def ensemble_code(self):
code = str(self.code) code = str(self.code)
return "{}-{}-{}".format(code[:3], code[3:6], code[6:]) return "{}-{}-{}".format(code[:3], code[3:6], code[6:])
def has_admin(self, user):
if not user.is_authenticated:
return False
if user.is_superuser:
return True
return user.pk in self.admins.values_list('pk', flat=True)
def save(self, **kwargs): def save(self, **kwargs):
if not self.slug: if not self.slug:
self.slug = slugify(self.name) self.slug = slugify(self.name)
@ -83,7 +96,7 @@ class Project(models.Model):
owner = models.CharField(max_length=255, blank=True) owner = models.CharField(max_length=255, blank=True)
class Meta: class Meta:
ordering = ['active', '-pk'] ordering = ['active', 'event_date']
@property @property
def days(self): def days(self):
@ -104,8 +117,13 @@ class Project(models.Model):
@property @property
def folder(self): def folder(self):
print(f"{self.ensemble.storage_id}:{self.ensemble.slug}/{self.slug}") project = slugify(self.name)
return f"{self.ensemble.storage_id}:{self.ensemble.slug}/{self.slug}" print(f"{self.ensemble.storage_id}:{self.ensemble.slug}/{project}")
return f"{self.ensemble.storage_id}:{self.ensemble.slug}/{project}"
@property
def active_modules(self):
return self.modules.values_list('name', flat=True)
def __str__(self): def __str__(self):
return self.name return self.name

View File

@ -0,0 +1,8 @@
{% extends "interface/project_base.html" %}
{% block page %}
<div class="hero">
<h3 class="is-size-3">Sorry, you do not have permission to do that!</h3>
<p>{{ exception }}</p>
</div>
{% endblock %}

View File

@ -0,0 +1,8 @@
{% extends "interface/project_base.html" %}
{% block page %}
<div class="hero">
<h3 class="is-size-3">Sorry, that resource is not found.</h3>
<p>{{ exception }}</p>
</div>
{% endblock %}

View File

@ -8,7 +8,10 @@
{% block page %} {% block page %}
<h3 class="subtitle">{% firstof title view.title %}</h3> <h3 class="subtitle">{% firstof title view.title %}</h3>
<div class="columns is-centered"> <div class="columns is-centered">
<div class="column is-form-group"> <div class="column is-two-thirds">
{% if instructions %}
<p>{{ instructions }}</p>
{% endif %}
{% crispy form %} {% crispy form %}
</div> </div>
</div> </div>

View File

@ -1,12 +1,49 @@
{% extends "base.html" %} {% extends "interface/project_base.html" %}
{% load md2 %}
{% block admin %}
<a href="{% url 'project_create' object.slug %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add project</span>
</a>
{% if inactive %}
<a href="?" class="button is-link">
<span class="icon"><i class="fas fa-archive"></i></span>
<span>Hide old</span>
</a>
{% else %}
<a href="?inactive" class="button is-link">
<span class="icon"><i class="fas fa-archive"></i></span>
<span>Show all</span>
</a>
{% endif %}
{% endblock %}
{% block page %}
<h3 class="title">Projects for {{ensemble.name }}</h3>
<p>{{ ensemble.details|markdown }}</p>
{% block content %}
<h2>{{ ensemble.name }}</h2>
<p>{{ ensemble.details }}</p>
<h4>Administrators</h4> <h4>Administrators</h4>
<ul> <ul>
{% for admin in ensemble.admins.all %} {% for admin in ensemble.admins.all %}
<li><a href="mailto:{{ admin.email }}">{% firstof admin.get_full_name admin.get_username %}</a></li> <li><a href="mailto:{{ admin.email }}">{% firstof admin.get_full_name admin.get_username %}</a></li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endblock %}
{% include 'interface/project_items.html' %}
<div class="">
<div class="card">
<header class="card header">
<p class="card-header-title">Admin Details</p>
</header>
<div class="card-content">
<p>
Joining instructions for participants<br/><br/>
URL: <a href="{{ ensemble_link }}">{{ ensemble_link }}</a><br/>
</p>
</div>
</div>
</div>
{% endblock %}

View File

@ -2,40 +2,51 @@
{% load md2 %} {% load md2 %}
{% block page %} {% block page %}
{% comment %}
<div class="admin-tools is-pulled-right"> <div class="admin-tools is-pulled-right">
<a class="button is-link" href="{% url 'register' %}"> <a class="button is-link" href="{% url 'register' %}">
<span class="icon"><i class="fas fa-plus-circle"></i></span> <span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Register another</span> <span>Register another</span>
</a> </a>
</div> </div>
{% endcomment %}
<h3 class="title">My Ensembles</h3> <h3 class="title">My Ensembles</h3>
<div class="columns is-multiline"> <div class="columns is-multiline">
{% for ensemble in object_list %} {% for ensemble in object_list %}
<div class="column is-half"> <div class="column is-half-tablet is-one-third-widescreen">
<div class="card"> <div class="card">
<header class="card-header{% if ensemble.pk == ensemble_id %} has-background-link-light{% endif %}">
<a class="card-header-title" href="{% url 'register' %}?code={{ ensemble.code }}">
{{ ensemble.name }}
</a>
<a class="card-header-icon" href="{% url 'ensemble_forget' pk=ensemble.id %}">
<span class="delete"></span>
</a>
</header>
<div class="card-content"> <div class="card-content">
{% if ensemble.details %} <div class="media">
<div class="content"> <div class="media-left">
{{ ensemble.details | markdown }} <figure class="image is-48x48">
</div> <img src="https://bulma.io/images/placeholders/96x96.png" alt="Placeholder image">
{% endif %} </figure>
<div> </div>
{% with projects=ensemble.active_projects.count %} <div class="media-content">
<p>{{ projects }} active project{{ projects|pluralize }}</p> <a href="{% url 'ensemble_detail' ensemble.slug %}">
{% endwith %} <p class="title is-4">{{ ensemble.name }}</p>
</a>
</div>
</div> </div>
{% if ensemble.details %}
<div class="content">
{{ ensemble.details | markdown }}
</div>
{% endif %}
</div>
<div class="card-footer">
{% with projects=ensemble.active_projects.count %}
<a class="card-footer-item" href="{% url 'ensemble_detail' ensemble.slug %}">{{ projects }} active project{{ projects|pluralize }}</a>
{% endwith %}
</div>
</div> </div>
</div> </div>
{% endfor %} {% empty %}
<div class="hero">
You don't currently have access to any ensembles - ask your administrator for a link.
</div>
{% endfor %}
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,73 +0,0 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% block admin %}
<a href="{% url 'project_create' %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add new</span>
</a>
{% endblock %}
{% block page %}
<h3 class="title">Projects for {{ ensemble.name }}</h3>
<div class="columns is-multiline">
{% for project in ensemble.active_projects %}
<div class="column is-half">
<div class="card">
<a class="" href="{% url 'project_detail' project=project.id %}">
<header class="card-header">
<p class="card-header-title">{{ 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.works.count %}
<a href="{% url 'item_list' project=project.pk %}">
{{ project.works.count }} works
</a>
<br/>
{% endif %}
{% if project.submissions.count %}{{ project.submissions.count }} submissions<br/>{% endif %}
</small></p>
</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>
{% if request.is_admin %}
<div class="">
<div class="card">
<header class="card header">
<p class="card-header-title">Admin Details</p>
</header>
<div class="card-content">
<p>
Joining instructions for participants<br/><br/>
URL: <a href="{{ ensemble_url }}">{{ ensemble_url }}</a><br/>
Code: {{ ensemble.ensemble_code }}<br/>
Passphrase: {{ ensemble.passphrase }}
</p>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -12,7 +12,7 @@
<p class="menu-label">My Things</p> <p class="menu-label">My Things</p>
<ul class="menu-list"> <ul class="menu-list">
<li><a href="{% url 'ensemble_list' %}">Ensembles</a></li> <li><a href="{% url 'ensemble_list' %}">Ensembles</a></li>
<li><a href="{% url 'ensemble_detail' %}">Projects</a></li> <li><a href="{% url 'project_list' %}">Projects</a></li>
<li><a href="{% url 'work_list' %}">Library</a></li> <li><a href="{% url 'work_list' %}">Library</a></li>
</ul> </ul>
@ -76,10 +76,10 @@
{% if project.resources.count %} {% if project.resources.count %}
<li><a href="{% url 'resource_list' project=project.pk %}">Resources</a></li> <li><a href="{% url 'resource_list' project=project.pk %}">Resources</a></li>
{% endif %} {% endif %}
{% if project.enable_library %} {% if 'library' in modules %}
<li><a href="{% url 'item_list' project=project.pk %}">My Music</a></li> <li><a href="{% url 'item_list' project=project.pk %}">My Music</a></li>
{% endif %} {% endif %}
{% if project.enable_submissions %} {% if 'submission' in modules %}
<li><a href="{% url 'submission_create' project=project.pk %}">Send File</a></li> <li><a href="{% url 'submission_create' project=project.pk %}">Send File</a></li>
{% endif %} {% endif %}
</ul> </ul>
@ -97,14 +97,8 @@
{% block page %} {% block page %}
No content No content
{% endblock %} {% endblock %}
</section> </section>
</div> </div>
</div> </div>
{% if ensemble %}
<div class="project-footer">
{{ ensemble.name }}
</div>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -58,5 +58,11 @@
</div> </div>
{% endif %} {% endif %}
{% if request.is_admin %}
<div class="block">
<a href="{{ project_link }}">Project Link</a>
</div>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,18 +0,0 @@
{% extends "interface/project_base.html" %}
{% load crispy_forms_tags %}
{% block media %}
{{ form.media }}
{% endblock %}
{% block page %}
<h3 class="subtitle">{% firstof title view.title %}</h3>
<div class="columns is-centered">
<div class="column is-form-group">
{% if instructions %}
<p>{{ instructions }}</p>
{% endif %}
{% crispy form %}
</div>
</div>
{% endblock %}

View File

@ -1,21 +1,18 @@
{% extends "interface/project_base.html" %} {% extends "interface/project_base.html" %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% block media %}
{{ form.media }}
{% endblock %}
{% block page %} {% block page %}
<style> <h3 class="subtitle">{% firstof title view.title %}</h3>
TEXTAREA { <div class="columns is-centered">
height: 200px; <div class="column">
} {% if instructions %}
FORM.vertical { <p>{{ instructions }}</p>
max-width: 90%; {% endif %}
} {% crispy form %}
</style> </div>
<div>
<h3>{{ title }}</h3>
<p>{{ instructions }}</p>
<form class="vertical" method="POST">
{% csrf_token %}
{{ form | crispy }}
</form>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -7,7 +7,6 @@
<div class="box is-half"> <div class="box is-half">
<p class="block"> <p class="block">
Login is only required to administer a project.<br/> Login is only required to administer a project.<br/>
If you have an ensemble code <a href="{% url 'register' %}">enter it here</a> instead.
</p> </p>
<form method="POST" class="vertical"> <form method="POST" class="vertical">
{% csrf_token %} {% csrf_token %}
@ -15,7 +14,7 @@
<div class="field is-grouped"> <div class="field is-grouped">
<div class="control"> <div class="control">
<button class="button is-link">Login</button> <button class="button is-link">Login</button>
<a href="{% url 'ensemble_detail' %}" class="button is-light">Cancel</a> <a href="{% url 'home' %}" class="button is-light">Cancel</a>
</div> </div>
</div> </div>
</form> </form>

View File

@ -0,0 +1,237 @@
from django.test import TestCase, Client
from interface import models, utils
from django.contrib.auth.models import User
from django.utils import timezone
from datetime import timedelta
class AccessTestCase(TestCase):
@classmethod
def setUpTestData(cls):
admin = User.objects.create_user(username='admin', password='foobar', is_superuser=True)
homer = User.objects.create_user(username='homer', password='maggie')
now = timezone.now()
b_sharps = models.Ensemble.objects.create(name='The Be Sharps')
b_sharps.admins.add(homer)
bleeding_gums = models.Ensemble.objects.create(name='Lisa and the Bleeding Gums', slug='bleeding-gums')
party_posse = models.Ensemble.objects.create(name="Party Posse", slug='party-posse')
bleeding_gums.projects.create(name='Baker St', event_date=now-timedelta(days=12))
party_posse.projects.create(name='Navy Recruitment Day', event_date=now+timedelta(days=6))
b_sharps.projects.create(name='Baby on Board', event_date=now+timedelta(days=28))
bleeding_gums.projects.create(name='Open Mic Night', event_date=now+timedelta(hours=1))
def test_admin_ensembles(self):
self.client.post('/login', {'username': 'admin', 'password': 'foobar'})
response = self.client.get('/ensembles')
self.assertObjectList(response, ['Lisa and the Bleeding Gums', 'Party Posse', 'The Be Sharps'])
self.assertContains(response, 'Django Admin')
def test_admin_ensemble_permissions(self):
self.client.post('/login', {'username': 'admin', 'password': 'foobar'})
response = self.client.get('/ensembles/party-posse')
self.assertTrue(response.context['request'].is_admin)
self.assertContains(response, "Add project")
self.assertAccess({
'/ensembles/the-be-sharps': True,
'/ensembles/bleeding-gums': True,
'/ensembles/party-posse': True,
'/ensembles/unknown': False,
'/ensembles/the-be-sharps/new-project': True,
})
def test_admin_projects(self):
self.client.post('/login', {'username': 'admin', 'password': 'foobar'})
response = self.client.get('/projects')
self.assertObjectList(response, ['Open Mic Night', 'Navy Recruitment Day', 'Baby on Board'])
self.assertObjectList(self.client.get('/ensembles/bleeding-gums'), ['Open Mic Night'])
self.assertObjectList(self.client.get('/ensembles/bleeding-gums?inactive'), ['Open Mic Night', 'Baker St'])
def test_user_ensembles(self):
self.client.post('/login', {'username': 'homer', 'password': 'maggie'})
response = self.client.get('/ensembles')
self.assertObjectList(response, ['The Be Sharps'])
self.assertNotContains(response, 'Django Admin')
def test_user_ensemble_permissions(self):
self.client.post('/login', {'username': 'homer', 'password': 'maggie'})
response = self.client.get('/ensembles/the-be-sharps')
self.assertTrue(response.context['request'].is_admin)
self.assertContains(response, "Add project")
self.assertContains(response, 'Show all')
self.assertAccess({
'/ensembles/the-be-sharps': True,
'/ensembles/bleeding-gums': False,
'/ensembles/party-posse': False,
'/ensembles/the-be-sharps/new-project': True,
'/ensembles/party-posse/new-project': False,
})
self.authorize('ensemble_detail', ensemble='bleeding-gums')
self.assertAccess({
'/ensembles/the-be-sharps': True,
'/ensembles/bleeding-gums': True,
'/ensembles/party-posse': False,
'/ensembles/the-be-sharps/new-project': True,
'/ensembles/party-posse/new-project': False,
})
response = self.client.get('/ensembles/bleeding-gums')
self.assertFalse(response.context['request'].is_admin)
self.assertNotContains(response, 'Add project')
self.assertNotContains(response, 'Show all')
def test_user_projects(self):
self.client.post('/login', {'username': 'homer', 'password': 'maggie'})
response = self.client.get('/projects')
self.assertObjectList(response, ['Baby on Board'])
response = self.client.get('/projects/3')
self.assertTrue(response.context['request'].is_admin)
self.assertAccess({
'/projects/3': True,
'/projects/3/resources': True,
'/projects/3/resources/add': True,
'/projects/4': False,
'/projects/4/resources': False,
'/projects/4/resources/add': False,
})
self.client.get(utils.signed_url('project_detail', project=4))
response = self.client.get('/projects')
self.assertObjectList(response, ['Open Mic Night', 'Baby on Board'])
response = self.client.get('/projects/4')
self.assertFalse(response.context['request'].is_admin)
def test_anon_ensembles(self):
response = self.client.get('/ensembles')
self.assertObjectList(response, [])
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.assertContains(response, 'Party Posse')
response = self.client.get('/ensembles')
self.assertObjectList(response, ['Party Posse'])
self.assertAccess({
'/ensembles/the-be-sharps': False,
'/ensembles/party-posse': True,
'/ensembles/bleeding-gums': False,
'/ensembles/unknown': False,
})
response = self.client.get('/projects')
self.assertObjectList(response, ['Navy Recruitment Day'])
def test_anon_authorized_project(self):
self.authorize('project_detail', project=4)
self.assertObjectList(self.client.get('/projects'), ['Open Mic Night'])
self.assertObjectList(self.client.get('/ensembles'), [])
self.assertAccess({
'/projects/4': True,
'/projects/4/resources': True,
'/projects/1': False,
'/projects/1/resources': False,
})
def test_anon_permission_denied(self):
self.assertAccess({
'/ensembles': True,
'/ensembles/the-be-sharps': False,
'/ensembles/party-posse': False,
'/ensembles/bleeding-gums': False,
'/ensembles/unknown': False,
})
def authorize(self, url, **kwargs):
response = self.client.get(utils.signed_url(url, **kwargs))
self.assertEqual(response.status_code, 200)
def assertAccess(self, urls):
for url, expected in urls.items():
response = self.client.get(url)
self.assertEqual(response.status_code == 200, expected, f"Expected {expected} for {url} (status: {response.status_code})")
def assertObjectList(self, response, expected, element='name'):
self.assertEqual(response.status_code, 200, "No result returned")
objects = response.context['object_list'].values_list(element, flat=True)
self.assertEqual(list(objects), expected)
"""
def test_redirect(self):
self.skipTest("No redirect")
response = self.client.get('/')
self.assertRedirects(response, '/register?')
def test_redirect_project(self):
response = self.client.get('/projects/1')
self.assertEqual(response.status_code, 404)
#def test_redirect_with_code(self):
# response = self.client.get('/?code=123-456-789')
# self.assertRedirects(response, '/register?code=123-456-789')
def test_register(self):
response = self.client.get('/ensembles/1')
self.assertEqual(response.status_code, 404)
url = utils.signed_url('register', group='ensemble', pk=1)
response = self.client.get(url + "i")
self.assertEqual(response.status_code, 400)
response = self.client.get(url)
self.assertRedirects(response, '/ensembles/1')
response = self.client.get('/ensembles/1')
self.assertEqual(response.context['object'].pk, 1)
response = self.client.get('/projects/1', )
def old_test_register(self):
response = self.client.post('/register', {'code': '123-456-789', })
self.assertFormError(response, 'form', 'passphrase', 'This field is required.')
response = self.client.post('/register', {'code': '123-456-789', 'passphrase': 'Foo'})
self.assertFormError(response, 'form', None, 'Incorrect code or passphrase')
response = self.client.post('/register', {'code': '12-34', 'passphrase': 'Homer'})
self.assertRedirects(response, '/')
response = self.client.get(response.url)
self.assertEqual(response.context['object'].pk, 1)
# revisting original url get redirected back to homepage
response = self.client.get('/?code=12-34')
response = self.client.get(response.url)
response = self.client.get(response.url)
self.assertEqual(response.context['object'].pk, 1)
# providing a new code
response = self.client.get('/?code=23-45')
self.assertRedirects(response, '/register?code=23-45')
response = self.client.get(response.url)
#self.assertQuerysetEqual(response.context['current'], ['<Ensemble: The Be Sharps>'])
#self.assertEqual(response.context['form'].code.initial, 'foo')
response = self.client.post('/register', {'code': '23-45', 'passphrase': 'maggie'})
self.assertRedirects(response, '/')
response = self.client.get('/')
self.assertEqual(response.context['object'].pk, 2)
# can use previous link to switch back without passphrase
response = self.client.get('/?code=12-34')
response = self.client.get(response.url)
response = self.client.get(response.url)
self.assertEqual(response.context['object'].pk, 1)
"""

View File

@ -1,63 +0,0 @@
from django.test import TestCase, Client
from interface import models
class RegisterTestCase(TestCase):
def setUp(self):
self.client = Client()
@staticmethod
def setUpTestData():
e1 = models.Ensemble.objects.create(name='The Be Sharps', code="1234", passphrase='Homer')
e1.projects.create(name='Baby on Board')
e2 = models.Ensemble.objects.create(name='Lisa and the Bleeding Gums', code="2345", passphrase="Maggie")
e2.projects.create(name='Baker St')
def test_redirect(self):
response = self.client.get('/')
self.assertRedirects(response, '/register?')
def test_redirect_project(self):
response = self.client.get('/projects/1')
self.assertRedirects(response, '/register?')
def test_redirect_with_code(self):
response = self.client.get('/?code=123-456-789')
self.assertRedirects(response, '/register?code=123-456-789')
def test_register(self):
response = self.client.post('/register', {'code': '123-456-789', })
self.assertFormError(response, 'form', 'passphrase', 'This field is required.')
response = self.client.post('/register', {'code': '123-456-789', 'passphrase': 'Foo'})
self.assertFormError(response, 'form', None, 'Incorrect code or passphrase')
response = self.client.post('/register', {'code': '12-34', 'passphrase': 'Homer'})
self.assertRedirects(response, '/')
response = self.client.get(response.url)
self.assertEqual(response.context['object'].pk, 1)
# revisting original url get redirected back to homepage
response = self.client.get('/?code=12-34')
response = self.client.get(response.url)
response = self.client.get(response.url)
self.assertEqual(response.context['object'].pk, 1)
# providing a new code
response = self.client.get('/?code=23-45')
self.assertRedirects(response, '/register?code=23-45')
response = self.client.get(response.url)
#self.assertQuerysetEqual(response.context['current'], ['<Ensemble: The Be Sharps>'])
#self.assertEqual(response.context['form'].code.initial, 'foo')
response = self.client.post('/register', {'code': '23-45', 'passphrase': 'maggie'})
self.assertRedirects(response, '/')
response = self.client.get('/')
self.assertEqual(response.context['object'].pk, 2)
# can use previous link to switch back without passphrase
response = self.client.get('/?code=12-34')
response = self.client.get(response.url)
response = self.client.get(response.url)
self.assertEqual(response.context['object'].pk, 1)

View File

@ -1,20 +1,24 @@
from django.urls import path from django.urls import path
from django.contrib.auth import views as auth_views from django.contrib.auth import views as auth_views
from django.views.generic.base import RedirectView
from . import views from . import views
urlpatterns = [ urlpatterns = [
path('', RedirectView.as_view(url='projects', permanent=False), name='home'),
path('login', auth_views.LoginView.as_view(), name='login'), path('login', auth_views.LoginView.as_view(), name='login'),
path('logout', views.logout, name='logout'), path('logout', auth_views.LogoutView.as_view(), name='logout'),
path('register', views.register, name="register"), #path('register/<group>/<int:pk>', views.register, name='register'),
#path('deregister/<group>/<int:pk>', views.deregister, name='deregister'),
path('', views.EnsembleProjectListView.as_view(), name='ensemble_detail'),
path('ensembles', views.EnsembleListView.as_view(), name="ensemble_list"), path('ensembles', views.EnsembleListView.as_view(), name="ensemble_list"),
path('ensembles/<int:pk>', views.EnsembleDetailView.as_view(), name='ensemble_detail'), path('ensembles/<slug:ensemble>', views.EnsembleDetailView.as_view(), name='ensemble_detail'),
path('ensembles/<int:pk>/forget', views.EnsembleForgetView.as_view(), name='ensemble_forget'), 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/create', views.ProjectCreateView.as_view(), name="project_create"), path('projects', views.ProjectListView.as_view(), name="project_list"),
path('projects/<int:project>', views.ProjectDetailView.as_view(), name="project_detail"), path('projects/<int:project>', views.ProjectDetailView.as_view(), name="project_detail"),
path('projects/<int:project>/edit', views.ProjectUpdateView.as_view(), name="project_edit"), path('projects/<int:project>/edit', views.ProjectUpdateView.as_view(), name="project_edit"),
#path('projects/<int:project>/submissions.mk', views.ProjectMakefileView.as_view(), name="project_makefile"), #path('projects/<int:project>/submissions.mk', views.ProjectMakefileView.as_view(), name="project_makefile"),

23
app/interface/utils.py Normal file
View File

@ -0,0 +1,23 @@
from django.shortcuts import resolve_url
from django.core.signing import Signer
from django.core.exceptions import SuspiciousOperation
signer = Signer()
def signed_url(name, **kwargs):
"""
>>> signed_url('foo/bar')
"""
url = resolve_url(name, **kwargs)
sig = signer.sign(url)
return sig.replace(":", "?auth=")
def check_signed_url(url, auth):
sig = signer.sign(url)
if sig[len(url)+1:] != auth:
sig = "_HIDDEN_"
raise SuspiciousOperation("Bad auth code")
if __name__ == '__main__':
import doctest
print(doctest.testmod())

View File

@ -1,215 +1,205 @@
"""
"""
from django.shortcuts import render, get_object_or_404, redirect, resolve_url from django.shortcuts import render, get_object_or_404, redirect, resolve_url
from django.views.generic import TemplateView, RedirectView from django.views.generic import TemplateView, RedirectView
from django.views.generic.detail import DetailView from django.views.generic.detail import DetailView
from django.views.generic.list import ListView from django.views.generic.list import ListView
from django.views.generic.edit import CreateView, UpdateView from django.views.generic.edit import CreateView, UpdateView
from django.core.exceptions import SuspiciousOperation from django.core.exceptions import SuspiciousOperation, PermissionDenied
from django.http import Http404, HttpResponseForbidden
from django.contrib import auth from django.contrib import auth
from django.db.models import Q
from django.utils import timezone
from markdown2 import markdown from markdown2 import markdown
from functools import cached_property
from . import models, forms from . import models, forms
from interface.utils import signed_url, check_signed_url
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
#from django.core.signing import Signer class AuthorizedResourceMixin(object):
#signer = Signer() """
Handles two parts of the permission system, signed urls and persistent authenticated resources
"""
#def signed_url(name, **kwargs): SESSION_KEY = 'authorized'
# url = resolve_url(name, **kwargs)
# sig = signer.sign(url)
# return sig.replace(":", "?auth=")
class EnsembleMixin(object):
admin_required = False admin_required = False
def is_authorized(self):
"By default check if superuser or a signed request"
if self.request.is_admin:
return True
if 'auth' in self.request.GET:
check_signed_url(self.request.path, self.request.GET['auth'])
self.on_signed_request()
return True
return False
def on_signed_request(self):
pass
def get_authorized_keys(self, resource):
'Returns a set of authorized keys for this resource'
return set(self._authorized.get(resource, []))
def add_authorized_key(self, resource, key):
'Adds a key to the authorized list for this resource'
current = self.get_authorized_keys(resource)
current.add(key)
self._authorized[resource] = list(current)
self.request.session[self.SESSION_KEY] = self._authorized
def del_authorized_key(self, resource, key):
current = self.get_authorized_keys(resource)
current.discard(key)
if current:
self._authorized[resource] = list(current)
else:
self._authorized.pop(current)
self.request.session[self.SESSION_KEY] = self._authorized
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
request.ensemble_id = request.session.get('ensemble') self._authorized = request.session.get('authorized', {})
request.is_admin = request.user.is_superuser request.is_admin = request.user.is_superuser
if request.is_admin: if not self.is_authorized():
if request.ensemble_id is None: raise Http404("Either the given resource doesn't exist or you dont have access to it.")
return redirect('ensemble_list')
return super().dispatch(request, *args, **kwargs)
# if 'auth' in request.GET:
# sig = signer.sign(request.path)
# if sig[len(request.path)+1:] == request.GET['auth']:
# logger.info("Allowing auth key")
# request.is_admin = True
# return super().dispatch(request, *args, **kwargs)
# else:
# raise SuspiciousOperation("Bad auth code")
if request.user.is_authenticated:
try:
request.user.ensembles.get(pk=request.ensemble_id)
request.is_admin = True
except models.Ensemble.DoesNotExist:
pass
if not request.ensemble_id:
return redirect('register')
if self.admin_required and not request.is_admin: if self.admin_required and not request.is_admin:
return redirect('login') raise PermissionDenied("You must be an ensemble admin.")
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@property
def ensemble(self):
return models.Ensemble.objects.get(pk=self.request.ensemble_id)
#def get_context_data(self, **kwargs): class EnsembleMixin(AuthorizedResourceMixin):
# context = super().get_context_data(**kwargs)
# context['ensemble'] = self.ensemble
# return context
class ProjectMixin(EnsembleMixin): 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
def get_project(self): def get_ensemble(self):
if not hasattr(self, '_project'): ensemble = self.get_queryset().get(slug=self.kwargs['ensemble'])
if self.request.is_admin: # can access any ensemble
self._project = get_object_or_404(models.Project, pk=self.kwargs['project']) self.request.is_admin = ensemble.has_admin(self.request.user)
else:
self._project = get_object_or_404(models.Project, return ensemble
pk=self.kwargs['project'], ensemble=self.request.ensemble_id)
return self._project def get_object(self):
return self.ensemble
def get_queryset(self): def get_queryset(self):
return super().get_queryset().filter(project=self.get_project()) 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):
def is_authorized(self):
super().is_authorized()
try:
self.project = self.get_project()
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)
return projects.filter(f)
def get_queryset(self):
return super().get_queryset().filter(project=self.project)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['project'] = self.get_project() if 'project' in self.kwargs:
context['modules'] = context['project'].modules.values_list('name', flat=True) context['project'] = self.project
context['modules'] = self.project.active_modules
return context return context
def register(request): class CrispyFormMixin(object):
if 'clear' in request.GET: cancel_url = None
request.session.clear()
request.ensemble_id = request.session.get('ensemble') def get_cancel_url(self):
registered = request.session.setdefault('registered', []) return self.cancel_url
code = request.GET.get('code', '').replace('-', '') """ ENSEMBLE VIEWS """
logger.debug("Registering with code %s", code)
# check if already joined class EnsembleListView(EnsembleMixin, ListView):
if code in registered or request.user.is_superuser: model = models.Ensemble
request.session['ensemble'] = models.Ensemble.objects.get(code=code).pk
return redirect('ensemble_detail')
if request.method == "POST": def is_authorized(self):
form = forms.CodeForm(request.POST) return True
if form.is_valid(): class EnsembleDetailView(EnsembleMixin, DetailView):
data = form.cleaned_data def on_signed_request(self):
try: self.add_authorized_key('ensemble', self.kwargs['ensemble'])
ensemble = models.Ensemble.objects.get(code=data['code'].replace('-', ''))
if ensemble.passphrase.lower() == data['passphrase'].lower():
request.session['ensemble'] = ensemble.pk
registered.append(ensemble.code)
return redirect('ensemble_detail')
except models.Ensemble.DoesNotExist:
form.add_error(None, "Incorrect code or passphrase")
else:
form = forms.CodeForm(initial=request.GET)
if request.user.is_superuser:
current = models.Ensemble.objects.all()
else:
current = models.Ensemble.objects.filter(pk__in=registered)
return render(request, 'interface/register.html', {'form': form, 'current': current})
def on_login(sender, **kwargs):
user = kwargs['user']
request = kwargs['request']
registered = request.session.get('registered', [])
for e in user.ensembles.all():
if not e.code in registered:
registered.append(e.code)
request.session['registered'] = registered
auth.signals.user_logged_in.connect(on_login)
def logout(request):
ensemble = request.session.get('ensemble')
registered = request.session.get('registered', {})
auth.logout(request)
request.session['ensemble'] = ensemble
request.session['registered'] = registered
return redirect('/')
class EnsembleForgetView(EnsembleMixin, RedirectView):
def get_redirect_url(self, *args, **kwargs):
registered = self.request.session.setdefault('registered', [])
ensemble = models.Ensemble.objects.get(pk=self.kwargs['pk'])
try:
registered.remove(ensemble.code)
self.request.session['registered'] = registered
except KeyError:
pass
if self.request.ensemble_id == ensemble.pk:
del(self.request.session['ensemble'])
return resolve_url('ensemble_detail')
class EnsembleListView(ListView):
def get_queryset(self):
registered = self.request.session.get('registered', [])
if self.request.user.is_superuser:
current = models.Ensemble.objects.all()
else:
current = models.Ensemble.objects.filter(code__in=registered)
return current
#def get_context_data(self, **kwargs):
# context = super().get_context_data(**kwargs)
# context['ensemble_id'] = self.request.session.get('ensemble')
# return context
class EnsembleProjectListView(EnsembleMixin, DetailView):
template_name = 'interface/ensemble_project_list.html'
def dispatch(self, request, *args, **kwargs):
# capture provided urls
if 'code' in request.GET:
return redirect('/register?code={0}'.format(request.GET['code']))
return super().dispatch(request, *args, **kwargs)
def get_object(self):
return models.Ensemble.objects.get(pk=self.request.ensemble_id)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs) data = super().get_context_data(**kwargs)
data['inactive'] = 'inactive' in self.request.GET
if data['inactive']:
data['object_list'] = self.object.projects.all().order_by('-pk')
else:
data['object_list'] = self.object.active_projects()
if self.request.is_admin: if self.request.is_admin:
data['ensemble_url'] = self.request.build_absolute_uri('/?code={0}'.format(self.ensemble.ensemble_code())) data['ensemble_link'] = signed_url(self.request.path)
return data return data
class EnsembleDetailView(DetailView): """ PROJECT VIEWS """
model = models.Ensemble
class ProjectDetailView(ProjectMixin, DetailView): class ProjectListView(ProjectMixin, ListView):
def get_object(self): def is_authorized(self):
return self.get_project() return True
def get_queryset(self):
return self.get_project_queryset().filter(active=True, event_date__gte=timezone.now()-timezone.timedelta(7))
class ProjectCreateView(EnsembleMixin, CreateView): class ProjectCreateView(EnsembleMixin, CreateView):
admin_required = True
model = models.Project model = models.Project
template_name = "interface/default_form.html" template_name = "interface/default_form.html"
title = "Add a new project" title = "Add a new project"
@ -217,21 +207,41 @@ class ProjectCreateView(EnsembleMixin, CreateView):
def form_valid(self, form): def form_valid(self, form):
self.object = form.save(commit=False) self.object = form.save(commit=False)
self.object.ensemble_id = self.request.ensemble_id self.object.ensemble_id = self.kwargs['pk']
self.object.owner = self.request.user self.object.owner = self.request.user
self.object.save() self.object.save()
return redirect('project_detail', project=self.object.pk) return redirect('project_detail', project=self.object.pk)
class ProjectUpdateView(EnsembleMixin, UpdateView): class ProjectDetailView(ProjectMixin, DetailView):
model = models.Project
def on_signed_request(self):
self.add_authorized_key('project', self.kwargs['project'])
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)
return data
class ProjectUpdateView(ProjectMixin, UpdateView):
admin_required = True
template_name = "interface/default_form.html" template_name = "interface/default_form.html"
#fields = ['name', 'description', 'event_date', 'enable_library', 'enable_submissions', 'active']
pk_url_kwarg = 'project' pk_url_kwarg = 'project'
form_class = forms.ProjectForm form_class = forms.ProjectForm
def get_object(self):
return self.project
@property
def cancel_url(self):
return self.get_success_url()
def get_success_url(self): def get_success_url(self):
return resolve_url('project_detail', project=self.kwargs['project']) return resolve_url('project_detail', project=self.kwargs['project'])
# 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'
@ -257,6 +267,8 @@ class ProjectUpdateView(EnsembleMixin, UpdateView):
# #
# return data # return data
""" 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
@ -269,30 +281,38 @@ class WikiView(ProjectMixin, DetailView):
class WikiCreateView(ProjectMixin, CreateView): class WikiCreateView(ProjectMixin, CreateView):
admin_required = True admin_required = True
model = models.WikiPage model = models.WikiPage
fields = ['title', 'markdown'] template_name = 'interface/default_form.html'
form_class = forms.WikiForm
def cancel_url(self):
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.get_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
model = models.WikiPage model = models.WikiPage
fields = ['title', 'markdown'] form_class = forms.WikiForm
def cancel_url(self):
return resolve_url('wiki', self.kwargs['project'], self.kwargs['pk'])
""" RESOURCE VIEWS """
class ResourceCreateView(ProjectMixin, CreateView): class ResourceCreateView(ProjectMixin, CreateView):
admin_required = True
model = models.Resource model = models.Resource
form_class = forms.ResourceForm form_class = forms.ResourceForm
template_name = 'interface/project_form.html' template_name = 'interface/default_form.html'
title = "Add a new resource" title = "Add a new resource"
admin_required = True
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.get_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)
@ -322,13 +342,3 @@ class ResourceEditView(ProjectMixin, UpdateView):
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 ManageView(EnsembleMixin, TemplateView):
template_name = 'interface/manage.html'
admin_required = True
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['ensemble'] = models.Ensemble.objects.get(pk=self.request.ensemble_id)
context['ensemble_url'] = self.request.build_absolute_uri('/?code={0}'.format(context['ensemble'].ensemble_code()))
return context