diff --git a/app/interface/forms.py b/app/interface/forms.py index 9e9e541..85aa1eb 100644 --- a/app/interface/forms.py +++ b/app/interface/forms.py @@ -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('Cancel')) + #print(helper.layout) + helper.layout.append(FormGroup( + Submit('submit', 'Save', css_class="button is-primary"), + HTML('{% if view.cancel_url %}
Cancel
{% 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'})) diff --git a/app/interface/migrations/0002_auto_20230202_0804.py b/app/interface/migrations/0002_auto_20230202_0804.py new file mode 100644 index 0000000..ece531d --- /dev/null +++ b/app/interface/migrations/0002_auto_20230202_0804.py @@ -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', + ), + ] diff --git a/app/interface/models.py b/app/interface/models.py index 261eb75..ac9a60f 100644 --- a/app/interface/models.py +++ b/app/interface/models.py @@ -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 diff --git a/app/interface/templates/403.html b/app/interface/templates/403.html new file mode 100644 index 0000000..b799d5d --- /dev/null +++ b/app/interface/templates/403.html @@ -0,0 +1,8 @@ +{% extends "interface/project_base.html" %} + +{% block page %} +
+

Sorry, you do not have permission to do that!

+

{{ exception }}

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

Sorry, that resource is not found.

+

{{ exception }}

+
+{% endblock %} \ No newline at end of file diff --git a/app/interface/templates/interface/default_form.html b/app/interface/templates/interface/default_form.html index 0e762e3..c071697 100644 --- a/app/interface/templates/interface/default_form.html +++ b/app/interface/templates/interface/default_form.html @@ -8,7 +8,10 @@ {% block page %}

{% firstof title view.title %}

-
+
+ {% if instructions %} +

{{ instructions }}

+ {% endif %} {% crispy form %}
diff --git a/app/interface/templates/interface/ensemble_detail.html b/app/interface/templates/interface/ensemble_detail.html index 9c14232..191985a 100644 --- a/app/interface/templates/interface/ensemble_detail.html +++ b/app/interface/templates/interface/ensemble_detail.html @@ -1,12 +1,49 @@ -{% extends "base.html" %} +{% extends "interface/project_base.html" %} +{% load md2 %} + +{% block admin %} + + + Add project + +{% if inactive %} + + + Hide old + +{% else %} + + + Show all + +{% endif %} +{% endblock %} + +{% block page %} +

Projects for {{ensemble.name }}

+

{{ ensemble.details|markdown }}

-{% block content %} -

{{ ensemble.name }}

-

{{ ensemble.details }}

Administrators

-{% endblock %} + +{% include 'interface/project_items.html' %} + +
+
+
+

Admin Details

+
+
+ +

+ Joining instructions for participants

+ URL: {{ ensemble_link }}
+

+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/interface/templates/interface/ensemble_list.html b/app/interface/templates/interface/ensemble_list.html index 492fa17..f51b04a 100644 --- a/app/interface/templates/interface/ensemble_list.html +++ b/app/interface/templates/interface/ensemble_list.html @@ -2,40 +2,51 @@ {% load md2 %} {% block page %} +{% comment %}
Register another
+{% endcomment %}

My Ensembles

{% for ensemble in object_list %} -
+
-
- {% if ensemble.details %} -
- {{ ensemble.details | markdown }} -
- {% endif %} -
- {% with projects=ensemble.active_projects.count %} -

{{ projects }} active project{{ projects|pluralize }}

- {% endwith %} + + {% if ensemble.details %} +
+ {{ ensemble.details | markdown }} +
+ {% endif %} +
+
- {% endfor %} +{% empty %} +
+ You don't currently have access to any ensembles - ask your administrator for a link. +
+{% endfor %}
{% endblock %} \ No newline at end of file diff --git a/app/interface/templates/interface/ensemble_project_list.html b/app/interface/templates/interface/ensemble_project_list.html deleted file mode 100644 index cbe4b15..0000000 --- a/app/interface/templates/interface/ensemble_project_list.html +++ /dev/null @@ -1,73 +0,0 @@ -{% extends "interface/project_base.html" %} -{% load md2 %} - -{% block admin %} - - - Add new - -{% endblock %} - - - -{% block page %} -

Projects for {{ ensemble.name }}

- -
-{% for project in ensemble.active_projects %} -
-
- - -
-

{{ project.name }}

-

{{ project.rough_date }}

-
-
-
-
- {{ project.description | markdown }} -
-

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

-
-
-
-{% empty %} -
-
-

No projects currently planned

-

Go put your feet up!

-
-
-{% endfor %} -
- -{% if request.is_admin %} -
-
-
-

Admin Details

-
-
- -

- Joining instructions for participants

- URL: {{ ensemble_url }}
- Code: {{ ensemble.ensemble_code }}
- Passphrase: {{ ensemble.passphrase }} -

-
-
-
-{% endif %} - -{% endblock %} \ No newline at end of file diff --git a/app/interface/templates/interface/project_base.html b/app/interface/templates/interface/project_base.html index 3e23e43..23de1f1 100644 --- a/app/interface/templates/interface/project_base.html +++ b/app/interface/templates/interface/project_base.html @@ -12,7 +12,7 @@ @@ -76,10 +76,10 @@ {% if project.resources.count %}
  • Resources
  • {% endif %} - {% if project.enable_library %} + {% if 'library' in modules %}
  • My Music
  • {% endif %} - {% if project.enable_submissions %} + {% if 'submission' in modules %}
  • Send File
  • {% endif %} @@ -97,14 +97,8 @@ {% block page %} No content {% endblock %} -
    -{% if ensemble %} - -{% endif %} {% endblock %} diff --git a/app/interface/templates/interface/project_detail.html b/app/interface/templates/interface/project_detail.html index 8fc3659..fc1290e 100644 --- a/app/interface/templates/interface/project_detail.html +++ b/app/interface/templates/interface/project_detail.html @@ -58,5 +58,11 @@
    {% endif %} + {% if request.is_admin %} +
    + Project Link +
    + {% endif %} + {% endblock %} diff --git a/app/interface/templates/interface/project_form.html b/app/interface/templates/interface/project_form.html deleted file mode 100644 index 0238b1c..0000000 --- a/app/interface/templates/interface/project_form.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "interface/project_base.html" %} -{% load crispy_forms_tags %} - -{% block media %} -{{ form.media }} -{% endblock %} - -{% block page %} -

    {% firstof title view.title %}

    -
    -
    - {% if instructions %} -

    {{ instructions }}

    - {% endif %} - {% crispy form %} -
    -
    -{% endblock %} \ No newline at end of file diff --git a/app/interface/templates/interface/wikipage_form.html b/app/interface/templates/interface/wikipage_form.html index ec8f08a..85ca5f5 100644 --- a/app/interface/templates/interface/wikipage_form.html +++ b/app/interface/templates/interface/wikipage_form.html @@ -1,21 +1,18 @@ {% extends "interface/project_base.html" %} {% load crispy_forms_tags %} +{% block media %} +{{ form.media }} +{% endblock %} + {% block page %} - -
    -

    {{ title }}

    -

    {{ instructions }}

    -
    - {% csrf_token %} - {{ form | crispy }} -
    +

    {% firstof title view.title %}

    +
    +
    + {% if instructions %} +

    {{ instructions }}

    + {% endif %} + {% crispy form %} +
    {% endblock %} \ No newline at end of file diff --git a/app/interface/templates/registration/login.html b/app/interface/templates/registration/login.html index 23c402d..f74e2d1 100644 --- a/app/interface/templates/registration/login.html +++ b/app/interface/templates/registration/login.html @@ -7,7 +7,6 @@

    Login is only required to administer a project.
    - If you have an ensemble code enter it here instead.

    {% csrf_token %} @@ -15,7 +14,7 @@
    - Cancel + Cancel
    diff --git a/app/interface/tests/test_access.py b/app/interface/tests/test_access.py new file mode 100644 index 0000000..67dad4e --- /dev/null +++ b/app/interface/tests/test_access.py @@ -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'], ['']) + #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) + """ \ No newline at end of file diff --git a/app/interface/tests/test_register.py b/app/interface/tests/test_register.py deleted file mode 100644 index 52865bd..0000000 --- a/app/interface/tests/test_register.py +++ /dev/null @@ -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'], ['']) - #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) \ No newline at end of file diff --git a/app/interface/urls.py b/app/interface/urls.py index 3ad37f8..de79b26 100644 --- a/app/interface/urls.py +++ b/app/interface/urls.py @@ -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//', views.register, name='register'), + #path('deregister//', views.deregister, name='deregister'), - path('', views.EnsembleProjectListView.as_view(), name='ensemble_detail'), path('ensembles', views.EnsembleListView.as_view(), name="ensemble_list"), - path('ensembles/', views.EnsembleDetailView.as_view(), name='ensemble_detail'), - path('ensembles//forget', views.EnsembleForgetView.as_view(), name='ensemble_forget'), + path('ensembles/', views.EnsembleDetailView.as_view(), name='ensemble_detail'), + path('ensembles//new-project', views.ProjectCreateView.as_view(), name="project_create"), + #path('ensembles//forget', views.EnsembleForgetView.as_view(), name='ensemble_forget'), - path('projects/create', views.ProjectCreateView.as_view(), name="project_create"), + path('projects', views.ProjectListView.as_view(), name="project_list"), path('projects/', views.ProjectDetailView.as_view(), name="project_detail"), path('projects//edit', views.ProjectUpdateView.as_view(), name="project_edit"), #path('projects//submissions.mk', views.ProjectMakefileView.as_view(), name="project_makefile"), diff --git a/app/interface/utils.py b/app/interface/utils.py new file mode 100644 index 0000000..0a91f1e --- /dev/null +++ b/app/interface/utils.py @@ -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()) \ No newline at end of file diff --git a/app/interface/views.py b/app/interface/views.py index b3361cb..cdc747a 100644 --- a/app/interface/views.py +++ b/app/interface/views.py @@ -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 \ No newline at end of file