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 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
class BaseForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = self.get_form_helper()
def get_form_helper(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
class ProjectForm(forms.ModelForm, BaseForm):
class Meta:
model = models.Project
fields = ['name', 'description', 'event_date']
widgets = {
'event_date': forms.DateTimeInput(attrs={'type': 'date'})
}
#widgets = {
# 'event_date': forms.DateTimeInput(attrs={'type': 'date'})
#}
class ResourceForm(forms.ModelForm, BaseForm):
@ -36,6 +44,12 @@ class ResourceForm(forms.ModelForm, BaseForm):
helper[3].wrap(fields.BulmaFileUpload)
return helper
class WikiForm(forms.ModelForm, BaseForm):
class Meta:
model = models.WikiPage
fields = ['title', 'markdown']
class CodeForm(BaseForm):
code = forms.CharField(max_length=14,
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
from datetime import datetime
from urllib.parse import urlparse
import os.path
@ -27,8 +25,13 @@ def rough_date(d):
in_past = days < 0
if in_past:
days = abs(days)
if days ==0:
return "today!"
if days == 0:
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:
return in_past, "{0:d} weeks, {1:d} days".format(int(days / 7), int(days % 7))
return in_past, f"{days} days"
@ -45,23 +48,33 @@ 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")
#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")
class Meta:
ordering = ('slug', )
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):
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
if user.is_superuser:
return True
return user.pk in self.admins.values_list('pk', flat=True)
def save(self, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
@ -83,7 +96,7 @@ class Project(models.Model):
owner = models.CharField(max_length=255, blank=True)
class Meta:
ordering = ['active', '-pk']
ordering = ['active', 'event_date']
@property
def days(self):
@ -104,8 +117,13 @@ class Project(models.Model):
@property
def folder(self):
print(f"{self.ensemble.storage_id}:{self.ensemble.slug}/{self.slug}")
return f"{self.ensemble.storage_id}:{self.ensemble.slug}/{self.slug}"
project = slugify(self.name)
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):
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 %}
<h3 class="subtitle">{% firstof title view.title %}</h3>
<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 %}
</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>
<ul>
{% for admin in ensemble.admins.all %}
<li><a href="mailto:{{ admin.email }}">{% firstof admin.get_full_name admin.get_username %}</a></li>
{% endfor %}
</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 %}
{% block page %}
{% comment %}
<div class="admin-tools is-pulled-right">
<a class="button is-link" href="{% url 'register' %}">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Register another</span>
</a>
</div>
{% endcomment %}
<h3 class="title">My Ensembles</h3>
<div class="columns is-multiline">
{% for ensemble in object_list %}
<div class="column is-half">
<div class="column is-half-tablet is-one-third-widescreen">
<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">
{% if ensemble.details %}
<div class="content">
{{ ensemble.details | markdown }}
</div>
{% endif %}
<div>
{% with projects=ensemble.active_projects.count %}
<p>{{ projects }} active project{{ projects|pluralize }}</p>
{% endwith %}
<div class="media">
<div class="media-left">
<figure class="image is-48x48">
<img src="https://bulma.io/images/placeholders/96x96.png" alt="Placeholder image">
</figure>
</div>
<div class="media-content">
<a href="{% url 'ensemble_detail' ensemble.slug %}">
<p class="title is-4">{{ ensemble.name }}</p>
</a>
</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>
{% endfor %}
{% empty %}
<div class="hero">
You don't currently have access to any ensembles - ask your administrator for a link.
</div>
{% endfor %}
</div>
{% 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>
<ul class="menu-list">
<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>
</ul>
@ -76,10 +76,10 @@
{% if project.resources.count %}
<li><a href="{% url 'resource_list' project=project.pk %}">Resources</a></li>
{% endif %}
{% if project.enable_library %}
{% if 'library' in modules %}
<li><a href="{% url 'item_list' project=project.pk %}">My Music</a></li>
{% endif %}
{% if project.enable_submissions %}
{% if 'submission' in modules %}
<li><a href="{% url 'submission_create' project=project.pk %}">Send File</a></li>
{% endif %}
</ul>
@ -97,14 +97,8 @@
{% block page %}
No content
{% endblock %}
</section>
</div>
</div>
{% if ensemble %}
<div class="project-footer">
{{ ensemble.name }}
</div>
{% endif %}
{% endblock %}

View File

@ -58,5 +58,11 @@
</div>
{% endif %}
{% if request.is_admin %}
<div class="block">
<a href="{{ project_link }}">Project Link</a>
</div>
{% endif %}
</div>
{% 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" %}
{% load crispy_forms_tags %}
{% block media %}
{{ form.media }}
{% endblock %}
{% block page %}
<style>
TEXTAREA {
height: 200px;
}
FORM.vertical {
max-width: 90%;
}
</style>
<div>
<h3>{{ title }}</h3>
<p>{{ instructions }}</p>
<form class="vertical" method="POST">
{% csrf_token %}
{{ form | crispy }}
</form>
<h3 class="subtitle">{% firstof title view.title %}</h3>
<div class="columns is-centered">
<div class="column">
{% if instructions %}
<p>{{ instructions }}</p>
{% endif %}
{% crispy form %}
</div>
</div>
{% endblock %}

View File

@ -7,7 +7,6 @@
<div class="box is-half">
<p class="block">
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>
<form method="POST" class="vertical">
{% csrf_token %}
@ -15,7 +14,7 @@
<div class="field is-grouped">
<div class="control">
<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>
</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.contrib.auth import views as auth_views
from django.views.generic.base import RedirectView
from . import views
urlpatterns = [
path('', RedirectView.as_view(url='projects', permanent=False), name='home'),
path('login', auth_views.LoginView.as_view(), name='login'),
path('logout', views.logout, name='logout'),
path('register', views.register, name="register"),
path('logout', auth_views.LogoutView.as_view(), name='logout'),
#path('register/<group>/<int:pk>', views.register, name='register'),
#path('deregister/<group>/<int:pk>', views.deregister, name='deregister'),
path('', views.EnsembleProjectListView.as_view(), name='ensemble_detail'),
path('ensembles', views.EnsembleListView.as_view(), name="ensemble_list"),
path('ensembles/<int:pk>', views.EnsembleDetailView.as_view(), name='ensemble_detail'),
path('ensembles/<int:pk>/forget', views.EnsembleForgetView.as_view(), name='ensemble_forget'),
path('ensembles/<slug:ensemble>', views.EnsembleDetailView.as_view(), name='ensemble_detail'),
path('ensembles/<slug:ensemble>/new-project', views.ProjectCreateView.as_view(), name="project_create"),
#path('ensembles/<int:pk>/forget', views.EnsembleForgetView.as_view(), name='ensemble_forget'),
path('projects/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>/edit', views.ProjectUpdateView.as_view(), name="project_edit"),
#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.views.generic import TemplateView, 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
from django.core.exceptions import SuspiciousOperation, PermissionDenied
from django.http import Http404, HttpResponseForbidden
from django.contrib import auth
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
import logging
logger = logging.getLogger(__name__)
#from django.core.signing import Signer
#signer = Signer()
class AuthorizedResourceMixin(object):
"""
Handles two parts of the permission system, signed urls and persistent authenticated resources
"""
#def signed_url(name, **kwargs):
# url = resolve_url(name, **kwargs)
# sig = signer.sign(url)
# return sig.replace(":", "?auth=")
class EnsembleMixin(object):
SESSION_KEY = 'authorized'
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):
request.ensemble_id = request.session.get('ensemble')
self._authorized = request.session.get('authorized', {})
request.is_admin = request.user.is_superuser
if request.is_admin:
if request.ensemble_id is None:
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 self.is_authorized():
raise Http404("Either the given resource doesn't exist or you dont have access to it.")
if not request.ensemble_id:
return redirect('register')
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)
@property
def ensemble(self):
return models.Ensemble.objects.get(pk=self.request.ensemble_id)
#def get_context_data(self, **kwargs):
# context = super().get_context_data(**kwargs)
# context['ensemble'] = self.ensemble
# return context
class EnsembleMixin(AuthorizedResourceMixin):
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):
if not hasattr(self, '_project'):
if self.request.is_admin: # can access any ensemble
self._project = get_object_or_404(models.Project, pk=self.kwargs['project'])
else:
self._project = get_object_or_404(models.Project,
pk=self.kwargs['project'], ensemble=self.request.ensemble_id)
return self._project
def get_ensemble(self):
ensemble = self.get_queryset().get(slug=self.kwargs['ensemble'])
self.request.is_admin = ensemble.has_admin(self.request.user)
return ensemble
def get_object(self):
return self.ensemble
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):
context = super().get_context_data(**kwargs)
context['project'] = self.get_project()
context['modules'] = context['project'].modules.values_list('name', flat=True)
if 'project' in self.kwargs:
context['project'] = self.project
context['modules'] = self.project.active_modules
return context
def register(request):
class CrispyFormMixin(object):
if 'clear' in request.GET:
request.session.clear()
cancel_url = None
request.ensemble_id = request.session.get('ensemble')
registered = request.session.setdefault('registered', [])
def get_cancel_url(self):
return self.cancel_url
code = request.GET.get('code', '').replace('-', '')
logger.debug("Registering with code %s", code)
""" ENSEMBLE VIEWS """
# check if already joined
if code in registered or request.user.is_superuser:
request.session['ensemble'] = models.Ensemble.objects.get(code=code).pk
return redirect('ensemble_detail')
class EnsembleListView(EnsembleMixin, ListView):
model = models.Ensemble
if request.method == "POST":
form = forms.CodeForm(request.POST)
def is_authorized(self):
return True
if form.is_valid():
class EnsembleDetailView(EnsembleMixin, DetailView):
data = form.cleaned_data
try:
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 on_signed_request(self):
self.add_authorized_key('ensemble', self.kwargs['ensemble'])
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')
else:
data['object_list'] = self.object.active_projects()
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
class EnsembleDetailView(DetailView):
model = models.Ensemble
""" PROJECT VIEWS """
class ProjectDetailView(ProjectMixin, DetailView):
class ProjectListView(ProjectMixin, ListView):
def get_object(self):
return self.get_project()
def is_authorized(self):
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):
admin_required = True
model = models.Project
template_name = "interface/default_form.html"
title = "Add a new project"
@ -217,21 +207,41 @@ class ProjectCreateView(EnsembleMixin, CreateView):
def form_valid(self, form):
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.save()
return redirect('project_detail', project=self.object.pk)
class ProjectUpdateView(EnsembleMixin, UpdateView):
model = models.Project
class ProjectDetailView(ProjectMixin, DetailView):
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"
#fields = ['name', 'description', 'event_date', 'enable_library', 'enable_submissions', 'active']
pk_url_kwarg = 'project'
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):
return resolve_url('project_detail', project=self.kwargs['project'])
# Old Makefile from submission module
#class ProjectMakefileView(EnsembleMixin, DetailView):
# template_name = 'interface/project_submissions.mk'
# content_type = 'text/plain'
@ -257,6 +267,8 @@ class ProjectUpdateView(EnsembleMixin, UpdateView):
#
# return data
""" WIKI VIEWS """
class WikiView(ProjectMixin, DetailView):
template_name = 'interface/wiki.html'
model = models.WikiPage
@ -269,30 +281,38 @@ class WikiView(ProjectMixin, DetailView):
class WikiCreateView(ProjectMixin, CreateView):
admin_required = True
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):
self.object = form.save(commit=False)
self.object.project = self.get_project()
self.object.project = self.project
self.object.save()
return redirect('wiki', project=self.object.project_id, pk=self.object.pk)
class WikiEditView(ProjectMixin, UpdateView):
admin_required = True
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):
admin_required = True
model = models.Resource
form_class = forms.ResourceForm
template_name = 'interface/project_form.html'
template_name = 'interface/default_form.html'
title = "Add a new resource"
admin_required = True
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.project = self.get_project()
self.object.project = self.project
self.object.save()
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):
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