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
|
*.pyc
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
credentials
|
credentials
|
||||||
polyphonic/settings.py
|
local_settings.py
|
||||||
env
|
env
|
||||||
|
old
|
||||||
test.*
|
test.*
|
||||||
static
|
static
|
||||||
teststore
|
teststore
|
||||||
cache
|
cache
|
||||||
local_storage
|
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 django import forms
|
||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
from crispy_forms.layout import Submit
|
from crispy_forms.layout import Submit, HTML, Div
|
||||||
|
from crispy_bulma.layout import FormGroup
|
||||||
|
|
||||||
from . import models, fields
|
from . import models, fields
|
||||||
|
|
||||||
class BaseForm(forms.Form):
|
class BaseForm(forms.Form):
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.helper = self.get_form_helper()
|
self.helper = self.get_form_helper()
|
||||||
|
|
||||||
def get_form_helper(self):
|
def get_form_helper(self):
|
||||||
helper = FormHelper(self)
|
helper = FormHelper(self)
|
||||||
helper.add_input(Submit('submit', 'Submit', css_class='button is-link'))
|
#helper.add_input(Submit('submit', 'Submit', css_class='button is-link'))
|
||||||
|
#helper.layout.subm append(HTML('<a class="button is-light">Cancel</a>'))
|
||||||
|
#print(helper.layout)
|
||||||
|
helper.layout.append(FormGroup(
|
||||||
|
Submit('submit', 'Save', css_class="button is-primary"),
|
||||||
|
HTML('{% if view.cancel_url %}<div class="control"><a href="{{ view.cancel_url }}" class="button is-light">Cancel</a></div>{% endif %}')
|
||||||
|
))
|
||||||
return helper
|
return helper
|
||||||
|
|
||||||
|
|
||||||
class ProjectForm(forms.ModelForm, BaseForm):
|
class ProjectForm(forms.ModelForm, BaseForm):
|
||||||
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Project
|
model = models.Project
|
||||||
fields = ['name', 'description', 'event_date']
|
fields = ['name', 'description', 'event_date']
|
||||||
widgets = {
|
#widgets = {
|
||||||
'event_date': forms.DateTimeInput(attrs={'type': 'date'})
|
# 'event_date': forms.DateTimeInput(attrs={'type': 'date'})
|
||||||
}
|
#}
|
||||||
|
|
||||||
class ResourceForm(forms.ModelForm, BaseForm):
|
class ResourceForm(forms.ModelForm, BaseForm):
|
||||||
|
|
||||||
@ -36,6 +44,12 @@ class ResourceForm(forms.ModelForm, BaseForm):
|
|||||||
helper[3].wrap(fields.BulmaFileUpload)
|
helper[3].wrap(fields.BulmaFileUpload)
|
||||||
return helper
|
return helper
|
||||||
|
|
||||||
|
class WikiForm(forms.ModelForm, BaseForm):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.WikiPage
|
||||||
|
fields = ['title', 'markdown']
|
||||||
|
|
||||||
class CodeForm(BaseForm):
|
class CodeForm(BaseForm):
|
||||||
code = forms.CharField(max_length=14,
|
code = forms.CharField(max_length=14,
|
||||||
widget=forms.TextInput(attrs={'placeholder': 'xxx-xxx-xxx', 'inputmode': 'numeric'}))
|
widget=forms.TextInput(attrs={'placeholder': 'xxx-xxx-xxx', 'inputmode': 'numeric'}))
|
||||||
@ -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
|
import byostorage.user
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -9,13 +9,11 @@ import interface.models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
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
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('byostorage', '0004_alter_userstorage_storage'),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('byostorage', '0006_alter_userstorage_settings_data'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@ -75,7 +73,7 @@ class Migration(migrations.Migration):
|
|||||||
name='Module',
|
name='Module',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('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')),
|
('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
|
import random
|
||||||
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import os.path
|
import os.path
|
||||||
@ -21,12 +19,19 @@ MEDIA_TYPES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
def rough_date(d):
|
def rough_date(d):
|
||||||
|
if not d:
|
||||||
|
return False, "sometime..."
|
||||||
days = (d - timezone.now()).days
|
days = (d - timezone.now()).days
|
||||||
in_past = days < 0
|
in_past = days < 0
|
||||||
if in_past:
|
if in_past:
|
||||||
days = abs(days)
|
days = abs(days)
|
||||||
if days ==0:
|
if days == 0:
|
||||||
return "today!"
|
m = int((d-timezone.now()).seconds/60)
|
||||||
|
if m > 60:
|
||||||
|
return in_past, "{0:d} hours".format(int(m / 60))
|
||||||
|
return in_past, "{0:d} minutes!".format(int(m % 60))
|
||||||
|
if days >= 14:
|
||||||
|
return in_past, "{0:d} weeks".format(int(days/7))
|
||||||
if days >= 7:
|
if days >= 7:
|
||||||
return in_past, "{0:d} weeks, {1:d} days".format(int(days / 7), int(days % 7))
|
return in_past, "{0:d} weeks, {1:d} days".format(int(days / 7), int(days % 7))
|
||||||
return in_past, f"{days} days"
|
return in_past, f"{days} days"
|
||||||
@ -43,23 +48,33 @@ class Ensemble(models.Model):
|
|||||||
help_text="Display name")
|
help_text="Display name")
|
||||||
slug = models.SlugField(max_length=100, editable=False, unique=True,
|
slug = models.SlugField(max_length=100, editable=False, unique=True,
|
||||||
help_text="Short name for the ensemble - used for folders")
|
help_text="Short name for the ensemble - used for folders")
|
||||||
code = models.CharField(max_length=9, default=generate_code,
|
#code = models.CharField(max_length=9, default=generate_code,
|
||||||
help_text="Ensemble registration code")
|
# help_text="Ensemble registration code")
|
||||||
passphrase = models.CharField(max_length=100,
|
#passphrase = models.CharField(max_length=100,
|
||||||
help_text="Used to register ensembles")
|
# help_text="Used to register ensembles")
|
||||||
admins = models.ManyToManyField('auth.User', related_name='ensembles')
|
admins = models.ManyToManyField('auth.User', related_name='ensembles')
|
||||||
details = models.TextField(blank=True,
|
details = models.TextField(blank=True,
|
||||||
help_text="Description of the ensemble (markdown)")
|
help_text="Description of the ensemble (markdown)")
|
||||||
storage = models.ForeignKey('byostorage.UserStorage', null=True, on_delete=models.SET_NULL,
|
storage = models.ForeignKey('byostorage.UserStorage', null=True, on_delete=models.SET_NULL,
|
||||||
help_text="Default storage for this ensemble")
|
help_text="Default storage for this ensemble")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ('slug', )
|
||||||
|
|
||||||
def active_projects(self):
|
def active_projects(self):
|
||||||
return self.projects.filter(active=True).order_by('event_date')
|
return self.projects.filter(active=True, event_date__gte=timezone.now())
|
||||||
|
|
||||||
def ensemble_code(self):
|
def ensemble_code(self):
|
||||||
code = str(self.code)
|
code = str(self.code)
|
||||||
return "{}-{}-{}".format(code[:3], code[3:6], code[6:])
|
return "{}-{}-{}".format(code[:3], code[3:6], code[6:])
|
||||||
|
|
||||||
|
def has_admin(self, user):
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return False
|
||||||
|
if user.is_superuser:
|
||||||
|
return True
|
||||||
|
return user.pk in self.admins.values_list('pk', flat=True)
|
||||||
|
|
||||||
def save(self, **kwargs):
|
def save(self, **kwargs):
|
||||||
if not self.slug:
|
if not self.slug:
|
||||||
self.slug = slugify(self.name)
|
self.slug = slugify(self.name)
|
||||||
@ -81,11 +96,7 @@ class Project(models.Model):
|
|||||||
owner = models.CharField(max_length=255, blank=True)
|
owner = models.CharField(max_length=255, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['active', '-pk']
|
ordering = ['active', 'event_date']
|
||||||
|
|
||||||
@property
|
|
||||||
def submissions(self):
|
|
||||||
return self.all_submissions.order_by('-pk')
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def days(self):
|
def days(self):
|
||||||
@ -93,6 +104,8 @@ class Project(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def has_happened(self):
|
def has_happened(self):
|
||||||
|
if not self.event_date:
|
||||||
|
return False
|
||||||
return self.event_date < timezone.now()
|
return self.event_date < timezone.now()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -104,8 +117,13 @@ class Project(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def folder(self):
|
def folder(self):
|
||||||
print(f"{self.ensemble.storage_id}:{self.ensemble.slug}/{self.slug}")
|
project = slugify(self.name)
|
||||||
return f"{self.ensemble.storage_id}:{self.ensemble.slug}/{self.slug}"
|
print(f"{self.ensemble.storage_id}:{self.ensemble.slug}/{project}")
|
||||||
|
return f"{self.ensemble.storage_id}:{self.ensemble.slug}/{project}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_modules(self):
|
||||||
|
return self.modules.values_list('name', flat=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
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 %}
|
{% block page %}
|
||||||
<h3 class="subtitle">{% firstof title view.title %}</h3>
|
<h3 class="subtitle">{% firstof title view.title %}</h3>
|
||||||
<div class="columns is-centered">
|
<div class="columns is-centered">
|
||||||
<div class="column is-form-group">
|
<div class="column is-two-thirds">
|
||||||
{% if instructions %}
|
{% if instructions %}
|
||||||
<p>{{ instructions }}</p>
|
<p>{{ instructions }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% crispy form %}
|
{% crispy form %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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>
|
<p class="menu-label">My Things</p>
|
||||||
<ul class="menu-list">
|
<ul class="menu-list">
|
||||||
<li><a href="{% url 'ensemble_list' %}">Ensembles</a></li>
|
<li><a href="{% url 'ensemble_list' %}">Ensembles</a></li>
|
||||||
<li><a href="{% url 'ensemble_detail' %}">Projects</a></li>
|
<li><a href="{% url 'project_list' %}">Projects</a></li>
|
||||||
<li><a href="{% url 'work_list' %}">Library</a></li>
|
<li><a href="{% url 'work_list' %}">Library</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@ -42,14 +42,14 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<p class="menu-label">Admin</p>
|
<p class="menu-label">Admin</p>
|
||||||
{% if request.is_admin %}
|
|
||||||
<ul class="menu-list">
|
<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>
|
<li><a class="admin-link" href="{% url 'collection_list' %}">Collections</a></li>
|
||||||
|
{% endif %}
|
||||||
{% if request.user.is_superuser %}
|
{% if request.user.is_superuser %}
|
||||||
<li><a class="admin-link" href="/admin" target="polyphonic_admin" rel="noopener noreferrer">Django Admin</a></li>
|
<li><a class="admin-link" href="/admin" target="polyphonic_admin" rel="noopener noreferrer">Django Admin</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<ul class="menu-list">
|
<ul class="menu-list">
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
@ -76,10 +76,10 @@
|
|||||||
{% if project.resources.count %}
|
{% if project.resources.count %}
|
||||||
<li><a href="{% url 'resource_list' project=project.pk %}">Resources</a></li>
|
<li><a href="{% url 'resource_list' project=project.pk %}">Resources</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if project.enable_library %}
|
{% if 'library' in modules %}
|
||||||
<li><a href="{% url 'item_list' project=project.pk %}">My Music</a></li>
|
<li><a href="{% url 'item_list' project=project.pk %}">My Music</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if project.enable_submissions %}
|
{% if 'submission' in modules %}
|
||||||
<li><a href="{% url 'submission_create' project=project.pk %}">Send File</a></li>
|
<li><a href="{% url 'submission_create' project=project.pk %}">Send File</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
@ -97,14 +97,8 @@
|
|||||||
{% block page %}
|
{% block page %}
|
||||||
No content
|
No content
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if ensemble %}
|
|
||||||
<div class="project-footer">
|
|
||||||
{{ ensemble.name }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -58,5 +58,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if request.is_admin %}
|
||||||
|
<div class="block">
|
||||||
|
<a href="{{ project_link }}">Project Link</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -8,7 +8,10 @@
|
|||||||
{% block page %}
|
{% block page %}
|
||||||
<h3 class="subtitle">{% firstof title view.title %}</h3>
|
<h3 class="subtitle">{% firstof title view.title %}</h3>
|
||||||
<div class="columns is-centered">
|
<div class="columns is-centered">
|
||||||
<div class="column is-form-group">
|
<div class="column">
|
||||||
|
{% if instructions %}
|
||||||
|
<p>{{ instructions }}</p>
|
||||||
|
{% endif %}
|
||||||
{% crispy form %}
|
{% crispy form %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -7,7 +7,6 @@
|
|||||||
<div class="box is-half">
|
<div class="box is-half">
|
||||||
<p class="block">
|
<p class="block">
|
||||||
Login is only required to administer a project.<br/>
|
Login is only required to administer a project.<br/>
|
||||||
If you have an ensemble code <a href="{% url 'register' %}">enter it here</a> instead.
|
|
||||||
</p>
|
</p>
|
||||||
<form method="POST" class="vertical">
|
<form method="POST" class="vertical">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@ -15,7 +14,7 @@
|
|||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-link">Login</button>
|
<button class="button is-link">Login</button>
|
||||||
<a href="{% url 'ensemble_detail' %}" class="button is-light">Cancel</a>
|
<a href="{% url 'home' %}" class="button is-light">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
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.urls import path
|
||||||
from django.contrib.auth import views as auth_views
|
from django.contrib.auth import views as auth_views
|
||||||
|
from django.views.generic.base import RedirectView
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|
||||||
|
path('', RedirectView.as_view(url='projects', permanent=False), name='home'),
|
||||||
|
|
||||||
path('login', auth_views.LoginView.as_view(), name='login'),
|
path('login', auth_views.LoginView.as_view(), name='login'),
|
||||||
path('logout', views.logout, name='logout'),
|
path('logout', auth_views.LogoutView.as_view(), name='logout'),
|
||||||
path('register', views.register, name="register"),
|
#path('register/<group>/<int:pk>', views.register, name='register'),
|
||||||
|
#path('deregister/<group>/<int:pk>', views.deregister, name='deregister'),
|
||||||
|
|
||||||
path('', views.EnsembleProjectListView.as_view(), name='ensemble_detail'),
|
|
||||||
path('ensembles', views.EnsembleListView.as_view(), name="ensemble_list"),
|
path('ensembles', views.EnsembleListView.as_view(), name="ensemble_list"),
|
||||||
path('ensembles/<int:pk>', views.EnsembleDetailView.as_view(), name='ensemble_detail'),
|
path('ensembles/<slug:ensemble>', views.EnsembleDetailView.as_view(), name='ensemble_detail'),
|
||||||
path('ensembles/<int:pk>/forget', views.EnsembleForgetView.as_view(), name='ensemble_forget'),
|
path('ensembles/<slug:ensemble>/new-project', views.ProjectCreateView.as_view(), name="project_create"),
|
||||||
|
#path('ensembles/<int:pk>/forget', views.EnsembleForgetView.as_view(), name='ensemble_forget'),
|
||||||
|
|
||||||
path('projects/create', views.ProjectCreateView.as_view(), name="project_create"),
|
path('projects', views.ProjectListView.as_view(), name="project_list"),
|
||||||
path('projects/<int:project>', views.ProjectDetailView.as_view(), name="project_detail"),
|
path('projects/<int:project>', views.ProjectDetailView.as_view(), name="project_detail"),
|
||||||
path('projects/<int:project>/edit', views.ProjectUpdateView.as_view(), name="project_edit"),
|
path('projects/<int:project>/edit', views.ProjectUpdateView.as_view(), name="project_edit"),
|
||||||
#path('projects/<int:project>/submissions.mk', views.ProjectMakefileView.as_view(), name="project_makefile"),
|
#path('projects/<int:project>/submissions.mk', views.ProjectMakefileView.as_view(), name="project_makefile"),
|
||||||
@ -27,4 +31,9 @@ urlpatterns = [
|
|||||||
path('projects/<int:project>/resources/add', views.ResourceCreateView.as_view(), name="resource_create"),
|
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>/upload', views.ResourceUploadView.as_view(), name="resource_upload"),
|
||||||
path('projects/<int:project>/resources/<int:pk>/edit', views.ResourceEditView.as_view(), name="resource_edit"),
|
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):
|
class SectionInline(admin.TabularInline):
|
||||||
model = models.Section
|
model = models.Section
|
||||||
fields = ['tag', 'start', 'end']
|
fields = ['type', 'tag', 'ordinal', 'start', 'end']
|
||||||
|
|
||||||
class DocumentAdmin(admin.ModelAdmin):
|
class DocumentAdmin(admin.ModelAdmin):
|
||||||
list_display = ['work', '__str__']
|
list_display = ['work', '__str__']
|
||||||
@ -1,8 +1,6 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from .models import Work
|
from .models import Work
|
||||||
from interface.models import Project
|
from interface.models import Project
|
||||||
from interface.widgets import DatePickerInput
|
|
||||||
from django.db.models import Q
|
|
||||||
from interface.forms import BaseForm
|
from interface.forms import BaseForm
|
||||||
|
|
||||||
|
|
||||||
@ -11,7 +9,7 @@ class WorkCreateForm(forms.ModelForm, BaseForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Work
|
model = Work
|
||||||
fields = ['name', 'code', 'running_time', 'notes']
|
fields = ['name', 'composer', 'edition', 'code', 'running_time', 'notes']
|
||||||
|
|
||||||
class PlaylistAddForm(forms.Form):
|
class PlaylistAddForm(forms.Form):
|
||||||
work = forms.ModelChoiceField(queryset=Work.objects.all())
|
work = forms.ModelChoiceField(queryset=Work.objects.all())
|
||||||
@ -2,9 +2,12 @@
|
|||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
|
||||||
# taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_Instruments
|
# taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_Instruments
|
||||||
|
# Place any extra abbreviations at the top
|
||||||
|
|
||||||
ABBREVIATIONS = """
|
ABBREVIATIONS = """
|
||||||
score Score
|
score Score
|
||||||
|
cb Double bass
|
||||||
|
|
||||||
acc Accordion
|
acc Accordion
|
||||||
afl Alto flute
|
afl Alto flute
|
||||||
alt Alto (voice) (contralto)
|
alt Alto (voice) (contralto)
|
||||||
@ -42,6 +45,7 @@ crt Cornet
|
|||||||
crtt Cornett (Zink)
|
crtt Cornett (Zink)
|
||||||
cv Child's voice
|
cv Child's voice
|
||||||
db Double Bass
|
db Double Bass
|
||||||
|
drum Drumset
|
||||||
dlcn Dulcian
|
dlcn Dulcian
|
||||||
dom Domra
|
dom Domra
|
||||||
dulc Dulcimer
|
dulc Dulcimer
|
||||||
@ -139,6 +143,18 @@ xyl Xylophone
|
|||||||
zith Zither
|
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 = []
|
INSTRUMENTS = []
|
||||||
for line in ABBREVIATIONS.split('\n'):
|
for line in ABBREVIATIONS.split('\n'):
|
||||||
parts = line.strip().split(maxsplit=1)
|
parts = line.strip().split(maxsplit=1)
|
||||||
@ -146,8 +162,8 @@ for line in ABBREVIATIONS.split('\n'):
|
|||||||
name, _, _ = parts[1].partition('(')
|
name, _, _ = parts[1].partition('(')
|
||||||
INSTRUMENTS.append((parts[0], name))
|
INSTRUMENTS.append((parts[0], name))
|
||||||
|
|
||||||
INSTRUMENT_LOOKUP = dict(INSTRUMENTS)
|
INSTRUMENT_NAMES = dict(INSTRUMENTS)
|
||||||
TAG_LOOKUP = dict( ( (x[1].lower(), x[0]) for x in INSTRUMENTS ) )
|
INSTRUMENT_TAGS = dict( ( (x[1].lower(), x[0]) for x in INSTRUMENTS ) )
|
||||||
|
|
||||||
class Instrument(namedtuple('Instrument', ('name', 'variant'), defaults=[None])):
|
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)
|
Instrument(name='Jaws Harp', variant=None)
|
||||||
"""
|
"""
|
||||||
abbr, _, variant = tag.partition('-')
|
abbr, _, variant = tag.partition('-')
|
||||||
name = INSTRUMENT_LOOKUP.get(abbr.lower(), abbr)
|
name = INSTRUMENT_NAMES.get(abbr.lower(), abbr)
|
||||||
|
|
||||||
if variant:
|
if variant:
|
||||||
return cls(name, variant)
|
return cls(name, variant)
|
||||||
@ -176,7 +192,7 @@ class Instrument(namedtuple('Instrument', ('name', 'variant'), defaults=[None]))
|
|||||||
>>> Instrument('Double Bass').abbreviate()
|
>>> Instrument('Double Bass').abbreviate()
|
||||||
'db'
|
'db'
|
||||||
"""
|
"""
|
||||||
tag = TAG_LOOKUP.get(self.name.lower())
|
tag = INSTRUMENT_TAGS.get(self.name.lower())
|
||||||
if self.variant:
|
if self.variant:
|
||||||
tag = f"{tag}-{self.variant}"
|
tag = f"{tag}-{self.variant}"
|
||||||
return tag
|
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
|
import byostorage.user
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -9,14 +9,12 @@ import library.models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
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
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('byostorage', '0004_alter_userstorage_storage'),
|
|
||||||
('interface', '0001_initial'),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('byostorage', '0006_alter_userstorage_settings_data'),
|
||||||
|
('interface', '0001_initial'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
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')),
|
('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(
|
migrations.CreateModel(
|
||||||
name='ProjectItem',
|
name='ProjectItem',
|
||||||
fields=[
|
fields=[
|
||||||
@ -40,6 +48,7 @@ class Migration(migrations.Migration):
|
|||||||
('due', models.DateTimeField(blank=True, null=True)),
|
('due', models.DateTimeField(blank=True, null=True)),
|
||||||
('returned', models.DateTimeField(blank=True, null=True)),
|
('returned', models.DateTimeField(blank=True, null=True)),
|
||||||
('order', models.SmallIntegerField(default=0)),
|
('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)),
|
('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')),
|
('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')),
|
('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)),
|
('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)),
|
('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)')),
|
('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)),
|
('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')),
|
('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')),
|
('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')),
|
('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')),
|
('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(
|
migrations.CreateModel(
|
||||||
@ -116,4 +97,26 @@ class Migration(migrations.Migration):
|
|||||||
'ordering': ['doc', 'start', 'pk'],
|
'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 django.db.models import Q, Count, Min, Max
|
||||||
|
|
||||||
from byostorage.user import BYOStorage
|
from byostorage.user import BYOStorage
|
||||||
|
from byostorage.cached import CachedStorage
|
||||||
from .imslp import Instrument
|
from .imslp import Instrument
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@ -22,38 +23,10 @@ logger = logging.getLogger(__name__)
|
|||||||
# library_storage = get_storage_class()()
|
# library_storage = get_storage_class()()
|
||||||
#logger.info("Library storage: %s", library_storage.__class__.__name__)
|
#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']
|
ordering = ['order', 'work']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"<{self.project_id}:{self.work.slug}>"
|
return f"<{self.project_id}:{slugify(self.work.name)}>"
|
||||||
|
|
||||||
class Collection(models.Model):
|
class Collection(models.Model):
|
||||||
"""
|
"""
|
||||||
@ -115,7 +88,20 @@ class Collection(models.Model):
|
|||||||
help_text="User storage for documents")
|
help_text="User storage for documents")
|
||||||
notes = models.TextField(blank=True,
|
notes = models.TextField(blank=True,
|
||||||
help_text="Publicly visible notes about collection and loans policy (markdown format)")
|
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):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
@ -123,6 +109,15 @@ class EnsembleAccess(models.Model):
|
|||||||
"""
|
"""
|
||||||
Can have different access levels to a collection
|
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")
|
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")
|
collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name="allowed_ensembles")
|
||||||
access_type = models.PositiveSmallIntegerField(choices=ACCESS_TYPES, default=2)
|
access_type = models.PositiveSmallIntegerField(choices=ACCESS_TYPES, default=2)
|
||||||
@ -130,30 +125,36 @@ class EnsembleAccess(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name_plural = "Ensemble access"
|
verbose_name_plural = "Ensemble access"
|
||||||
|
|
||||||
META_TAGS = (
|
|
||||||
('tag', 'Tag'),
|
|
||||||
('arr', 'Arranger'),
|
|
||||||
('lyrics', 'Lyracist'),
|
|
||||||
('genre', 'Genre'),
|
|
||||||
('style', 'Style'),
|
|
||||||
('orchestration', 'Orchestration'),
|
|
||||||
)
|
|
||||||
|
|
||||||
class Work(models.Model):
|
class Work(models.Model):
|
||||||
"""
|
"""
|
||||||
A musical work 'owned' by a collection from a licencing perspective.
|
A musical work 'owned' by a collection from a licencing perspective.
|
||||||
"""
|
"""
|
||||||
slug = models.SlugField(max_length=100, editable=False,
|
LICENCE_PUBLIC = 2
|
||||||
help_text="Used as folder name")
|
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")
|
name = models.CharField(max_length=255, help_text="Original name of the work")
|
||||||
edition = models.CharField(max_length=255, blank=True,
|
edition = models.CharField(max_length=255, blank=True,
|
||||||
help_text="Edition details to distinguish multiple versions")
|
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",
|
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")
|
help_text="Arrangement of another work or part of an anthology")
|
||||||
composer = models.CharField(max_length=255, blank=True,
|
composer = models.CharField(max_length=255, default='Anon',
|
||||||
help_text="Surname, First Name/Initials")
|
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 details
|
||||||
collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name="works")
|
collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name="works")
|
||||||
@ -168,6 +169,10 @@ class Work(models.Model):
|
|||||||
# Allocation to projects
|
# Allocation to projects
|
||||||
projects = models.ManyToManyField('interface.Project', through='ProjectItem', related_name="works", help_text="Current usage")
|
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):
|
def extract(self, *tags):
|
||||||
|
|
||||||
qs = self.docs.filter(sections__tag__in=tags)
|
qs = self.docs.filter(sections__tag__in=tags)
|
||||||
@ -194,11 +199,6 @@ class Work(models.Model):
|
|||||||
def meta(self):
|
def meta(self):
|
||||||
return self.meta_info.exclude(name='tag')
|
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
|
@property
|
||||||
def active_projects(self):
|
def active_projects(self):
|
||||||
return self.projects.filter(active=True)
|
return self.projects.filter(active=True)
|
||||||
@ -235,10 +235,11 @@ class Work(models.Model):
|
|||||||
|
|
||||||
composer = self.composer or "Anon"
|
composer = self.composer or "Anon"
|
||||||
words = self.name.split()
|
words = self.name.split()
|
||||||
if len(words) > 2:
|
#if len(words) > 2:
|
||||||
work = ''.join([ x[0] for x in self.name.split() ])
|
# work = ''.join([ x[0] for x in self.name.split() ])
|
||||||
else:
|
#else:
|
||||||
work = words[0][:3]
|
# work = words[0][:3]
|
||||||
|
work = words[0][:3]
|
||||||
|
|
||||||
return f"{composer[:4]}-{work}-{self.pk:03d}".upper()
|
return f"{composer[:4]}-{work}-{self.pk:03d}".upper()
|
||||||
|
|
||||||
@ -247,8 +248,18 @@ class Work(models.Model):
|
|||||||
return f"{self.name} ({self.composer})"
|
return f"{self.name} ({self.composer})"
|
||||||
|
|
||||||
class WorkMeta(models.Model):
|
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')
|
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)
|
value = models.CharField(max_length=255)
|
||||||
|
|
||||||
def doc_upload_filename(doc, filename):
|
def doc_upload_filename(doc, filename):
|
||||||
@ -256,18 +267,35 @@ def doc_upload_filename(doc, filename):
|
|||||||
storage = collection.storage
|
storage = collection.storage
|
||||||
if not storage:
|
if not storage:
|
||||||
raise RuntimeError("Collection has no storage attached")
|
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):
|
class Document(models.Model):
|
||||||
"""
|
"""
|
||||||
Document represents a single file stored in the storage backend.
|
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")
|
work = models.ForeignKey('Work', on_delete=models.CASCADE, related_name="docs")
|
||||||
doctype = models.PositiveSmallIntegerField(choices=DOCTYPES, default=1)
|
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)
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
version = models.CharField(max_length=30, blank=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):
|
def __str__(self):
|
||||||
return self.upload.name
|
return self.upload.name
|
||||||
|
|
||||||
@ -275,21 +303,54 @@ class Section(models.Model):
|
|||||||
"""
|
"""
|
||||||
Section is a tagged portion of a Document
|
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")
|
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)
|
start = models.SmallIntegerField(null=True, blank=True)
|
||||||
end = models.SmallIntegerField(null=True, blank=True)
|
end = models.SmallIntegerField(null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['doc', 'start', 'pk']
|
ordering = ['type', 'ordinal', 'doc', 'start', 'pk']
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def instrument(self):
|
def name(self):
|
||||||
return Instrument.from_tag(self.tag)
|
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
|
@property
|
||||||
def filename(self):
|
def filename(self):
|
||||||
return slugify(f'{self.doc.work.name}_{self.instrument}') + '.pdf'
|
return slugify(f'{self.doc.work.name} - {self.name}') + '.pdf'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pagerange(self):
|
def pagerange(self):
|
||||||
@ -300,4 +361,4 @@ class Section(models.Model):
|
|||||||
return "all"
|
return "all"
|
||||||
|
|
||||||
def __str__(self):
|
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">
|
<h3 class="title">
|
||||||
{{ work.name }}
|
{{ work.name }}
|
||||||
{% for tag in work.tags %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="subtitle">{% firstof work.composer "Unattributed" %}{% if work.edition %} - {{ work.edition }}{% endif %}</p>
|
<p class="subtitle">{% firstof work.composer "Unattributed" %}{% if work.edition %} - {{ work.edition }}{% endif %}</p>
|
||||||
@ -30,10 +30,10 @@
|
|||||||
|
|
||||||
<p class="block">
|
<p class="block">
|
||||||
Location: <a href="{% url 'collection_work_list' work.collection.pk %}">{{ work.collection }}</a> [{{ work.identifier }}]<br/>
|
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/>
|
Licence: {{ work.get_licence_display }}<br/>
|
||||||
{% for meta in work.meta %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -79,9 +79,9 @@
|
|||||||
{% if work.digital_parts %}
|
{% if work.digital_parts %}
|
||||||
<a class="tag is-danger" href="{% url 'work_partset' pk=work.pk %}">Full Set</a>
|
<a class="tag is-danger" href="{% url 'work_partset' pk=work.pk %}">Full Set</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for part in work.digital_parts %}
|
{% for section in work.digital_parts %}
|
||||||
<a class="tag is-info" href="{% url 'part_download' pk=part.pk filename=part.filename %}"
|
<a class="tag is-info" href="{% url 'part_download' pk=section.pk filename=section.filename %}"
|
||||||
target="part_{{ part.pk }}" rel="">{{ part.instrument }}</a>
|
target="section_{{ section.pk }}" rel="">{{ section.name }}</a>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<p class="is-italic">No digital parts available</p>
|
<p class="is-italic">No digital parts available</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -110,22 +110,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="doc-list">
|
<tbody id="doc-list">
|
||||||
{% for doc in work.docs.all %}
|
{% for doc in work.docs.all %}
|
||||||
<tr>
|
{% include 'library/document_entry.html' %}
|
||||||
<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>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -188,7 +173,7 @@
|
|||||||
<script>
|
<script>
|
||||||
Dropzone.options.docUpload = { // camelized version of the `id`
|
Dropzone.options.docUpload = { // camelized version of the `id`
|
||||||
paramName: "upload", // The name that will be used to transfer the file
|
paramName: "upload", // The name that will be used to transfer the file
|
||||||
maxFilesize: 12, // MB
|
maxFilesize: 50, // MB
|
||||||
createImageThumbnails: false,
|
createImageThumbnails: false,
|
||||||
thumbnailWidth: 60,
|
thumbnailWidth: 60,
|
||||||
thumbnailHeight: 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 django.contrib.auth import views as auth_views
|
||||||
|
from rest_framework import routers
|
||||||
|
|
||||||
from . import views
|
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 = [
|
urlpatterns = [
|
||||||
|
|
||||||
path('projects/<int:project>/items', views.ProjectItemListView.as_view(), name="item_list"),
|
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', views.CollectionListView.as_view(), name="collection_list"),
|
||||||
path('library/collections/<int:pk>', views.CollectionWorkListView.as_view(), name="collection_work_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', views.WorkListView.as_view(), name="work_list"),
|
||||||
path('library/works/<int:pk>', views.WorkDetailView.as_view(), name="work_detail"),
|
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>/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/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>/download', views.DocumentDownloadView.as_view(), name="document_download"),
|
||||||
path('library/documents/<int:pk>/annotate', views.DocumentAnnotateView.as_view(), name="document_annotate"),
|
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"),
|
path('library/parts/<int:pk>/<str:filename>', views.PartDownloadView.as_view(), name="part_download"),
|
||||||
]
|
|
||||||
|
|
||||||
from django.views.static import serve
|
#path('api/', include(router.urls))
|
||||||
urlpatterns.append(path('docs/<path:path>', serve, {'document_root': 'local_storage'}))
|
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.shortcuts import render, redirect, resolve_url
|
||||||
from django.views.generic.detail import DetailView, SingleObjectMixin, View
|
from django.views.generic.detail import DetailView, SingleObjectMixin, View
|
||||||
from django.views.generic.list import ListView, MultipleObjectMixin
|
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.http import FileResponse, HttpResponse, JsonResponse
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.db.models import Q, Count, Sum
|
from django.db.models import Q, Count, Sum
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os.path
|
import os.path
|
||||||
|
import re
|
||||||
|
|
||||||
from interface.views import EnsembleMixin, ProjectMixin
|
from interface.views import EnsembleMixin, ProjectMixin
|
||||||
from interface.models import Project
|
from interface.models import Project
|
||||||
from .models import Collection, Work, Document, Section
|
from library.models import Collection, Work, Document, Section
|
||||||
from .imslp import INSTRUMENTS
|
from library.imslp import INSTRUMENT_TAGS, INSTRUMENTS
|
||||||
from . import forms, models
|
from library import forms, models
|
||||||
from .pdf_utils import extract_pages, extract_and_concat
|
from library.pdf_utils import extract_pages, extract_and_concat
|
||||||
|
|
||||||
class ProjectItemListView(ProjectMixin, ListView):
|
class ProjectItemListView(ProjectMixin, ListView):
|
||||||
template_name = "library/item_list.html"
|
template_name = "library/item_list.html"
|
||||||
@ -193,6 +195,8 @@ class WorkUpdateView(EnsembleMixin, WorkMixin, UpdateView):
|
|||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
return resolve_url('work_detail', self.kwargs['pk'])
|
return resolve_url('work_detail', self.kwargs['pk'])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class WorkAddToProject(EnsembleMixin, FormView):
|
class WorkAddToProject(EnsembleMixin, FormView):
|
||||||
admin_required = True
|
admin_required = True
|
||||||
form_class = forms.ProjectSelectForm
|
form_class = forms.ProjectSelectForm
|
||||||
@ -269,20 +273,27 @@ class WorkAddDocumentView(EnsembleMixin, CreateView):
|
|||||||
doc = form.save(commit=False)
|
doc = form.save(commit=False)
|
||||||
doc.work_id = self.kwargs['pk']
|
doc.work_id = self.kwargs['pk']
|
||||||
doc.save()
|
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':
|
if self.request.headers['Accept'] == 'application/json':
|
||||||
filename = os.path.basename(doc.upload.name)
|
filename = os.path.basename(doc.upload.name)
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
"message": "created",
|
"message": "created",
|
||||||
"id": doc.pk,
|
"id": doc.pk,
|
||||||
"entry": f"""
|
"entry": render_to_string('library/document_entry.html', {'doc': doc, 'request': self.request})
|
||||||
<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>
|
|
||||||
"""
|
|
||||||
}, status=201)
|
}, status=201)
|
||||||
|
|
||||||
return redirect('document_annotate', doc.pk)
|
return redirect('document_annotate', doc.pk)
|
||||||
@ -336,6 +347,13 @@ class DocumentAnnotateView(EnsembleMixin, DocumentMixin, DetailView):
|
|||||||
data['json_data'] = {'pageTags': pages, 'instruments': dict(INSTRUMENTS)}
|
data['json_data'] = {'pageTags': pages, 'instruments': dict(INSTRUMENTS)}
|
||||||
return data
|
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):
|
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)
|
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 = 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
|
return response
|
||||||
|
|
||||||
def get_queryset(self):
|
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."""
|
"""Django's command-line utility for administrative tasks."""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@ -11,23 +11,22 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from os import environ
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
SECRET_KEY=environ.get('SECRET_KEY')
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
|
# 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!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
|
|
||||||
ALLOWED_HOSTS = ['localhost']
|
ALLOWED_HOSTS = ['localhost']
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
POLYPHONIC_MODULES = [
|
POLYPHONIC_MODULES = [
|
||||||
@ -42,11 +41,12 @@ INSTALLED_APPS = [
|
|||||||
'django.contrib.messages',
|
'django.contrib.messages',
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
'django_markdown2',
|
'django_markdown2',
|
||||||
|
'rest_framework',
|
||||||
'crispy_forms',
|
'crispy_forms',
|
||||||
'crispy_bulma',
|
'crispy_bulma',
|
||||||
'byostorage',
|
'byostorage',
|
||||||
'interface',
|
'interface',
|
||||||
] + POLYPHONIC_MODULES
|
]
|
||||||
|
|
||||||
CRISPY_ALLOWED_TEMPLATE_PACKS = ("bulma",)
|
CRISPY_ALLOWED_TEMPLATE_PACKS = ("bulma",)
|
||||||
CRISPY_TEMPLATE_PACK = "bulma"
|
CRISPY_TEMPLATE_PACK = "bulma"
|
||||||
@ -131,8 +131,8 @@ USE_TZ = True
|
|||||||
# https://docs.djangoproject.com/en/3.1/howto/static-files/
|
# https://docs.djangoproject.com/en/3.1/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = '/static/'
|
STATIC_URL = '/static/'
|
||||||
|
STATIC_ROOT = 'static'
|
||||||
|
|
||||||
# Need to set this
|
# Need to set this
|
||||||
AWS_BUCKET = ''
|
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
|
asgiref==3.4.1
|
||||||
boto3==1.18.34
|
boto3==1.18.34
|
||||||
botocore==1.21.34
|
botocore==1.21.34
|
||||||
|
crispy-bulma==0.8.0
|
||||||
Django==3.2.7
|
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
|
django-markdown2==0.3.1
|
||||||
jmespath==0.10.0
|
|
||||||
markdown2==2.4.1
|
markdown2==2.4.1
|
||||||
python-dateutil==2.8.2
|
python-dateutil==2.8.2
|
||||||
pytz==2021.1
|
pytz==2021.1
|
||||||
s3transfer==0.5.0
|
|
||||||
six==1.16.0
|
six==1.16.0
|
||||||
sqlparse==0.4.1
|
sqlparse==0.4.1
|
||||||
urllib3==1.26.6
|
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