mirror of
https://gitlab.science.ru.nl/technicie/MarietjeDjango.git
synced 2025-12-09 17:52:21 +01:00
Add announcements app
This commit is contained in:
0
marietje/announcements/__init__.py
Normal file
0
marietje/announcements/__init__.py
Normal file
16
marietje/announcements/admin.py
Normal file
16
marietje/announcements/admin.py
Normal 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
|
||||
8
marietje/announcements/apps.py
Normal file
8
marietje/announcements/apps.py
Normal file
@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AnnouncementsConfig(AppConfig):
|
||||
"""Announcements AppConfig."""
|
||||
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "announcements"
|
||||
43
marietje/announcements/middleware.py
Normal file
43
marietje/announcements/middleware.py
Normal 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)
|
||||
30
marietje/announcements/migrations/0001_initial.py
Normal file
30
marietje/announcements/migrations/0001_initial.py
Normal 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',),
|
||||
},
|
||||
),
|
||||
]
|
||||
0
marietje/announcements/migrations/__init__.py
Normal file
0
marietje/announcements/migrations/__init__.py
Normal file
47
marietje/announcements/models.py
Normal file
47
marietje/announcements/models.py
Normal 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()
|
||||
33
marietje/announcements/services.py
Normal file
33
marietje/announcements/services.py
Normal 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))
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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 %}
|
||||
0
marietje/announcements/templatetags/__init__.py
Normal file
0
marietje/announcements/templatetags/__init__.py
Normal file
20
marietje/announcements/templatetags/announcements.py
Normal file
20
marietje/announcements/templatetags/announcements.py
Normal 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,
|
||||
}
|
||||
41
marietje/announcements/templatetags/bleach_tags.py
Normal file
41
marietje/announcements/templatetags/bleach_tags.py
Normal 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,
|
||||
)
|
||||
)
|
||||
0
marietje/announcements/tests/__init__.py
Normal file
0
marietje/announcements/tests/__init__.py
Normal file
43
marietje/announcements/tests/test_services.py
Normal file
43
marietje/announcements/tests/test_services.py
Normal 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])),
|
||||
)
|
||||
@ -13,6 +13,8 @@ INSTALLED_APPS = [
|
||||
'django_bootstrap5',
|
||||
'fontawesomefree',
|
||||
'rest_framework',
|
||||
'tinymce',
|
||||
'announcements',
|
||||
'marietje',
|
||||
'queues',
|
||||
'songs',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -38,7 +38,7 @@ Number.prototype.timestampToHHMMSS = function () {
|
||||
return hours + ':' + minutes + ':' + seconds;
|
||||
};
|
||||
|
||||
function setCookie(name, value, days) {
|
||||
function setCookie(name,value,days) {
|
||||
let expires = "";
|
||||
value = encodeURI(value);
|
||||
if (days) {
|
||||
@ -58,4 +58,15 @@ function getCookie(name) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
{% load static django_bootstrap5 %}
|
||||
{% load static django_bootstrap5 announcements %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@ -23,11 +23,18 @@
|
||||
<!-- 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">
|
||||
<button class="navbar-toggler ms-auto" type="button" data-bs-toggle="offcanvas"
|
||||
|
||||
Reference in New Issue
Block a user