Merge branch 'marietje-zuid' into feature/marietje-4.1-in-header

This commit is contained in:
Lars van Rhijn
2023-10-11 17:35:44 +02:00
29 changed files with 792 additions and 244 deletions

View File

View File

@ -0,0 +1,16 @@
from django.contrib import admin
from announcements.models import Announcement
@admin.register(Announcement)
class AnnouncementAdmin(admin.ModelAdmin):
"""Manage the admin pages for the announcements."""
list_display = ("title", "since", "until", "visible")
def visible(self, obj):
"""Is the object visible."""
return obj.is_visible
visible.boolean = True

View File

@ -0,0 +1,8 @@
from django.apps import AppConfig
class AnnouncementsConfig(AppConfig):
"""Announcements AppConfig."""
default_auto_field = "django.db.models.BigAutoField"
name = "announcements"

View File

@ -0,0 +1,43 @@
from announcements.services import (
validate_closed_announcements,
sanitize_closed_announcements,
encode_closed_announcements,
)
from django.apps import apps
class ClosedAnnouncementsMiddleware:
"""Closed Announcements Middleware."""
def __init__(self, get_response):
"""Initialize."""
self.get_response = get_response
def __call__(self, request):
"""Update the closed announcements' cookie."""
response = self.get_response(request)
closed_announcements = validate_closed_announcements(
sanitize_closed_announcements(request.COOKIES.get("closed-announcements", None))
)
response.set_cookie("closed-announcements", encode_closed_announcements(closed_announcements))
return response
class AppAnnouncementMiddleware:
"""Announcement Middleware."""
def __init__(self, get_response):
"""Initialize Announcement Middleware."""
self.get_response = get_response
def __call__(self, request):
"""Call app announcement function if they exist for gathering all announcements."""
announcements = []
for app in apps.get_app_configs():
if hasattr(app, "announcements") and hasattr(app.announcements, "__call__"):
announcements += app.announcements(request)
setattr(request, "_app_announcements", announcements)
return self.get_response(request)

View File

@ -0,0 +1,30 @@
# Generated by Django 3.2.16 on 2022-12-09 19:50
from django.db import migrations, models
import django.utils.timezone
import tinymce.models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Announcement',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(help_text='This is not shown on the announcement but can be used as an identifier in the admin area.', max_length=100)),
('content', tinymce.models.HTMLField(max_length=500)),
('since', models.DateTimeField(default=django.utils.timezone.now)),
('until', models.DateTimeField(blank=True, null=True)),
('icon', models.CharField(default='bullhorn', help_text='Font Awesome 6 abbreviation for icon to use.', max_length=150, verbose_name='Font Awesome 6 icon')),
],
options={
'ordering': ('-since',),
},
),
]

View File

@ -0,0 +1,47 @@
from django.db import models
from django.db.models import Q
from django.utils import timezone
from tinymce.models import HTMLField
class AnnouncementManager(models.Manager):
"""Announcement Manager."""
def visible(self):
"""Get only visible announcements."""
return self.get_queryset().filter((Q(until__gt=timezone.now()) | Q(until=None)) & Q(since__lte=timezone.now()))
class Announcement(models.Model):
"""Announcement model."""
title = models.CharField(
max_length=100,
help_text="This is not shown on the announcement but can be used as an identifier in the admin area.",
)
content = HTMLField(blank=False, max_length=500)
since = models.DateTimeField(default=timezone.now)
until = models.DateTimeField(blank=True, null=True)
icon = models.CharField(
verbose_name="Font Awesome 6 icon",
help_text="Font Awesome 6 abbreviation for icon to use.",
max_length=150,
default="bullhorn",
)
objects = AnnouncementManager()
class Meta:
"""Meta class."""
ordering = ("-since",)
def __str__(self):
"""Cast this object to string."""
return self.title
@property
def is_visible(self):
"""Is this announcement currently visible."""
return (self.until is None or self.until > timezone.now()) and self.since <= timezone.now()

View File

@ -0,0 +1,33 @@
import json
import urllib.parse
from announcements.models import Announcement
def sanitize_closed_announcements(closed_announcements) -> list:
"""Convert a cookie (closed_announcements) to a list of id's of closed announcements."""
if closed_announcements is None or not isinstance(closed_announcements, str):
return []
try:
closed_announcements_list = json.loads(urllib.parse.unquote(closed_announcements))
except json.JSONDecodeError:
return []
if not isinstance(closed_announcements_list, list):
return []
closed_announcements_list_ints = []
for closed_announcement in closed_announcements_list:
if isinstance(closed_announcement, int):
closed_announcements_list_ints.append(closed_announcement)
return closed_announcements_list_ints
def validate_closed_announcements(closed_announcements) -> list:
"""Verify the integers in the list such that the ID's that in the database exist only remain."""
return list(Announcement.objects.filter(id__in=closed_announcements).values_list("id", flat=True))
def encode_closed_announcements(closed_announcements: list) -> str:
"""Encode the announcement list in URL encoding."""
return urllib.parse.quote(json.dumps(closed_announcements))

View File

@ -0,0 +1,34 @@
.announcement {
display: flex;
justify-content: center;
align-items: center;
background: var(--primary-shade);
color: var(--primary-contrast);
text-align: center;
padding: 0.5rem 1rem;
}
.announcement .btn-close {
color: var(--primary-contrast);
}
.announcement p {
display: inline;
margin: 0;
color: var(--primary-contrast);
font-size: 0.9rem;
font-weight: bold;
}
.announcement a {
color: var(--primary-contrast);
text-decoration: underline;
}
.announcement a:hover {
color: var(--primary-contrast-hover);
}
.announcement button {
background-size: .6rem;
}

View File

@ -0,0 +1,53 @@
const ANNOUNCEMENT_COOKIE = "closed-announcements";
function safeGetCookie() {
try {
return getCookie(ANNOUNCEMENT_COOKIE);
} catch {
return null;
}
}
function sanitizeAnnouncementsArray(closedAnnouncements) {
if (closedAnnouncements === null || typeof closedAnnouncements !== 'string') {
return [];
}
else {
try {
closedAnnouncements = JSON.parse(closedAnnouncements);
} catch {
return [];
}
if (! Array.isArray(closedAnnouncements)) {
closedAnnouncements = [];
}
for (let i = 0; i < closedAnnouncements.length; i++) {
if (!Number.isInteger(closedAnnouncements[i])) {
closedAnnouncements.splice(i, 1);
}
}
return closedAnnouncements;
}
}
function close_announcement(announcementId) {
let announcementElement = document.getElementById("announcement-" + announcementId);
if (announcementElement !== null) {
announcementElement.remove();
}
let closedAnnouncements = safeGetCookie();
if (closedAnnouncements === null) {
closedAnnouncements = [];
} else {
closedAnnouncements = sanitizeAnnouncementsArray(closedAnnouncements);
}
if (!closedAnnouncements.includes(announcementId)) {
closedAnnouncements.push(announcementId);
}
setListCookie(ANNOUNCEMENT_COOKIE, closedAnnouncements, 31);
}

View File

@ -0,0 +1,15 @@
{% load bleach_tags %}
{% for announcement in announcements %}
<div class="announcement" id="announcement-{{ announcement.id }}">
<i class="fas fa-{{ announcement.icon }} me-3"></i> {{ announcement.content|bleach }}
<button type="button" class="btn-close ms-3" aria-label="Close"
data-announcement-id="{{ announcement.pk }}" onclick="close_announcement({{ announcement.id }})"></button>
</div>
{% endfor %}
{% for announcement in app_announcements %}
<div class="announcement">
{{ announcement|bleach }}
</div>
{% endfor %}

View File

@ -0,0 +1,20 @@
from django import template
from django.db.models import Q
from announcements.models import Announcement
from announcements.services import sanitize_closed_announcements
register = template.Library()
@register.inclusion_tag("announcements/announcements.html", takes_context=True)
def render_announcements(context):
"""Render all active announcements."""
request = context.get("request")
closed_announcements = sanitize_closed_announcements(request.COOKIES.get("closed-announcements", None))
return {
"announcements": Announcement.objects.visible().filter(~Q(id__in=closed_announcements)),
"app_announcements": getattr(request, "_app_announcements", []),
"closed_announcements": closed_announcements,
}

View File

@ -0,0 +1,41 @@
from bleach.css_sanitizer import CSSSanitizer
from django import template
from django.template.defaultfilters import stringfilter
from django.utils.safestring import mark_safe
from bleach import clean
register = template.Library()
@register.filter(is_safe=True)
@stringfilter
def bleach(value):
"""Bleach dangerous html from the input."""
css_sanitizer = CSSSanitizer(allowed_css_properties=["text-decoration"])
return mark_safe(
clean(
value,
tags=[
"h2",
"h3",
"p",
"a",
"div",
"strong",
"em",
"i",
"b",
"ul",
"li",
"br",
"ol",
"span",
],
attributes={
"*": ["class", "style"],
"a": ["href", "rel", "target", "title"],
},
css_sanitizer=css_sanitizer,
strip=True,
)
)

View File

View File

@ -0,0 +1,43 @@
from django.test import TestCase
from announcements import models
from announcements.services import sanitize_closed_announcements, validate_closed_announcements
class OrderServicesTests(TestCase):
def test_sanitize_closed_announcements_none(self):
self.assertEquals([], sanitize_closed_announcements(None))
def test_sanitize_closed_announcements_non_string(self):
self.assertEquals([], sanitize_closed_announcements(5))
def test_sanitize_closed_announcements_non_json(self):
self.assertEquals([], sanitize_closed_announcements("this,is,not,json"))
def test_sanitize_closed_announcements_non_list(self):
self.assertEquals([], sanitize_closed_announcements("{}"))
def test_sanitize_closed_announcements_list_of_ints(self):
self.assertEquals([1, 8, 4], sanitize_closed_announcements("[1, 8, 4]"))
def test_sanitize_closed_announcements_list_of_different_types(self):
self.assertEquals(
[1, 8, 4], sanitize_closed_announcements('[1, "bla", 8, 4, 123.4, {"a": "b"}, ["This is also a list"]]')
)
def test_validate_closed_announcements_all_exist(self):
announcement_1 = models.Announcement.objects.create(title="Announcement 1", content="blablabla")
announcement_2 = models.Announcement.objects.create(title="Announcement 2", content="blablabla")
announcement_3 = models.Announcement.objects.create(title="Announcement 3", content="blablabla")
self.assertEqual(
{announcement_1.id, announcement_2.id, announcement_3.id}, set(validate_closed_announcements([1, 2, 3]))
)
def test_validate_closed_announcements_some_do_not_exist(self):
announcement_1 = models.Announcement.objects.create(title="Announcement 1", content="blablabla")
announcement_2 = models.Announcement.objects.create(title="Announcement 2", content="blablabla")
announcement_3 = models.Announcement.objects.create(title="Announcement 3", content="blablabla")
self.assertEqual(
{announcement_1.id, announcement_2.id, announcement_3.id},
set(validate_closed_announcements([1, 2, 3, 4, 5, 6])),
)

View File

@ -3,7 +3,13 @@ import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "marietje.settings.settings")
try:
import marietje.settings.production # noqa
except ModuleNotFoundError:
# Use the development settings if the production settings are not available (so we're on a dev machine)
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "marietje.settings.development")
else:
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "marietje.settings.production")
try:
from django.core.management import execute_from_command_line
except ImportError:

View File

@ -1,14 +1,7 @@
"""
Django settings for marietje project.
"""
import os
from pathlib import Path
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
SECRET_KEY = 'sae2hahHao1soo0Ocoz5Ieh1Ushae6feJe4mooliooj0Ula8'
DEBUG = False
ALLOWED_HOSTS = ['*']
BASE_DIR = Path(__file__).resolve().parent.parent.parent
INSTALLED_APPS = [
'django.contrib.admin',
@ -20,6 +13,8 @@ INSTALLED_APPS = [
'django_bootstrap5',
'fontawesomefree',
'rest_framework',
'tinymce',
'announcements',
'marietje',
'queues',
'songs',
@ -64,18 +59,6 @@ TEMPLATES = [
WSGI_APPLICATION = 'marietje.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'marietje',
'USER': 'marietje',
'PASSWORD': 'v8TzZwdAdSi7Tk5I',
'HOST': 'localhost',
'PORT': '3306',
'OPTIONS': {'init_command': "SET sql_mode='STRICT_TRANS_TABLES'"},
}
}
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
@ -147,12 +130,6 @@ OAUTH2_PROVIDER = {
},
}
# zc files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.10/howto/static-files/
BASE_URL = 'https://marietje-zuid.science.ru.nl'
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
STATIC_URL = '/static/'
LOGIN_URL = '/login/'
@ -164,10 +141,8 @@ LOGOUT_REDIRECT_URL = '/'
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# App specific settings
BERTHA_HOST = ('bach.science.ru.nl', 1234)
MAIL_FROM = 'marietje@marietje.science.ru.nl'
MAX_MINUTES_IN_A_ROW = 45
# Time range (dependent on timezone specified) when MAX_MINUTES_IN_A_ROW is in effect.

View File

@ -0,0 +1,20 @@
import os
from .base import *
SECRET_KEY = 'sae2hahHao1soo0Ocoz5Ieh1Ushae6feJe4mooliooj0Ula8'
DEBUG = False
ALLOWED_HOSTS = ['*']
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(BASE_DIR, "db.sqlite3"),
}
}
BERTHA_HOST = ('localhost', 1234)
BASE_URL = 'http://localhost:8000'

View File

@ -0,0 +1,23 @@
from .base import *
SECRET_KEY = '******'
DEBUG = False
ALLOWED_HOSTS = ['marietje-zuid.nl']
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'marietje',
'USER': 'marietje',
'PASSWORD': '******',
'HOST': 'localhost',
'PORT': '3306',
'OPTIONS': {'init_command': "SET sql_mode='STRICT_TRANS_TABLES'"},
}
}
BASE_URL = 'https://marietje-zuid.science.ru.nl'
BERTHA_HOST = ('bach.science.ru.nl', 1234)

View File

@ -1,4 +1,9 @@
:root {
--pimary: #0d6efd;
--primary-shade: #0d54bd;
--primary-contrast: #ffffff;
--primary-contrast-hover: rgba(255, 255, 255, 0.7);
--background-color: #ffffff;
--background-shade: #dddddd;
--background-shade-light: #f7f7f7;

View File

@ -36,4 +36,37 @@ Number.prototype.timestampToHHMMSS = function () {
seconds = '0' + seconds;
}
return hours + ':' + minutes + ':' + seconds;
};
};
function setCookie(name,value,days) {
let expires = "";
value = encodeURI(value);
if (days) {
let date = new Date();
date.setTime(date.getTime() + (days*24*60*60*1000));
expires = "; expires=" + date.toUTCString();
}
document.cookie = name + "=" + (value || "") + expires + "; path=/";
}
function getCookie(name) {
let nameEQ = name + "=";
let ca = document.cookie.split(';');
for(let i=0;i < ca.length;i++) {
let c = ca[i];
while (c.charAt(0)===' ') c = c.substring(1,c.length);
if (c.indexOf(nameEQ) === 0) return decodeURIComponent(c.substring(nameEQ.length,c.length));
}
return null;
}
function setListCookie(name, list, days) {
try {
let string = JSON.stringify(list);
setCookie(name, string, days);
return true;
}
catch(error) {
return false;
}
}

View File

@ -1,4 +1,4 @@
{% load static django_bootstrap5 %}
{% load static django_bootstrap5 announcements %}
<!DOCTYPE html>
<html lang="en">
<head>
@ -25,8 +25,19 @@
<!-- Base JavaScript -->
<script type="text/javascript" src="{% static "marietje/js/base.js" %}"></script>
<!-- Announcements static files -->
<link rel="stylesheet" href="{% static 'announcements/css/announcements.css' %}" type="text/css">
<script src="{% static 'announcements/js/announcements.js' %}"></script>
<script>
const CSRF_TOKEN = "{{ csrf_token }}";
</script>
</head>
<body>
<section id="announcements-alerts">
{% render_announcements %}
</section>
<nav class="navbar navbar-expand-lg sticky-top navbar-dark bg-primary">
<div class="container">
<a class="navbar-brand d-block d-lg-none" href="{% url "index" %}">Marietje 4.1</a>
@ -113,9 +124,6 @@
</footer>
{% endif %}
{% bootstrap_javascript %}
<script>
const CSRF_TOKEN = "{{ csrf_token }}";
</script>
{% block js %}{% endblock %}
</body>
</html>

View File

@ -59,7 +59,13 @@
return response.json();
})
.then(json => {
updateScreen(json.current_song.user.name, json.current_song.song.artist, json.current_song.song.title);
let requestor;
if(json.current_song.user === null) {
requestor = "Marietje";
} else {
requestor = json.current_song.user.name;
}
updateScreen(requestor, json.current_song.song.artist, json.current_song.song.title);
setTimeout(fetchCurrentQueue, 1000);
}).catch(err => {
if(err.name == "NotLoggedIn") {

View File

@ -31,7 +31,7 @@ urlpatterns = [
path("register/", RegisterView.as_view(), name="register"),
path("activate/<int:user_id>/<str:token>/", ActivateView.as_view(), name="activate"),
path("forgotpassword/", ForgotPasswordView.as_view(), name="forgotpassword"),
path("resetpassword/<int:user_id>/<str:token>/", ResetPasswordView.as_view, name="resetpassword"),
path("resetpassword/<int:user_id>/<str:token>/", ResetPasswordView.as_view(), name="resetpassword"),
path("admin/", admin.site.urls),
path("privacy/", PrivacyView.as_view(), name="privacy"),
path("metrics/", metrics, name="metrics"),

View File

@ -66,7 +66,7 @@
<td class="col-md-2 d-sm-table-cell d-none">Requested By</td>
<td class="col-md-1 text-info d-sm-table-cell d-none" style="cursor: pointer;">
<span v-if="playsIn" id="timeswitch" class="btn btn-link p-0" v-on:click="playsIn = false">Plays In</span>
<span v-else class="btn btn-link p-0" v-on:click="playsIn = true">Plays at</span>
<span v-else class="btn btn-link p-0" v-on:click="playsIn = true">Plays At</span>
</td>
<td class="col-md-1 control-icons">Control</td>
</tr>
@ -93,17 +93,22 @@
</template>
</td>
<td>
<a href="#" v-if="song.can_move_up" v-on:click="move_down(queue[index-1].id)"><i
class="fa-solid fa-arrow-up"></i></a>
<a href="#" v-else class="invisible"><i class="fa-solid fa-arrow-up"></i></a>
<div class="d-flex flex-column">
<div class="d-flex flex-row">
<button v-if="song.can_move_up" v-on:click="move_down(queue[index-1].id)" class="btn btn-link"><i
class="fa-solid fa-arrow-up"></i></button>
<button v-else class="btn btn-link invisible"><i class="fa-solid fa-arrow-up"></i></button>
<a href="#" v-if="song.can_move_down" v-on:click="move_down(song.id)"><i
class="fa-solid fa-arrow-down"></i></a>
<a href="#" v-else class="invisible"><i class="fa-solid fa-arrow-down"></i></a>
<a href="#" v-if="song.can_delete" v-on:click="cancel_song(song.id)"><i
class="fa-solid fa-trash-can"></i></a>
<a href="#" v-else class="invisible"><i class="fa-solid fa-trash-can"></i></a>
<button v-if="song.can_move_down" v-on:click="move_down(song.id)" class="btn btn-link"><i
class="fa-solid fa-arrow-down"></i></button>
<button v-else class="btn btn-link invisible"><i class="fa-solid fa-arrow-down"></i></button>
</div>
<div class="d-flex flex-row">
<button v-if="song.can_delete" v-on:click="cancel_song(song.id)" class="btn btn-link"><i
class="fa-solid fa-trash-can"></i></button>
<button v-else class="btn btn-link invisible"><i class="fa-solid fa-trash-can"></i></button>
</div>
</div>
</td>
</tr>
</template>
@ -353,7 +358,11 @@
"Content-Type": 'application/json',
},
}
).finally(() => {
).then(() => {
tata.success("", "Removed song from the queue.");
}).catch(() => {
tata.error("", "An error occurred while removing the song, please try again.");
}).finally(() => {
this.refresh();
});
},
@ -368,7 +377,11 @@
"Content-Type": 'application/json',
},
}
).finally(() => {
).then(() => {
tata.success("", "Song was moved successfully.");
}).catch(() => {
tata.error("", "An error occurred while moving the song, please try again.");
}).finally(() => {
this.refresh();
});
}
@ -414,6 +427,7 @@
this.page_size = 10;
}
this.page_number = 1;
setCookie("REQUEST_PAGE_SIZE", this.page_size, 14);
this.search();
}
}
@ -425,7 +439,7 @@
},
created() {
fetch(
`/api/v1/songs/?ordering=title&limit=${this.page_size}&offset=${this.page_size * (this.page_number - 1)}`
`/api/v1/songs/?ordering=artist,title&limit=${this.page_size}&offset=${this.page_size * (this.page_number - 1)}`
).then(response => {
if (response.status === 200) {
return response.json();
@ -444,11 +458,15 @@
tata.error("", "An unknown error occurred, please try again.")
}
});
const stored_page_size = parseInt(getCookie("REQUEST_PAGE_SIZE"));
if (stored_page_size !== Number.NaN && stored_page_size > 0) {
this.page_size = stored_page_size;
}
},
methods: {
search() {
fetch(
`/api/v1/songs/?ordering=title&limit=${this.page_size}&offset=${this.page_size * (this.page_number - 1)}&search=${this.search_input}`,
`/api/v1/songs/?ordering=artist,title&limit=${this.page_size}&offset=${this.page_size * (this.page_number - 1)}&search=${this.search_input}`,
{
headers: {
"X-CSRFToken": CSRF_TOKEN,

View File

@ -102,6 +102,7 @@
this.page_size = 10;
}
this.page_number = 1;
setCookie("MANAGE_PAGE_SIZE", this.page_size, 14);
this.refresh();
}
}
@ -113,7 +114,7 @@
},
created() {
fetch(
`/api/v1/songs/?ordering=title&limit=${this.page_size}&offset=${this.page_size * (this.page_number - 1)}`
`/api/v1/songs/?ordering=artist,title&limit=${this.page_size}&offset=${this.page_size * (this.page_number - 1)}`
).then(response => {
if (response.status === 200) {
return response.json();
@ -132,6 +133,10 @@
tata.error("", "An unknown error occurred, please try again.")
}
});
const stored_page_size = parseInt(getCookie("MANAGE_PAGE_SIZE"));
if (stored_page_size !== Number.NaN && stored_page_size > 0) {
this.page_size = stored_page_size;
}
},
methods: {
search() {
@ -140,7 +145,7 @@
},
refresh() {
fetch(
`/api/v1/songs/?ordering=title&limit=${this.page_size}&offset=${this.page_size * (this.page_number - 1)}&search=${this.search_input}`,
`/api/v1/songs/?ordering=artist,title&limit=${this.page_size}&offset=${this.page_size * (this.page_number - 1)}&search=${this.search_input}`,
{
headers: {
"X-CSRFToken": CSRF_TOKEN,