Compare commits
15 Commits
f7aaa98000
...
bc9f292a2e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc9f292a2e | ||
|
|
8a249de51c | ||
|
|
7e47eec4ae | ||
|
|
59deeffefe | ||
|
|
53ec846f98 | ||
|
|
94bba3769a | ||
|
|
2726a8fe04 | ||
|
|
4731d18131 | ||
|
|
c639020ac9 | ||
|
|
7f6875f3c4 | ||
|
|
025e1344f0 | ||
|
|
bbc74a77f9 | ||
|
|
dfe4a925c7 | ||
|
|
18e5893cc2 | ||
|
|
988161b599 |
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@ -0,0 +1,2 @@
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -2,11 +2,12 @@ __pycache__
|
||||
*.pyc
|
||||
db.sqlite3
|
||||
credentials
|
||||
polyphonic/settings.py
|
||||
local_settings.py
|
||||
env
|
||||
old
|
||||
test.*
|
||||
static
|
||||
teststore
|
||||
cache
|
||||
local_storage
|
||||
media
|
||||
media
|
||||
|
||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@ -0,0 +1,21 @@
|
||||
FROM alpine:3.14
|
||||
|
||||
RUN apk add --no-cache python3 git ghostscript sqlite
|
||||
|
||||
WORKDIR /root
|
||||
RUN python3 -m ensurepip
|
||||
RUN pip3 install -U pip --no-cache-dir
|
||||
|
||||
COPY app/requirements.txt .
|
||||
RUN pip3 install -r requirements.txt --no-cache-dir
|
||||
|
||||
COPY app /opt/polyphonic
|
||||
WORKDIR /opt/polyphonic
|
||||
|
||||
COPY docker_settings.py polyphonic/local_settings.py
|
||||
|
||||
RUN mkdir /var/polyphonic
|
||||
RUN SECRET_KEY=_ python3 manage.py collectstatic --noinput
|
||||
|
||||
ENTRYPOINT ["python3", "manage.py"]
|
||||
CMD ["runserver", "0.0.0.0:8000", "--insecure"]
|
||||
@ -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'}))
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 3.2.7 on 2022-11-19 10:25
|
||||
# Generated by Django 3.2.7 on 2022-11-19 01:24
|
||||
|
||||
import byostorage.user
|
||||
from django.conf import settings
|
||||
@ -9,13 +9,11 @@ import interface.models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [('interface', '0001_initial'), ('interface', '0002_alter_module_name'), ('interface', '0003_alter_ensemble_slug'), ('interface', '0004_alter_project_event_date')]
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('byostorage', '0004_alter_userstorage_storage'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('byostorage', '0006_alter_userstorage_settings_data'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -75,7 +73,7 @@ class Migration(migrations.Migration):
|
||||
name='Module',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.SlugField(choices=[('library', 'Library'), ('submissions', 'Submissions')], max_length=20)),
|
||||
('name', models.SlugField(choices=[('library', 'Library')], max_length=20)),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modules', to='interface.project')),
|
||||
],
|
||||
),
|
||||
25
app/interface/migrations/0002_auto_20230202_0804.py
Normal file
25
app/interface/migrations/0002_auto_20230202_0804.py
Normal 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',
|
||||
),
|
||||
]
|
||||
@ -8,8 +8,6 @@ from byostorage.user import BYOStorage
|
||||
|
||||
import random
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import os.path
|
||||
@ -21,12 +19,19 @@ MEDIA_TYPES = [
|
||||
]
|
||||
|
||||
def rough_date(d):
|
||||
if not d:
|
||||
return False, "sometime..."
|
||||
days = (d - timezone.now()).days
|
||||
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"
|
||||
@ -43,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)
|
||||
@ -81,11 +96,7 @@ class Project(models.Model):
|
||||
owner = models.CharField(max_length=255, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['active', '-pk']
|
||||
|
||||
@property
|
||||
def submissions(self):
|
||||
return self.all_submissions.order_by('-pk')
|
||||
ordering = ['active', 'event_date']
|
||||
|
||||
@property
|
||||
def days(self):
|
||||
@ -93,6 +104,8 @@ class Project(models.Model):
|
||||
|
||||
@property
|
||||
def has_happened(self):
|
||||
if not self.event_date:
|
||||
return False
|
||||
return self.event_date < timezone.now()
|
||||
|
||||
@property
|
||||
@ -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
|
||||
BIN
app/interface/static/fonts/Martinhand3.ttf
Normal file
BIN
app/interface/static/fonts/Martinhand3.ttf
Normal file
Binary file not shown.
|
Before Width: | Height: | Size: 426 KiB After Width: | Height: | Size: 426 KiB |
8
app/interface/templates/403.html
Normal file
8
app/interface/templates/403.html
Normal 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 %}
|
||||
8
app/interface/templates/404.html
Normal file
8
app/interface/templates/404.html
Normal 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 %}
|
||||
@ -8,11 +8,11 @@
|
||||
{% 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 %}
|
||||
{% crispy form %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
49
app/interface/templates/interface/ensemble_detail.html
Normal file
49
app/interface/templates/interface/ensemble_detail.html
Normal file
@ -0,0 +1,49 @@
|
||||
{% 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>
|
||||
|
||||
<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>
|
||||
|
||||
{% 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 %}
|
||||
52
app/interface/templates/interface/ensemble_list.html
Normal file
52
app/interface/templates/interface/ensemble_list.html
Normal file
@ -0,0 +1,52 @@
|
||||
{% extends "interface/project_base.html" %}
|
||||
{% 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-tablet is-one-third-widescreen">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<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>
|
||||
{% empty %}
|
||||
<div class="hero">
|
||||
You don't currently have access to any ensembles - ask your administrator for a link.
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -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>
|
||||
|
||||
@ -42,14 +42,14 @@
|
||||
{% endif %}
|
||||
|
||||
<p class="menu-label">Admin</p>
|
||||
{% if request.is_admin %}
|
||||
<ul class="menu-list">
|
||||
{% if request.is_admin or request.user.is_superuser %}
|
||||
<li><a class="admin-link" href="{% url 'collection_list' %}">Collections</a></li>
|
||||
{% endif %}
|
||||
{% if request.user.is_superuser %}
|
||||
<li><a class="admin-link" href="/admin" target="polyphonic_admin" rel="noopener noreferrer">Django Admin</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<ul class="menu-list">
|
||||
{% if request.user.is_authenticated %}
|
||||
@ -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 %}
|
||||
@ -58,5 +58,11 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if request.is_admin %}
|
||||
<div class="block">
|
||||
<a href="{{ project_link }}">Project Link</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -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">
|
||||
{% if instructions %}
|
||||
<p>{{ instructions }}</p>
|
||||
{% endif %}
|
||||
{% crispy form %}
|
||||
</div>
|
||||
</div>
|
||||
@ -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>
|
||||
237
app/interface/tests/test_access.py
Normal file
237
app/interface/tests/test_access.py
Normal 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)
|
||||
"""
|
||||
@ -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"),
|
||||
@ -27,4 +31,9 @@ urlpatterns = [
|
||||
path('projects/<int:project>/resources/add', views.ResourceCreateView.as_view(), name="resource_create"),
|
||||
path('projects/<int:project>/resources/<int:pk>/upload', views.ResourceUploadView.as_view(), name="resource_upload"),
|
||||
path('projects/<int:project>/resources/<int:pk>/edit', views.ResourceEditView.as_view(), name="resource_edit"),
|
||||
]
|
||||
]
|
||||
|
||||
from django.conf import settings
|
||||
if settings.DEBUG:
|
||||
from django.views.static import serve
|
||||
urlpatterns.append(path('local_storage/<path:path>', serve, {'document_root': 'local_storage'}))
|
||||
23
app/interface/utils.py
Normal file
23
app/interface/utils.py
Normal 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())
|
||||
344
app/interface/views.py
Normal file
344
app/interface/views.py
Normal file
@ -0,0 +1,344 @@
|
||||
"""
|
||||
|
||||
"""
|
||||
|
||||
|
||||
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, 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__)
|
||||
|
||||
class AuthorizedResourceMixin(object):
|
||||
"""
|
||||
Handles two parts of the permission system, signed urls and persistent authenticated resources
|
||||
"""
|
||||
|
||||
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):
|
||||
|
||||
self._authorized = request.session.get('authorized', {})
|
||||
request.is_admin = request.user.is_superuser
|
||||
|
||||
if not self.is_authorized():
|
||||
raise Http404("Either the given resource doesn't exist or you dont have access to it.")
|
||||
|
||||
if self.admin_required and not request.is_admin:
|
||||
raise PermissionDenied("You must be an ensemble admin.")
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class EnsembleMixin(AuthorizedResourceMixin):
|
||||
|
||||
def is_authorized(self):
|
||||
if 'forget' in self.request.GET:
|
||||
self.del_authorized_key('ensemble', self.kwargs['ensemble'])
|
||||
raise Http404("Access removed")
|
||||
super().is_authorized()
|
||||
try:
|
||||
self.ensemble = self.get_ensemble()
|
||||
return True
|
||||
except models.Ensemble.DoesNotExist:
|
||||
return False
|
||||
|
||||
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):
|
||||
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)
|
||||
if 'project' in self.kwargs:
|
||||
context['project'] = self.project
|
||||
context['modules'] = self.project.active_modules
|
||||
return context
|
||||
|
||||
class CrispyFormMixin(object):
|
||||
|
||||
cancel_url = None
|
||||
|
||||
def get_cancel_url(self):
|
||||
return self.cancel_url
|
||||
|
||||
""" ENSEMBLE VIEWS """
|
||||
|
||||
class EnsembleListView(EnsembleMixin, ListView):
|
||||
model = models.Ensemble
|
||||
|
||||
def is_authorized(self):
|
||||
return True
|
||||
|
||||
class EnsembleDetailView(EnsembleMixin, DetailView):
|
||||
|
||||
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_link'] = signed_url(self.request.path)
|
||||
return data
|
||||
|
||||
""" PROJECT VIEWS """
|
||||
|
||||
class ProjectListView(ProjectMixin, ListView):
|
||||
|
||||
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"
|
||||
form_class = forms.ProjectForm
|
||||
|
||||
def form_valid(self, form):
|
||||
self.object = form.save(commit=False)
|
||||
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 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"
|
||||
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'
|
||||
#
|
||||
# def get_queryset(self):
|
||||
# if self.request.is_admin:
|
||||
# return models.Project.objects.all()
|
||||
#
|
||||
# return models.Project.objects.filter(ensemble=self.request.ensemble_id)
|
||||
#
|
||||
# def get_context_data(self, **kwargs):
|
||||
# data = super().get_context_data(**kwargs)
|
||||
#
|
||||
# data['submissions'] = []
|
||||
# data['targets'] = []
|
||||
# for s in self.object.submissions:
|
||||
# name = s.short_name
|
||||
# data['targets'].append(name)
|
||||
# data['submissions'].append({
|
||||
# 'url': self.request.build_absolute_uri(signed_url('submission_download', project=self.kwargs['pk'], pk=s.pk)),
|
||||
# 'name': name,
|
||||
# })
|
||||
#
|
||||
# return data
|
||||
|
||||
""" WIKI VIEWS """
|
||||
|
||||
class WikiView(ProjectMixin, DetailView):
|
||||
template_name = 'interface/wiki.html'
|
||||
model = models.WikiPage
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
data = super().get_context_data(**kwargs)
|
||||
data['wiki_html'] = markdown(self.object.markdown)
|
||||
return data
|
||||
|
||||
class WikiCreateView(ProjectMixin, CreateView):
|
||||
admin_required = True
|
||||
model = models.WikiPage
|
||||
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.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
|
||||
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/default_form.html'
|
||||
title = "Add a new resource"
|
||||
|
||||
def form_valid(self, form):
|
||||
self.object = form.save(commit=False)
|
||||
self.object.project = self.project
|
||||
self.object.save()
|
||||
return redirect('resource_upload', project=self.object.project_id, pk=self.object.pk)
|
||||
|
||||
class ResourceUploadView(ProjectMixin, UpdateView):
|
||||
admin_required = True
|
||||
model = models.Resource
|
||||
fields = ['file']
|
||||
template_name = 'interface/default_form.html'
|
||||
|
||||
def get_success_url(self):
|
||||
return resolve_url('resource_list', project=self.kwargs['project'])
|
||||
|
||||
class ResourceListView(ProjectMixin, ListView):
|
||||
model = models.Resource
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
if not self.request.is_admin:
|
||||
qs = qs.filter(visible=True)
|
||||
return qs
|
||||
|
||||
class ResourceEditView(ProjectMixin, UpdateView):
|
||||
admin_required = True
|
||||
model = models.Resource
|
||||
fields = ['name', 'description', 'visible']
|
||||
template_name = 'interface/default_form.html'
|
||||
|
||||
def get_success_url(self):
|
||||
return resolve_url('resource_list', project=self.kwargs['project'])
|
||||
@ -33,7 +33,7 @@ admin.site.register(models.Work, WorkAdmin)
|
||||
|
||||
class SectionInline(admin.TabularInline):
|
||||
model = models.Section
|
||||
fields = ['tag', 'start', 'end']
|
||||
fields = ['type', 'tag', 'ordinal', 'start', 'end']
|
||||
|
||||
class DocumentAdmin(admin.ModelAdmin):
|
||||
list_display = ['work', '__str__']
|
||||
@ -1,8 +1,6 @@
|
||||
from django import forms
|
||||
from .models import Work
|
||||
from interface.models import Project
|
||||
from interface.widgets import DatePickerInput
|
||||
from django.db.models import Q
|
||||
from interface.forms import BaseForm
|
||||
|
||||
|
||||
@ -11,7 +9,7 @@ class WorkCreateForm(forms.ModelForm, BaseForm):
|
||||
|
||||
class Meta:
|
||||
model = Work
|
||||
fields = ['name', 'code', 'running_time', 'notes']
|
||||
fields = ['name', 'composer', 'edition', 'code', 'running_time', 'notes']
|
||||
|
||||
class PlaylistAddForm(forms.Form):
|
||||
work = forms.ModelChoiceField(queryset=Work.objects.all())
|
||||
@ -2,9 +2,12 @@
|
||||
from collections import namedtuple
|
||||
|
||||
# taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_Instruments
|
||||
# Place any extra abbreviations at the top
|
||||
|
||||
ABBREVIATIONS = """
|
||||
score Score
|
||||
cb Double bass
|
||||
|
||||
acc Accordion
|
||||
afl Alto flute
|
||||
alt Alto (voice) (contralto)
|
||||
@ -42,6 +45,7 @@ crt Cornet
|
||||
crtt Cornett (Zink)
|
||||
cv Child's voice
|
||||
db Double Bass
|
||||
drum Drumset
|
||||
dlcn Dulcian
|
||||
dom Domra
|
||||
dulc Dulcimer
|
||||
@ -139,6 +143,18 @@ xyl Xylophone
|
||||
zith Zither
|
||||
"""
|
||||
|
||||
ORCHESTRATIONS = {
|
||||
'SATB': ('S', 'A', 'T', 'B'),
|
||||
'String Quartet': ('Vln1', 'Vln2', 'Vla', 'Vc'),
|
||||
'String Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb'),
|
||||
'Chamber Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb',
|
||||
'Fl1', 'Fl2', 'Cl1', 'Cl2', 'Hn1', 'Hn2',
|
||||
'Tpt1', 'Tpt2', 'Tbn1', 'Tbn2', 'Tuba',
|
||||
'Timp', 'Drum', 'Perc'),
|
||||
'Custom': (),
|
||||
}
|
||||
|
||||
|
||||
INSTRUMENTS = []
|
||||
for line in ABBREVIATIONS.split('\n'):
|
||||
parts = line.strip().split(maxsplit=1)
|
||||
@ -146,8 +162,8 @@ for line in ABBREVIATIONS.split('\n'):
|
||||
name, _, _ = parts[1].partition('(')
|
||||
INSTRUMENTS.append((parts[0], name))
|
||||
|
||||
INSTRUMENT_LOOKUP = dict(INSTRUMENTS)
|
||||
TAG_LOOKUP = dict( ( (x[1].lower(), x[0]) for x in INSTRUMENTS ) )
|
||||
INSTRUMENT_NAMES = dict(INSTRUMENTS)
|
||||
INSTRUMENT_TAGS = dict( ( (x[1].lower(), x[0]) for x in INSTRUMENTS ) )
|
||||
|
||||
class Instrument(namedtuple('Instrument', ('name', 'variant'), defaults=[None])):
|
||||
|
||||
@ -162,7 +178,7 @@ class Instrument(namedtuple('Instrument', ('name', 'variant'), defaults=[None]))
|
||||
Instrument(name='Jaws Harp', variant=None)
|
||||
"""
|
||||
abbr, _, variant = tag.partition('-')
|
||||
name = INSTRUMENT_LOOKUP.get(abbr.lower(), abbr)
|
||||
name = INSTRUMENT_NAMES.get(abbr.lower(), abbr)
|
||||
|
||||
if variant:
|
||||
return cls(name, variant)
|
||||
@ -176,7 +192,7 @@ class Instrument(namedtuple('Instrument', ('name', 'variant'), defaults=[None]))
|
||||
>>> Instrument('Double Bass').abbreviate()
|
||||
'db'
|
||||
"""
|
||||
tag = TAG_LOOKUP.get(self.name.lower())
|
||||
tag = INSTRUMENT_TAGS.get(self.name.lower())
|
||||
if self.variant:
|
||||
tag = f"{tag}-{self.variant}"
|
||||
return tag
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 3.2.7 on 2022-11-19 10:24
|
||||
# Generated by Django 3.2.7 on 2022-11-19 01:24
|
||||
|
||||
import byostorage.user
|
||||
from django.conf import settings
|
||||
@ -9,14 +9,12 @@ import library.models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [('library', '0001_initial'), ('library', '0002_auto_20221118_2208'), ('library', '0003_work_composer'), ('library', '0004_auto_20221118_2223'), ('library', '0005_auto_20221118_2253'), ('library', '0006_auto_20221119_2121')]
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('byostorage', '0004_alter_userstorage_storage'),
|
||||
('interface', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('byostorage', '0006_alter_userstorage_settings_data'),
|
||||
('interface', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -32,6 +30,16 @@ class Migration(migrations.Migration):
|
||||
('storage', models.ForeignKey(blank=True, help_text='User storage for documents', null=True, on_delete=django.db.models.deletion.CASCADE, to='byostorage.userstorage')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Document',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('doctype', models.PositiveSmallIntegerField(choices=[(1, 'PDF'), (2, 'Audio'), (3, 'Video'), (4, 'Source')], default=1)),
|
||||
('upload', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=library.models.doc_upload_filename)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('version', models.CharField(blank=True, max_length=30)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProjectItem',
|
||||
fields=[
|
||||
@ -40,6 +48,7 @@ class Migration(migrations.Migration):
|
||||
('due', models.DateTimeField(blank=True, null=True)),
|
||||
('returned', models.DateTimeField(blank=True, null=True)),
|
||||
('order', models.SmallIntegerField(default=0)),
|
||||
('section', models.CharField(blank=True, max_length=100)),
|
||||
('approved_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='interface.project')),
|
||||
],
|
||||
@ -53,6 +62,8 @@ class Migration(migrations.Migration):
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('slug', models.SlugField(editable=False, help_text='Used as folder name', max_length=100)),
|
||||
('name', models.CharField(help_text='Original name of the work', max_length=255)),
|
||||
('edition', models.CharField(blank=True, help_text='Edition details to distinguish multiple versions', max_length=255)),
|
||||
('composer', models.CharField(blank=True, help_text='Surname, First Name/Initials', max_length=255)),
|
||||
('original_parts', models.JSONField(blank=True, default=dict, help_text='Original printed parts (IMSLP format)')),
|
||||
('code', models.CharField(blank=True, help_text='Collection specific code or number', max_length=100)),
|
||||
('licence', models.PositiveSmallIntegerField(choices=[(2, 'Public Domain'), (4, 'Copyright Expired'), (6, 'Copyrighted'), (10, 'Internal use only')], default=6, help_text='Copyright status')),
|
||||
@ -62,36 +73,6 @@ class Migration(migrations.Migration):
|
||||
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='works', to='library.collection')),
|
||||
('parent', models.ForeignKey(blank=True, help_text='Arrangement of another work or part of an anthology', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_works', to='library.work')),
|
||||
('projects', models.ManyToManyField(help_text='Current usage', related_name='works', through='library.ProjectItem', to='interface.Project')),
|
||||
('composer', models.CharField(blank=True, help_text='Surname, First Name/Initials', max_length=255)),
|
||||
('edition', models.CharField(blank=True, help_text='Edition details to distinguish multiple versions', max_length=255)),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectitem',
|
||||
name='work',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_items', to='library.work'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EnsembleAccess',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('access_type', models.PositiveSmallIntegerField(choices=[(1, 'Unlimited'), (2, 'Approval required')], default=2)),
|
||||
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_ensembles', to='library.collection')),
|
||||
('ensemble', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_collections', to='interface.ensemble')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Ensemble access',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Document',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('doctype', models.PositiveSmallIntegerField(choices=[(1, 'PDF'), (2, 'Audio'), (3, 'Video'), (4, 'Source')], default=1)),
|
||||
('upload', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=library.models.doc_upload_filename)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('version', models.CharField(blank=True, max_length=30)),
|
||||
('work', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='docs', to='library.work')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@ -116,4 +97,26 @@ class Migration(migrations.Migration):
|
||||
'ordering': ['doc', 'start', 'pk'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectitem',
|
||||
name='work',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_items', to='library.work'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EnsembleAccess',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('access_type', models.PositiveSmallIntegerField(choices=[(1, 'Unlimited'), (2, 'Approval required')], default=2)),
|
||||
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_ensembles', to='library.collection')),
|
||||
('ensemble', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_collections', to='interface.ensemble')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Ensemble access',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='work',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='docs', to='library.work'),
|
||||
),
|
||||
]
|
||||
28
app/library/migrations/0002_auto_20221201_0934.py
Normal file
28
app/library/migrations/0002_auto_20221201_0934.py
Normal file
@ -0,0 +1,28 @@
|
||||
# Generated by Django 3.2.7 on 2022-11-30 22:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('library', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='work',
|
||||
name='slug',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='work',
|
||||
name='path',
|
||||
field=models.CharField(default='', editable=False, help_text='Used as folder name', max_length=255),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='work',
|
||||
name='composer',
|
||||
field=models.CharField(default='Anon', help_text='Surname, Initials', max_length=255),
|
||||
),
|
||||
]
|
||||
21
app/library/migrations/0003_auto_20221201_1540.py
Normal file
21
app/library/migrations/0003_auto_20221201_1540.py
Normal file
@ -0,0 +1,21 @@
|
||||
# Generated by Django 3.2.7 on 2022-12-01 04:40
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('library', '0002_auto_20221201_0934'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='work',
|
||||
unique_together={('collection', 'composer', 'name', 'edition')},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='work',
|
||||
name='path',
|
||||
),
|
||||
]
|
||||
40
app/library/migrations/0004_auto_20230101_1535.py
Normal file
40
app/library/migrations/0004_auto_20230101_1535.py
Normal file
@ -0,0 +1,40 @@
|
||||
# Generated by Django 3.2.7 on 2023-01-01 04:35
|
||||
|
||||
import byostorage.cached
|
||||
import byostorage.user
|
||||
from django.db import migrations, models
|
||||
import library.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('library', '0003_auto_20221201_1540'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='section',
|
||||
options={'ordering': ['doc', 'type', 'start', 'pk']},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='section',
|
||||
name='type',
|
||||
field=models.SmallIntegerField(choices=[(1, 'Instrument'), (2, 'Movement')], default=1),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='upload',
|
||||
field=models.FileField(storage=byostorage.cached.CachedStorage(byostorage.user.BYOStorage()), upload_to=library.models.doc_upload_filename),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='work',
|
||||
name='original_parts',
|
||||
field=models.JSONField(default=dict, help_text='Original printed parts (IMSLP format)'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='work',
|
||||
unique_together=set(),
|
||||
),
|
||||
]
|
||||
22
app/library/migrations/0005_auto_20230101_1547.py
Normal file
22
app/library/migrations/0005_auto_20230101_1547.py
Normal file
@ -0,0 +1,22 @@
|
||||
# Generated by Django 3.2.7 on 2023-01-01 04:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('library', '0004_auto_20230101_1535'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='section',
|
||||
options={'ordering': ['type', 'ordinal', 'doc', 'start', 'pk']},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='section',
|
||||
name='ordinal',
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
]
|
||||
@ -8,6 +8,7 @@ from django.core.files.storage import get_storage_class
|
||||
from django.db.models import Q, Count, Min, Max
|
||||
|
||||
from byostorage.user import BYOStorage
|
||||
from byostorage.cached import CachedStorage
|
||||
from .imslp import Instrument
|
||||
|
||||
import logging
|
||||
@ -22,38 +23,10 @@ logger = logging.getLogger(__name__)
|
||||
# library_storage = get_storage_class()()
|
||||
#logger.info("Library storage: %s", library_storage.__class__.__name__)
|
||||
|
||||
# FIXME: move back to settings
|
||||
library_storage = CachedStorage(BYOStorage())
|
||||
|
||||
'''
|
||||
ORCHESTRATIONS = {
|
||||
'SATB': ('S', 'A', 'T', 'B'),
|
||||
'String Quartet': ('Vln1', 'Vln2', 'Vla', 'Vc'),
|
||||
'String Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb'),
|
||||
'Chamber Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb',
|
||||
'Fl1', 'Fl2', 'Cl1', 'Cl2', 'Hn1', 'Hn2',
|
||||
'Tpt1', 'Tpt2', 'Tbn1', 'Tbn2', 'Tuba',
|
||||
'Timp', 'Drum', 'Perc'),
|
||||
'Custom': (),
|
||||
}
|
||||
'''
|
||||
|
||||
DOCTYPES = [
|
||||
(1, 'PDF'),
|
||||
(2, 'Audio'),
|
||||
(3, 'Video'),
|
||||
(4, 'Source'),
|
||||
]
|
||||
|
||||
LICENCE_TYPES = [
|
||||
(2, 'Public Domain'),
|
||||
(4, 'Copyright Expired'),
|
||||
(6, 'Copyrighted'),
|
||||
(10, 'Internal use only'),
|
||||
]
|
||||
|
||||
ACCESS_TYPES = [
|
||||
(1, 'Unlimited'),
|
||||
(2, 'Approval required'),
|
||||
]
|
||||
|
||||
|
||||
'''
|
||||
@ -97,7 +70,7 @@ class ProjectItem(models.Model):
|
||||
ordering = ['order', 'work']
|
||||
|
||||
def __str__(self):
|
||||
return f"<{self.project_id}:{self.work.slug}>"
|
||||
return f"<{self.project_id}:{slugify(self.work.name)}>"
|
||||
|
||||
class Collection(models.Model):
|
||||
"""
|
||||
@ -115,7 +88,20 @@ class Collection(models.Model):
|
||||
help_text="User storage for documents")
|
||||
notes = models.TextField(blank=True,
|
||||
help_text="Publicly visible notes about collection and loans policy (markdown format)")
|
||||
|
||||
def meta(self, name):
|
||||
items = WorkMeta.objects.filter(work__collection=self.pk, name=name).values_list('value', flat=True).distinct()
|
||||
return items
|
||||
|
||||
@property
|
||||
def tags(self):
|
||||
return self.meta('tag')
|
||||
|
||||
@property
|
||||
def genres(self):
|
||||
return self.meta('genre')
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@ -123,6 +109,15 @@ class EnsembleAccess(models.Model):
|
||||
"""
|
||||
Can have different access levels to a collection
|
||||
"""
|
||||
|
||||
ACCESS_UNLIMITED = 1
|
||||
ACCESS_APPROVED = 2
|
||||
|
||||
ACCESS_TYPES = (
|
||||
(ACCESS_UNLIMITED, 'Unlimited'),
|
||||
(ACCESS_APPROVED, 'Approval required'),
|
||||
)
|
||||
|
||||
ensemble = models.ForeignKey('interface.Ensemble', on_delete=models.CASCADE, related_name="allowed_collections")
|
||||
collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name="allowed_ensembles")
|
||||
access_type = models.PositiveSmallIntegerField(choices=ACCESS_TYPES, default=2)
|
||||
@ -130,30 +125,36 @@ class EnsembleAccess(models.Model):
|
||||
class Meta:
|
||||
verbose_name_plural = "Ensemble access"
|
||||
|
||||
META_TAGS = (
|
||||
('tag', 'Tag'),
|
||||
('arr', 'Arranger'),
|
||||
('lyrics', 'Lyracist'),
|
||||
('genre', 'Genre'),
|
||||
('style', 'Style'),
|
||||
('orchestration', 'Orchestration'),
|
||||
)
|
||||
|
||||
class Work(models.Model):
|
||||
"""
|
||||
A musical work 'owned' by a collection from a licencing perspective.
|
||||
"""
|
||||
slug = models.SlugField(max_length=100, editable=False,
|
||||
help_text="Used as folder name")
|
||||
LICENCE_PUBLIC = 2
|
||||
LICENCE_EXPIRED = 4
|
||||
LICENCE_RECORDING = 5
|
||||
LICENCE_PERFORMANCE = 6
|
||||
LICENCE_PERUSAL = 8
|
||||
LICENCE_NONE = 10
|
||||
|
||||
LICENCE_TYPES = (
|
||||
(LICENCE_PUBLIC, 'Public Domain'),
|
||||
(LICENCE_EXPIRED, 'Copyright Expired'),
|
||||
(LICENCE_RECORDING, 'Recording Licence'),
|
||||
(LICENCE_PERFORMANCE, 'Performance Licence'),
|
||||
(LICENCE_PERUSAL, 'Perusal Licence'),
|
||||
(LICENCE_NONE, 'Internal use only'),
|
||||
)
|
||||
|
||||
name = models.CharField(max_length=255, help_text="Original name of the work")
|
||||
edition = models.CharField(max_length=255, blank=True,
|
||||
help_text="Edition details to distinguish multiple versions")
|
||||
parent = models.ForeignKey('Work', null=True, blank=True, on_delete=models.SET_NULL, related_name="related_works",
|
||||
help_text="Arrangement of another work or part of an anthology")
|
||||
composer = models.CharField(max_length=255, blank=True,
|
||||
help_text="Surname, First Name/Initials")
|
||||
composer = models.CharField(max_length=255, default='Anon',
|
||||
help_text="Surname, Initials")
|
||||
|
||||
original_parts = models.JSONField(default=dict, blank=True, help_text="Original printed parts (IMSLP format)")
|
||||
original_parts = models.JSONField(default=dict, help_text="Original printed parts (IMSLP format)")
|
||||
|
||||
# Collection details
|
||||
collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name="works")
|
||||
@ -168,6 +169,10 @@ class Work(models.Model):
|
||||
# Allocation to projects
|
||||
projects = models.ManyToManyField('interface.Project', through='ProjectItem', related_name="works", help_text="Current usage")
|
||||
|
||||
@property
|
||||
def folder(self):
|
||||
return f"{slugify(self.composer)}/{slugify(self.name)}-{self.pk:04d}"
|
||||
|
||||
def extract(self, *tags):
|
||||
|
||||
qs = self.docs.filter(sections__tag__in=tags)
|
||||
@ -194,11 +199,6 @@ class Work(models.Model):
|
||||
def meta(self):
|
||||
return self.meta_info.exclude(name='tag')
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
super(Work, self).save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def active_projects(self):
|
||||
return self.projects.filter(active=True)
|
||||
@ -235,10 +235,11 @@ class Work(models.Model):
|
||||
|
||||
composer = self.composer or "Anon"
|
||||
words = self.name.split()
|
||||
if len(words) > 2:
|
||||
work = ''.join([ x[0] for x in self.name.split() ])
|
||||
else:
|
||||
work = words[0][:3]
|
||||
#if len(words) > 2:
|
||||
# work = ''.join([ x[0] for x in self.name.split() ])
|
||||
#else:
|
||||
# work = words[0][:3]
|
||||
work = words[0][:3]
|
||||
|
||||
return f"{composer[:4]}-{work}-{self.pk:03d}".upper()
|
||||
|
||||
@ -247,8 +248,18 @@ class Work(models.Model):
|
||||
return f"{self.name} ({self.composer})"
|
||||
|
||||
class WorkMeta(models.Model):
|
||||
|
||||
META_CHOICES = (
|
||||
('tag', 'Tag'),
|
||||
('arr', 'Arranger'),
|
||||
('lyrics', 'Lyracist'),
|
||||
('genre', 'Genre'),
|
||||
('style', 'Style'),
|
||||
('orchestration', 'Orchestration'),
|
||||
)
|
||||
|
||||
work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name='meta_info')
|
||||
name = models.SlugField(max_length=20, choices=META_TAGS)
|
||||
name = models.SlugField(max_length=20, choices=META_CHOICES)
|
||||
value = models.CharField(max_length=255)
|
||||
|
||||
def doc_upload_filename(doc, filename):
|
||||
@ -256,18 +267,35 @@ def doc_upload_filename(doc, filename):
|
||||
storage = collection.storage
|
||||
if not storage:
|
||||
raise RuntimeError("Collection has no storage attached")
|
||||
return f'{storage}:library/{collection.prefix}/{doc.work.slug}-{doc.work.pk}/{filename}'
|
||||
return f'{storage}:library/{collection.prefix}/{doc.work.folder}/{filename}'
|
||||
|
||||
class Document(models.Model):
|
||||
"""
|
||||
Document represents a single file stored in the storage backend.
|
||||
"""
|
||||
|
||||
DOCTYPE_PDF = 1
|
||||
DOCTYPE_AUDIO = 2
|
||||
DOCTYPE_VIDEO = 3
|
||||
DOCTYPE_SOURCE = 4
|
||||
|
||||
DOCTYPES = (
|
||||
(DOCTYPE_PDF, 'PDF'),
|
||||
(DOCTYPE_AUDIO, 'Audio'),
|
||||
(DOCTYPE_VIDEO, 'Video'),
|
||||
(DOCTYPE_SOURCE, 'Source'),
|
||||
)
|
||||
|
||||
work = models.ForeignKey('Work', on_delete=models.CASCADE, related_name="docs")
|
||||
doctype = models.PositiveSmallIntegerField(choices=DOCTYPES, default=1)
|
||||
upload = models.FileField(upload_to=doc_upload_filename, storage=BYOStorage())
|
||||
upload = models.FileField(upload_to=doc_upload_filename, storage=library_storage)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
version = models.CharField(max_length=30, blank=True)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
self.upload.delete(save=False)
|
||||
return super().delete(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.upload.name
|
||||
|
||||
@ -275,21 +303,54 @@ class Section(models.Model):
|
||||
"""
|
||||
Section is a tagged portion of a Document
|
||||
"""
|
||||
|
||||
TYPE_INSTRUMENT = 1
|
||||
TYPE_MOVEMENT = 2
|
||||
TYPE_EXCERPT = 3
|
||||
|
||||
SECTION_TYPES = (
|
||||
(TYPE_INSTRUMENT, "Instrument"),
|
||||
(TYPE_MOVEMENT, "Movement"),
|
||||
(TYPE_EXCERPT, "Excerpt"),
|
||||
)
|
||||
|
||||
SECTION_CLASSES = {
|
||||
TYPE_INSTRUMENT: 'info',
|
||||
TYPE_MOVEMENT: 'success',
|
||||
TYPE_EXCERPT: 'warning',
|
||||
}
|
||||
|
||||
|
||||
type = models.SmallIntegerField(choices=SECTION_TYPES)
|
||||
doc = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="sections")
|
||||
tag = models.CharField(max_length=50)
|
||||
tag = models.CharField(max_length=50, blank=True)
|
||||
ordinal = models.IntegerField(default=0)
|
||||
start = models.SmallIntegerField(null=True, blank=True)
|
||||
end = models.SmallIntegerField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['doc', 'start', 'pk']
|
||||
ordering = ['type', 'ordinal', 'doc', 'start', 'pk']
|
||||
|
||||
@property
|
||||
def instrument(self):
|
||||
return Instrument.from_tag(self.tag)
|
||||
def name(self):
|
||||
if self.type == self.TYPE_INSTRUMENT:
|
||||
instr = Instrument.from_tag(self.tag)
|
||||
if self.ordinal:
|
||||
return f'{instr} {self.ordinal}'
|
||||
return str(instr)
|
||||
return f"{self.ordinal} - {self.tag}"
|
||||
|
||||
#@property
|
||||
#def instrument(self):
|
||||
# return Instrument.from_tag(self.tag)
|
||||
|
||||
@property
|
||||
def bulma_class(self):
|
||||
return self.SECTION_CLASSES[self.type]
|
||||
|
||||
@property
|
||||
def filename(self):
|
||||
return slugify(f'{self.doc.work.name}_{self.instrument}') + '.pdf'
|
||||
return slugify(f'{self.doc.work.name} - {self.name}') + '.pdf'
|
||||
|
||||
@property
|
||||
def pagerange(self):
|
||||
@ -300,4 +361,4 @@ class Section(models.Model):
|
||||
return "all"
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.doc.upload} [{self.pagerange}]'
|
||||
return self.name
|
||||
37
app/library/templates/library/collection_list.html
Normal file
37
app/library/templates/library/collection_list.html
Normal file
@ -0,0 +1,37 @@
|
||||
{% extends "interface/project_base.html" %}
|
||||
|
||||
{% block page %}
|
||||
<h3 class="title">Library collections for {% firstof request.user.first_name request.user.username %}</h3>
|
||||
|
||||
<div class="columns is-multiline">
|
||||
{% for collection in object_list %}
|
||||
<div class="column is-half">
|
||||
<div class="card">
|
||||
<header class="card-header">
|
||||
<a class="" href="{% url 'collection_work_list' pk=collection.id %}">
|
||||
<p class="card-header-title">{{ collection.name }}</p>
|
||||
</a>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
<p>
|
||||
{% if collection.location %}{{ collection.location }},{% endif %}
|
||||
{{ collection.works.count }} items.
|
||||
</p>
|
||||
<p>
|
||||
{% for tag in collection.tags %}
|
||||
<a href="{% url 'collection_work_list' collection.pk %}?filter=tag:{{ tag }}" class="tag is-success">{{ tag }}</a>
|
||||
{% endfor %}
|
||||
{% for genre in collection.genres %}
|
||||
<a href="{% url 'collection_work_list' collection.pk %}?filter=genre:{{ genre }}" class="tag is-warning">{{ genre }}</a>
|
||||
{% endfor %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<small>{{ ensemble.ensemble_code }}</small>
|
||||
</div>
|
||||
{% endblock %}
|
||||
22
app/library/templates/library/document_confirm_delete.html
Normal file
22
app/library/templates/library/document_confirm_delete.html
Normal file
@ -0,0 +1,22 @@
|
||||
{% extends "interface/project_base.html" %}
|
||||
|
||||
{% block page %}
|
||||
<div class="columns">
|
||||
<div class="column is-half is-centered">
|
||||
<div class="block">
|
||||
<p>Are you sure you want to delete<br>
|
||||
<b>"{{ object.upload.name }}"</b>?</p>
|
||||
</div>
|
||||
<form method="post">{% csrf_token %}
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button class="button is-link">Yes</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<a class="button is-link is-light" href="{% url 'work_detail' object.work.pk %}">No</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
17
app/library/templates/library/document_entry.html
Normal file
17
app/library/templates/library/document_entry.html
Normal file
@ -0,0 +1,17 @@
|
||||
{% load path_filters %}
|
||||
<tr>
|
||||
<td><a href="{{ doc.upload.url }}" target="_blank">
|
||||
{{ doc.upload.name|basename }}</a></td>
|
||||
<td>
|
||||
{% for section in doc.sections.all %}
|
||||
<a class="tag is-{{ section.bulma_class }}" target="_blank" href="{% url 'part_download' pk=section.pk filename=section.filename %}">{{ section.name }}</a>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="has-text-right" style="white-space: nowrap;">
|
||||
{% if request.is_admin %}
|
||||
<a href="{% url 'document_annotate' pk=doc.pk %}"><i class="fas fa-tags"
|
||||
title="Manage Tags"></i></a>
|
||||
<a href="{% url 'document_delete' pk=doc.pk %}"><i class="fas fa-trash-alt" title="Delete Document"></i></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@ -21,7 +21,7 @@
|
||||
<h3 class="title">
|
||||
{{ work.name }}
|
||||
{% for tag in work.tags %}
|
||||
<span class="tag is-success">{{ tag }}</span>
|
||||
<a href="{% url 'collection_work_list' work.collection.pk %}?filter=tag:{{ tag }}" class="tag is-success">{{ tag }}</a>
|
||||
{% endfor %}
|
||||
</h3>
|
||||
<p class="subtitle">{% firstof work.composer "Unattributed" %}{% if work.edition %} - {{ work.edition }}{% endif %}</p>
|
||||
@ -30,10 +30,10 @@
|
||||
|
||||
<p class="block">
|
||||
Location: <a href="{% url 'collection_work_list' work.collection.pk %}">{{ work.collection }}</a> [{{ work.identifier }}]<br/>
|
||||
Running time: {{ work.duration }}<br/>
|
||||
Running time: {% firstof work.duration 'Unknown' %}<br/>
|
||||
Licence: {{ work.get_licence_display }}<br/>
|
||||
{% for meta in work.meta %}
|
||||
{{ meta.get_name_display }}: {{ meta.value }}<br/>
|
||||
{{ meta.get_name_display }}: <a href="{% url 'collection_work_list' work.collection.pk %}?filter={{ meta.name}}:{{ meta.value }}">{{ meta.value }}</a><br/>
|
||||
{% endfor %}
|
||||
</p>
|
||||
|
||||
@ -79,9 +79,9 @@
|
||||
{% if work.digital_parts %}
|
||||
<a class="tag is-danger" href="{% url 'work_partset' pk=work.pk %}">Full Set</a>
|
||||
{% endif %}
|
||||
{% for part in work.digital_parts %}
|
||||
<a class="tag is-info" href="{% url 'part_download' pk=part.pk filename=part.filename %}"
|
||||
target="part_{{ part.pk }}" rel="">{{ part.instrument }}</a>
|
||||
{% for section in work.digital_parts %}
|
||||
<a class="tag is-info" href="{% url 'part_download' pk=section.pk filename=section.filename %}"
|
||||
target="section_{{ section.pk }}" rel="">{{ section.name }}</a>
|
||||
{% empty %}
|
||||
<p class="is-italic">No digital parts available</p>
|
||||
{% endfor %}
|
||||
@ -110,22 +110,7 @@
|
||||
</thead>
|
||||
<tbody id="doc-list">
|
||||
{% for doc in work.docs.all %}
|
||||
<tr>
|
||||
<td><a href="{% url 'document_download' pk=doc.pk %}" target="_blank">
|
||||
{{ doc.upload.name|basename }}</a></td>
|
||||
<td>
|
||||
{% for part in doc.sections.all %}
|
||||
<a class="tag is-info" href="{% url 'part_download' pk=part.pk filename=part.filename %}">{{ part.instrument }}</a>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="has-text-right">
|
||||
{% if request.is_admin %}
|
||||
<a href="{% url 'document_annotate' pk=doc.pk %}"><i class="fas fa-tags"
|
||||
title="Manage Tags"></i></a>
|
||||
<a href=""><i class="fas fa-trash-alt" title="Delete Document"></i></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% include 'library/document_entry.html' %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@ -188,7 +173,7 @@
|
||||
<script>
|
||||
Dropzone.options.docUpload = { // camelized version of the `id`
|
||||
paramName: "upload", // The name that will be used to transfer the file
|
||||
maxFilesize: 12, // MB
|
||||
maxFilesize: 50, // MB
|
||||
createImageThumbnails: false,
|
||||
thumbnailWidth: 60,
|
||||
thumbnailHeight: 60,
|
||||
@ -1,8 +1,15 @@
|
||||
from django.urls import path
|
||||
from django.urls import path, include
|
||||
from django.contrib.auth import views as auth_views
|
||||
from rest_framework import routers
|
||||
|
||||
from . import views
|
||||
|
||||
from library.views import api
|
||||
|
||||
#router = routers.DefaultRouter()
|
||||
#router.register(r'collection', external.CollectionViewSet, basename="collection")
|
||||
#router.register(r'work', external.WorkViewSet, basename="work")
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
path('projects/<int:project>/items', views.ProjectItemListView.as_view(), name="item_list"),
|
||||
@ -11,7 +18,7 @@ urlpatterns = [
|
||||
|
||||
path('library/collections', views.CollectionListView.as_view(), name="collection_list"),
|
||||
path('library/collections/<int:pk>', views.CollectionWorkListView.as_view(), name="collection_work_list"),
|
||||
path('library/collection/<int:pk>/create', views.WorkAddView.as_view(), name="work_add"),
|
||||
path('library/collections/<int:pk>/create', views.WorkAddView.as_view(), name="work_add"),
|
||||
|
||||
path('library/works', views.WorkListView.as_view(), name="work_list"),
|
||||
path('library/works/<int:pk>', views.WorkDetailView.as_view(), name="work_detail"),
|
||||
@ -20,10 +27,13 @@ urlpatterns = [
|
||||
path('library/works/<int:pk>/add_to_project', views.WorkAddToProject.as_view(), name="work_add_to_project"),
|
||||
path('library/works/<int:pk>/upload', views.WorkAddDocumentView.as_view(), name="document_add"),
|
||||
|
||||
path('library/documents/<int:pk>/delete', views.DocumentDeleteView.as_view(), name="document_delete"),
|
||||
path('library/documents/<int:pk>/download', views.DocumentDownloadView.as_view(), name="document_download"),
|
||||
path('library/documents/<int:pk>/annotate', views.DocumentAnnotateView.as_view(), name="document_annotate"),
|
||||
path('library/parts/<int:pk>/<str:filename>', views.PartDownloadView.as_view(), name="part_download"),
|
||||
]
|
||||
|
||||
from django.views.static import serve
|
||||
urlpatterns.append(path('docs/<path:path>', serve, {'document_root': 'local_storage'}))
|
||||
#path('api/', include(router.urls))
|
||||
path('api/library/collections/<int:pk>/export', api.CollectionExportView.as_view(), name="collection_export"),
|
||||
path('api/library/works/<int:pk>/export', api.WorkExportView.as_view(), name="work_export"),
|
||||
path('api/library/collections/<int:pk>/import', api.WorkImportView.as_view(), name="work_import"),
|
||||
]
|
||||
@ -1,22 +1,24 @@
|
||||
from django.shortcuts import render, redirect, resolve_url
|
||||
from django.views.generic.detail import DetailView, SingleObjectMixin, View
|
||||
from django.views.generic.list import ListView, MultipleObjectMixin
|
||||
from django.views.generic.edit import CreateView, FormView, UpdateView
|
||||
from django.views.generic.edit import CreateView, FormView, UpdateView, DeleteView
|
||||
from django.http import FileResponse, HttpResponse, JsonResponse
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Q, Count, Sum
|
||||
from django.utils.timezone import now
|
||||
from django.urls import reverse
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
import json
|
||||
import os.path
|
||||
import re
|
||||
|
||||
from interface.views import EnsembleMixin, ProjectMixin
|
||||
from interface.models import Project
|
||||
from .models import Collection, Work, Document, Section
|
||||
from .imslp import INSTRUMENTS
|
||||
from . import forms, models
|
||||
from .pdf_utils import extract_pages, extract_and_concat
|
||||
from library.models import Collection, Work, Document, Section
|
||||
from library.imslp import INSTRUMENT_TAGS, INSTRUMENTS
|
||||
from library import forms, models
|
||||
from library.pdf_utils import extract_pages, extract_and_concat
|
||||
|
||||
class ProjectItemListView(ProjectMixin, ListView):
|
||||
template_name = "library/item_list.html"
|
||||
@ -193,6 +195,8 @@ class WorkUpdateView(EnsembleMixin, WorkMixin, UpdateView):
|
||||
def get_success_url(self):
|
||||
return resolve_url('work_detail', self.kwargs['pk'])
|
||||
|
||||
|
||||
|
||||
class WorkAddToProject(EnsembleMixin, FormView):
|
||||
admin_required = True
|
||||
form_class = forms.ProjectSelectForm
|
||||
@ -269,20 +273,27 @@ class WorkAddDocumentView(EnsembleMixin, CreateView):
|
||||
doc = form.save(commit=False)
|
||||
doc.work_id = self.kwargs['pk']
|
||||
doc.save()
|
||||
|
||||
# auto tag the document
|
||||
|
||||
name, _ = os.path.splitext(os.path.basename(doc.upload.name))
|
||||
parts = re.split(r'[^A-Za-z]+', name)
|
||||
parts.reverse()
|
||||
for word in parts:
|
||||
try:
|
||||
tag = INSTRUMENT_TAGS[word.lower()]
|
||||
doc.sections.create(tag=tag)
|
||||
break
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
|
||||
if self.request.headers['Accept'] == 'application/json':
|
||||
filename = os.path.basename(doc.upload.name)
|
||||
return JsonResponse({
|
||||
"message": "created",
|
||||
"id": doc.pk,
|
||||
"entry": f"""
|
||||
<td><a href="{reverse('document_download', args=[doc.pk])}">{filename}</a></td>
|
||||
<td/>
|
||||
<td class="has-text-right">
|
||||
<a href="{reverse('document_annotate', args=[doc.pk])}"><i class="fas fa-tags"
|
||||
title="Manage Tags"></i></a>
|
||||
<a href=""><i class="fas fa-trash-alt" title="Delete Document"></i></a>
|
||||
</td>
|
||||
"""
|
||||
"entry": render_to_string('library/document_entry.html', {'doc': doc, 'request': self.request})
|
||||
}, status=201)
|
||||
|
||||
return redirect('document_annotate', doc.pk)
|
||||
@ -336,6 +347,13 @@ class DocumentAnnotateView(EnsembleMixin, DocumentMixin, DetailView):
|
||||
data['json_data'] = {'pageTags': pages, 'instruments': dict(INSTRUMENTS)}
|
||||
return data
|
||||
|
||||
class DocumentDeleteView(EnsembleMixin, DocumentMixin, DeleteView):
|
||||
|
||||
#def get_template_names(self):
|
||||
# return ["interface/default_form.html"]
|
||||
|
||||
def get_success_url(self):
|
||||
return resolve_url('work_detail', self.object.work.pk)
|
||||
|
||||
class PartDownloadView(EnsembleMixin, SingleObjectMixin, View):
|
||||
|
||||
@ -346,10 +364,10 @@ class PartDownloadView(EnsembleMixin, SingleObjectMixin, View):
|
||||
|
||||
result = extract_pages(self.object.doc.upload.path, self.object.doc.work.name, self.object.start, self.object.end)
|
||||
|
||||
download_name = f'{self.object.doc.work.name}_{self.object.instrument}.pdf'
|
||||
#download_name = f'{self.object.doc.work.name}_{self.object.instrument}.pdf'
|
||||
|
||||
response = FileResponse(result, content_type="application/pdf")
|
||||
response['Content-Disposition'] = f'inline; filename="foo.pdf"'
|
||||
response['Content-Disposition'] = f'inline; filename="{self.args["filename"]}"'
|
||||
return response
|
||||
|
||||
def get_queryset(self):
|
||||
161
app/library/views/api.py
Normal file
161
app/library/views/api.py
Normal file
@ -0,0 +1,161 @@
|
||||
"""
|
||||
Views relating to importing and exporting collection items
|
||||
"""
|
||||
"""
|
||||
from interface.views import EnsembleMixin
|
||||
from library.views import WorkMixin
|
||||
from django.views.generic import View
|
||||
from django.http import JsonResponse
|
||||
|
||||
from djantic import ModelSchema
|
||||
from library.models import Work, Document, Section
|
||||
|
||||
class DocumentSchema(ModelSchema):
|
||||
class Config:
|
||||
model = Document
|
||||
|
||||
|
||||
class WorkSchema(ModelSchema):
|
||||
|
||||
docs: DocumentSchema
|
||||
|
||||
class Config:
|
||||
model = Work
|
||||
exclude = ['licence']
|
||||
|
||||
class WorkExportView(EnsembleMixin, WorkMixin, View):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
obj = self.get_queryset().get(pk=kwargs['pk'])
|
||||
schema = WorkSchema.from_orm(obj)
|
||||
return JsonResponse(schema.dict())
|
||||
|
||||
"""
|
||||
|
||||
from library.views import WorkMixin
|
||||
from interface.views import EnsembleMixin
|
||||
|
||||
from rest_framework import routers, serializers, viewsets
|
||||
|
||||
from library.models import Collection, Work, Document, Section
|
||||
|
||||
|
||||
import requests
|
||||
from io import BytesIO
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
from django.db import transaction
|
||||
from django.core.files.uploadedfile import TemporaryUploadedFile
|
||||
|
||||
|
||||
class SectionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Section
|
||||
exclude = ['id', 'doc']
|
||||
|
||||
def to_representation(self, instance):
|
||||
start = instance.start or 0
|
||||
end = instance.end or 0
|
||||
return f"{instance.tag}:{instance.type}:{start}:{end}"
|
||||
|
||||
def to_internal_value(self, data):
|
||||
tag, section_type, start, end = data.split(":")
|
||||
try:
|
||||
start = int(start)
|
||||
except:
|
||||
start = 0
|
||||
try:
|
||||
end = int(end)
|
||||
except:
|
||||
end = 0
|
||||
return super().to_internal_value({'tag': tag, 'type': int(section_type), 'start': start, 'end': end})
|
||||
|
||||
class DocumentSerializer(serializers.ModelSerializer):
|
||||
|
||||
upload = serializers.URLField()
|
||||
sections = SectionSerializer(many=True)
|
||||
#doctype = serializers.CharField(source='get_doctype_display')
|
||||
|
||||
#def to_internal_value(self, data):
|
||||
# r = requests.get(data['upload'], stream=True)
|
||||
# with tempfile.NamedTemporaryFile('wb') as f:
|
||||
# shutil.copyfileobj(r.raw, f)
|
||||
# data['upload'] = f.name
|
||||
# print(repr(data))
|
||||
# return super().to_internal_value(data)
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = super().to_representation(instance)
|
||||
if data['upload'][0] == '/':
|
||||
data['upload'] = 'http://localhost:8000' + (data['upload'])
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
print("CREATE", validated_data)
|
||||
return super().create(validated_data)
|
||||
|
||||
def validate(self, data):
|
||||
print("VALIDATE", data)
|
||||
return super().validate(data)
|
||||
|
||||
def validate_upload(self, value):
|
||||
print("VALIDATE", value)
|
||||
return value
|
||||
|
||||
class Meta:
|
||||
model = Document
|
||||
exclude = ["id", "work", "version", "created"]
|
||||
|
||||
# Serializers define the API representation.
|
||||
class WorkSerializer(serializers.ModelSerializer):
|
||||
|
||||
docs = DocumentSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = Work
|
||||
exclude = ['id', 'collection', 'projects', 'parent']
|
||||
|
||||
def create(self, validated):
|
||||
with transaction.atomic():
|
||||
docs = validated.pop('docs', [])
|
||||
work = Work.objects.create(**validated)
|
||||
|
||||
for d in docs:
|
||||
sections = d.pop('sections', [])
|
||||
|
||||
r = requests.get(d['upload'], stream=True)
|
||||
f = TemporaryUploadedFile(d['upload'], r.headers['content-type'], r.headers['content-length'], r.encoding)
|
||||
shutil.copyfileobj(r.raw, f.file)
|
||||
r.close()
|
||||
d['upload'] = f
|
||||
doc = Document.objects.create(work_id=work.pk, **d)
|
||||
|
||||
for s in sections:
|
||||
Section.objects.create(doc_id=doc.pk, **s)
|
||||
|
||||
return work
|
||||
|
||||
class CollectionSerializer(serializers.Serializer):
|
||||
works = WorkSerializer(many=True)
|
||||
|
||||
from rest_framework import generics
|
||||
|
||||
class CollectionExportView(generics.RetrieveAPIView):
|
||||
serializer_class = CollectionSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return Collection.objects.filter(administrators=self.request.user)
|
||||
|
||||
class WorkExportView(generics.RetrieveAPIView):
|
||||
serializer_class = WorkSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return Work.objects.filter(collection__administrators=self.request.user)
|
||||
|
||||
class WorkImportView(generics.CreateAPIView):
|
||||
serializer_class = WorkSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(collection_id=self.kwargs['pk'])
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env python
|
||||
#!/usr/bin/env python3
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
@ -11,23 +11,22 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from os import environ
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
SECRET_KEY=environ.get('SECRET_KEY')
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = None
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = False
|
||||
|
||||
ALLOWED_HOSTS = ['localhost']
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
POLYPHONIC_MODULES = [
|
||||
@ -42,11 +41,12 @@ INSTALLED_APPS = [
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django_markdown2',
|
||||
'rest_framework',
|
||||
'crispy_forms',
|
||||
'crispy_bulma',
|
||||
'byostorage',
|
||||
'interface',
|
||||
] + POLYPHONIC_MODULES
|
||||
]
|
||||
|
||||
CRISPY_ALLOWED_TEMPLATE_PACKS = ("bulma",)
|
||||
CRISPY_TEMPLATE_PACK = "bulma"
|
||||
@ -131,8 +131,8 @@ USE_TZ = True
|
||||
# https://docs.djangoproject.com/en/3.1/howto/static-files/
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_ROOT = 'static'
|
||||
|
||||
# Need to set this
|
||||
AWS_BUCKET = ''
|
||||
|
||||
MEDIA_ROOT = 'media'
|
||||
7
app/polyphonic/settings.py
Normal file
7
app/polyphonic/settings.py
Normal file
@ -0,0 +1,7 @@
|
||||
try:
|
||||
from .local_settings import *
|
||||
except ImportError:
|
||||
from .default_settings import *
|
||||
|
||||
|
||||
INSTALLED_APPS += POLYPHONIC_MODULES
|
||||
@ -1,14 +1,14 @@
|
||||
asgiref==3.4.1
|
||||
boto3==1.18.34
|
||||
botocore==1.21.34
|
||||
crispy-bulma==0.8.0
|
||||
Django==3.2.7
|
||||
django-byostorage @ git+https://gitea.tfconsulting.com.au/tris/django-byostorage.git@c67d636d2457faa57644cd812ca1b5a916e23766
|
||||
django-byostorage @ git+https://gitea.tfconsulting.com.au/tris/django-byostorage.git@4d87edd995a2b0c154298bd00b3fe0abb78ba72c
|
||||
django-crispy-forms==1.14.0
|
||||
django-markdown2==0.3.1
|
||||
jmespath==0.10.0
|
||||
markdown2==2.4.1
|
||||
python-dateutil==2.8.2
|
||||
pytz==2021.1
|
||||
s3transfer==0.5.0
|
||||
six==1.16.0
|
||||
sqlparse==0.4.1
|
||||
urllib3==1.26.6
|
||||
8
docker_settings.py
Normal file
8
docker_settings.py
Normal file
@ -0,0 +1,8 @@
|
||||
from .default_settings import *
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': '/var/polyphonic/db.sqlite3',
|
||||
}
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% 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 %}
|
||||
@ -1,41 +0,0 @@
|
||||
{% extends "interface/project_base.html" %}
|
||||
{% load md2 %}
|
||||
|
||||
{% block page %}
|
||||
<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>
|
||||
|
||||
<h3 class="title">My Ensembles</h3>
|
||||
|
||||
<div class="columns is-multiline">
|
||||
{% for ensemble in object_list %}
|
||||
<div class="column is-half">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -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 %}
|
||||
@ -1,21 +0,0 @@
|
||||
{% extends "interface/project_base.html" %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% 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>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -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)
|
||||
@ -1,336 +0,0 @@
|
||||
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.signing import Signer
|
||||
from django.contrib import auth
|
||||
|
||||
from markdown2 import markdown
|
||||
|
||||
from . import models, forms
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
signer = Signer()
|
||||
|
||||
def signed_url(name, **kwargs):
|
||||
url = resolve_url(name, **kwargs)
|
||||
sig = signer.sign(url)
|
||||
return sig.replace(":", "?auth=")
|
||||
|
||||
class EnsembleMixin(object):
|
||||
admin_required = False
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
|
||||
request.ensemble_id = request.session.get('ensemble')
|
||||
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 request.ensemble_id:
|
||||
return redirect('register')
|
||||
|
||||
if self.admin_required and not request.is_admin:
|
||||
return redirect('login')
|
||||
|
||||
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 ProjectMixin(EnsembleMixin):
|
||||
|
||||
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_queryset(self):
|
||||
return super().get_queryset().filter(project=self.get_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)
|
||||
return context
|
||||
|
||||
def register(request):
|
||||
|
||||
if 'clear' in request.GET:
|
||||
request.session.clear()
|
||||
|
||||
|
||||
request.ensemble_id = request.session.get('ensemble')
|
||||
registered = request.session.setdefault('registered', [])
|
||||
|
||||
code = request.GET.get('code', '').replace('-', '')
|
||||
print("Registering with code %s", code)
|
||||
|
||||
# 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')
|
||||
|
||||
if request.method == "POST":
|
||||
form = forms.CodeForm(request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
|
||||
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 get_context_data(self, **kwargs):
|
||||
data = super().get_context_data(**kwargs)
|
||||
if self.request.is_admin:
|
||||
data['ensemble_url'] = self.request.build_absolute_uri('/?code={0}'.format(self.ensemble.ensemble_code()))
|
||||
return data
|
||||
|
||||
class EnsembleDetailView(DetailView):
|
||||
model = models.Ensemble
|
||||
|
||||
class ProjectDetailView(ProjectMixin, DetailView):
|
||||
|
||||
def get_object(self):
|
||||
return self.get_project()
|
||||
|
||||
|
||||
class ProjectCreateView(EnsembleMixin, CreateView):
|
||||
model = models.Project
|
||||
template_name = "interface/default_form.html"
|
||||
title = "Add a new project"
|
||||
form_class = forms.ProjectForm
|
||||
|
||||
def form_valid(self, form):
|
||||
self.object = form.save(commit=False)
|
||||
self.object.ensemble_id = self.request.ensemble_id
|
||||
self.object.owner = self.request.user
|
||||
self.object.save()
|
||||
return redirect('project_detail', project=self.object.pk)
|
||||
|
||||
class ProjectUpdateView(EnsembleMixin, UpdateView):
|
||||
model = models.Project
|
||||
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_success_url(self):
|
||||
return resolve_url('project_detail', project=self.kwargs['project'])
|
||||
|
||||
#class ProjectMakefileView(EnsembleMixin, DetailView):
|
||||
# template_name = 'interface/project_submissions.mk'
|
||||
# content_type = 'text/plain'
|
||||
#
|
||||
# def get_queryset(self):
|
||||
# if self.request.is_admin:
|
||||
# return models.Project.objects.all()
|
||||
#
|
||||
# return models.Project.objects.filter(ensemble=self.request.ensemble_id)
|
||||
#
|
||||
# def get_context_data(self, **kwargs):
|
||||
# data = super().get_context_data(**kwargs)
|
||||
#
|
||||
# data['submissions'] = []
|
||||
# data['targets'] = []
|
||||
# for s in self.object.submissions:
|
||||
# name = s.short_name
|
||||
# data['targets'].append(name)
|
||||
# data['submissions'].append({
|
||||
# 'url': self.request.build_absolute_uri(signed_url('submission_download', project=self.kwargs['pk'], pk=s.pk)),
|
||||
# 'name': name,
|
||||
# })
|
||||
#
|
||||
# return data
|
||||
|
||||
class WikiView(ProjectMixin, DetailView):
|
||||
template_name = 'interface/wiki.html'
|
||||
model = models.WikiPage
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
data = super().get_context_data(**kwargs)
|
||||
data['wiki_html'] = markdown(self.object.markdown)
|
||||
return data
|
||||
|
||||
class WikiCreateView(ProjectMixin, CreateView):
|
||||
admin_required = True
|
||||
model = models.WikiPage
|
||||
fields = ['title', 'markdown']
|
||||
|
||||
def form_valid(self, form):
|
||||
self.object = form.save(commit=False)
|
||||
self.object.project = self.get_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']
|
||||
|
||||
|
||||
class ResourceCreateView(ProjectMixin, CreateView):
|
||||
model = models.Resource
|
||||
form_class = forms.ResourceForm
|
||||
template_name = 'interface/project_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.save()
|
||||
return redirect('resource_upload', project=self.object.project_id, pk=self.object.pk)
|
||||
|
||||
class ResourceUploadView(ProjectMixin, UpdateView):
|
||||
admin_required = True
|
||||
model = models.Resource
|
||||
fields = ['file']
|
||||
template_name = 'interface/default_form.html'
|
||||
|
||||
def get_success_url(self):
|
||||
return resolve_url('resource_list', project=self.kwargs['project'])
|
||||
|
||||
class ResourceListView(ProjectMixin, ListView):
|
||||
model = models.Resource
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
if not self.request.is_admin:
|
||||
qs = qs.filter(visible=True)
|
||||
return qs
|
||||
|
||||
class ResourceEditView(ProjectMixin, UpdateView):
|
||||
admin_required = True
|
||||
model = models.Resource
|
||||
fields = ['name', 'description', 'visible']
|
||||
template_name = 'interface/default_form.html'
|
||||
|
||||
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
|
||||
@ -1,26 +0,0 @@
|
||||
{% extends "interface/project_base.html" %}
|
||||
|
||||
{% block page %}
|
||||
<h3 class="title">Library collections for {{ request.user }}</h3>
|
||||
|
||||
<div class="columns is-multiline">
|
||||
{% for collection in object_list %}
|
||||
<div class="column is-half">
|
||||
<div class="card">
|
||||
<header class="card-header">
|
||||
<a class="" href="{% url 'collection_work_list' pk=collection.id %}">
|
||||
<p class="card-header-title">{{ collection.name }}</p>
|
||||
</a>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
<p>{{ collection.location }}, {{ collection.works.count }} items.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<small>{{ ensemble.ensemble_code }}</small>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Loading…
x
Reference in New Issue
Block a user