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/manage.py b/marietje/manage.py
index c3b939e..029aed6 100755
--- a/marietje/manage.py
+++ b/marietje/manage.py
@@ -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:
diff --git a/marietje/marietje/settings/settings.py b/marietje/marietje/settings/base.py
similarity index 85%
rename from marietje/marietje/settings/settings.py
rename to marietje/marietje/settings/base.py
index 4fc3125..412c69e 100644
--- a/marietje/marietje/settings/settings.py
+++ b/marietje/marietje/settings/base.py
@@ -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.
diff --git a/marietje/marietje/settings/development.py b/marietje/marietje/settings/development.py
new file mode 100644
index 0000000..c31ae2e
--- /dev/null
+++ b/marietje/marietje/settings/development.py
@@ -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'
diff --git a/marietje/marietje/settings/production.py.example b/marietje/marietje/settings/production.py.example
new file mode 100644
index 0000000..2be9f0e
--- /dev/null
+++ b/marietje/marietje/settings/production.py.example
@@ -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)
\ No newline at end of file
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 f008583..84651c8 100644
--- a/marietje/marietje/static/marietje/js/base.js
+++ b/marietje/marietje/static/marietje/js/base.js
@@ -36,4 +36,37 @@ Number.prototype.timestampToHHMMSS = function () {
seconds = '0' + seconds;
}
return hours + ':' + minutes + ':' + seconds;
-};
\ No newline at end of file
+};
+
+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;
+ }
+}
\ No newline at end of file
diff --git a/marietje/marietje/templates/marietje/base.html b/marietje/marietje/templates/marietje/base.html
index 72f3ec0..a999251 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 %}
@@ -25,8 +25,19 @@
+
+
+
+
+
+
+
+ {% render_announcements %}
+