From a78098198fabf20d47a0fb598ed123f8ef83107c Mon Sep 17 00:00:00 2001 From: Lars van Rhijn Date: Wed, 11 Oct 2023 16:49:37 +0200 Subject: [PATCH] Add announcements app --- marietje/announcements/__init__.py | 0 marietje/announcements/admin.py | 16 + marietje/announcements/apps.py | 8 + marietje/announcements/middleware.py | 43 ++ .../announcements/migrations/0001_initial.py | 30 ++ marietje/announcements/migrations/__init__.py | 0 marietje/announcements/models.py | 47 ++ marietje/announcements/services.py | 33 ++ .../announcements/css/announcements.css | 34 ++ .../static/announcements/js/announcements.js | 53 +++ .../announcements/announcements.html | 15 + .../announcements/templatetags/__init__.py | 0 .../templatetags/announcements.py | 20 + .../announcements/templatetags/bleach_tags.py | 41 ++ marietje/announcements/tests/__init__.py | 0 marietje/announcements/tests/test_services.py | 43 ++ marietje/marietje/settings/base.py | 2 + .../static/marietje/css/variables.css | 5 + marietje/marietje/static/marietje/js/base.js | 13 +- .../marietje/templates/marietje/base.html | 9 +- poetry.lock | 442 ++++++++++-------- pyproject.toml | 2 + 22 files changed, 665 insertions(+), 191 deletions(-) create mode 100644 marietje/announcements/__init__.py create mode 100644 marietje/announcements/admin.py create mode 100644 marietje/announcements/apps.py create mode 100644 marietje/announcements/middleware.py create mode 100644 marietje/announcements/migrations/0001_initial.py create mode 100644 marietje/announcements/migrations/__init__.py create mode 100644 marietje/announcements/models.py create mode 100644 marietje/announcements/services.py create mode 100644 marietje/announcements/static/announcements/css/announcements.css create mode 100644 marietje/announcements/static/announcements/js/announcements.js create mode 100644 marietje/announcements/templates/announcements/announcements.html create mode 100644 marietje/announcements/templatetags/__init__.py create mode 100644 marietje/announcements/templatetags/announcements.py create mode 100644 marietje/announcements/templatetags/bleach_tags.py create mode 100644 marietje/announcements/tests/__init__.py create mode 100644 marietje/announcements/tests/test_services.py diff --git a/marietje/announcements/__init__.py b/marietje/announcements/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/marietje/announcements/admin.py b/marietje/announcements/admin.py new file mode 100644 index 0000000..43cadcb --- /dev/null +++ b/marietje/announcements/admin.py @@ -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 diff --git a/marietje/announcements/apps.py b/marietje/announcements/apps.py new file mode 100644 index 0000000..1a9d940 --- /dev/null +++ b/marietje/announcements/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class AnnouncementsConfig(AppConfig): + """Announcements AppConfig.""" + + default_auto_field = "django.db.models.BigAutoField" + name = "announcements" diff --git a/marietje/announcements/middleware.py b/marietje/announcements/middleware.py new file mode 100644 index 0000000..0cf4305 --- /dev/null +++ b/marietje/announcements/middleware.py @@ -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) diff --git a/marietje/announcements/migrations/0001_initial.py b/marietje/announcements/migrations/0001_initial.py new file mode 100644 index 0000000..634ed5f --- /dev/null +++ b/marietje/announcements/migrations/0001_initial.py @@ -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',), + }, + ), + ] diff --git a/marietje/announcements/migrations/__init__.py b/marietje/announcements/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/marietje/announcements/models.py b/marietje/announcements/models.py new file mode 100644 index 0000000..f8d947d --- /dev/null +++ b/marietje/announcements/models.py @@ -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() diff --git a/marietje/announcements/services.py b/marietje/announcements/services.py new file mode 100644 index 0000000..cb96da8 --- /dev/null +++ b/marietje/announcements/services.py @@ -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)) diff --git a/marietje/announcements/static/announcements/css/announcements.css b/marietje/announcements/static/announcements/css/announcements.css new file mode 100644 index 0000000..a376436 --- /dev/null +++ b/marietje/announcements/static/announcements/css/announcements.css @@ -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; +} \ No newline at end of file diff --git a/marietje/announcements/static/announcements/js/announcements.js b/marietje/announcements/static/announcements/js/announcements.js new file mode 100644 index 0000000..c3650f2 --- /dev/null +++ b/marietje/announcements/static/announcements/js/announcements.js @@ -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); +} diff --git a/marietje/announcements/templates/announcements/announcements.html b/marietje/announcements/templates/announcements/announcements.html new file mode 100644 index 0000000..872e18b --- /dev/null +++ b/marietje/announcements/templates/announcements/announcements.html @@ -0,0 +1,15 @@ +{% load bleach_tags %} + +{% for announcement in announcements %} +
+ {{ announcement.content|bleach }} + +
+{% endfor %} + +{% for announcement in app_announcements %} +
+ {{ announcement|bleach }} +
+{% endfor %} \ No newline at end of file diff --git a/marietje/announcements/templatetags/__init__.py b/marietje/announcements/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/marietje/announcements/templatetags/announcements.py b/marietje/announcements/templatetags/announcements.py new file mode 100644 index 0000000..61c473a --- /dev/null +++ b/marietje/announcements/templatetags/announcements.py @@ -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, + } diff --git a/marietje/announcements/templatetags/bleach_tags.py b/marietje/announcements/templatetags/bleach_tags.py new file mode 100644 index 0000000..142b75f --- /dev/null +++ b/marietje/announcements/templatetags/bleach_tags.py @@ -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, + ) + ) diff --git a/marietje/announcements/tests/__init__.py b/marietje/announcements/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/marietje/announcements/tests/test_services.py b/marietje/announcements/tests/test_services.py new file mode 100644 index 0000000..8d7e352 --- /dev/null +++ b/marietje/announcements/tests/test_services.py @@ -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])), + ) diff --git a/marietje/marietje/settings/base.py b/marietje/marietje/settings/base.py index b055549..412c69e 100644 --- a/marietje/marietje/settings/base.py +++ b/marietje/marietje/settings/base.py @@ -13,6 +13,8 @@ INSTALLED_APPS = [ 'django_bootstrap5', 'fontawesomefree', 'rest_framework', + 'tinymce', + 'announcements', 'marietje', 'queues', 'songs', diff --git a/marietje/marietje/static/marietje/css/variables.css b/marietje/marietje/static/marietje/css/variables.css index c6dc7a8..1a416fb 100644 --- a/marietje/marietje/static/marietje/css/variables.css +++ b/marietje/marietje/static/marietje/css/variables.css @@ -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; diff --git a/marietje/marietje/static/marietje/js/base.js b/marietje/marietje/static/marietje/js/base.js index e28d9a2..84651c8 100644 --- a/marietje/marietje/static/marietje/js/base.js +++ b/marietje/marietje/static/marietje/js/base.js @@ -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; + } } \ No newline at end of file diff --git a/marietje/marietje/templates/marietje/base.html b/marietje/marietje/templates/marietje/base.html index edba2ff..8b6d2cf 100644 --- a/marietje/marietje/templates/marietje/base.html +++ b/marietje/marietje/templates/marietje/base.html @@ -1,4 +1,4 @@ -{% load static django_bootstrap5 %} +{% load static django_bootstrap5 announcements %} @@ -23,11 +23,18 @@ + + + + +
+ {% render_announcements %} +