diff --git a/client/Embedded.vue b/client/Embedded.vue index a0f0d2831e..80b105dc15 100644 --- a/client/Embedded.vue +++ b/client/Embedded.vue @@ -1,12 +1,13 @@ diff --git a/client/index.html b/client/index.html index 740c994329..75d6f77727 100644 --- a/client/index.html +++ b/client/index.html @@ -12,6 +12,7 @@ +
@@ -20,5 +21,6 @@
+ diff --git a/client/shared/json-wrapper.js b/client/shared/json-wrapper.js new file mode 100644 index 0000000000..e080b5a479 --- /dev/null +++ b/client/shared/json-wrapper.js @@ -0,0 +1,20 @@ +export const JSONWrapper = { + parse(jsonString, defaultValue) { + if(typeof jsonString !== "string") { + return defaultValue + } + try { + return JSON.parse(jsonString); + } catch (e) { + console.error(e); + } + return defaultValue + }, + stringify(data) { + try { + return JSON.stringify(data); + } catch (e) { + console.error(e) + } + }, +} diff --git a/client/shared/local-storage-wrapper.js b/client/shared/local-storage-wrapper.js new file mode 100644 index 0000000000..88cd3dc589 --- /dev/null +++ b/client/shared/local-storage-wrapper.js @@ -0,0 +1,42 @@ + +/* + * DEVELOPER NOTE + * + * Some browsers can block storage (localStorage, sessionStorage) + * access for privacy reasons, and all browsers can have storage + * that's full, and then they throw exceptions. + * + * See https://michalzalecki.com/why-using-localStorage-directly-is-a-bad-idea/ + * + * Exceptions can even be thrown when testing if localStorage + * even exists. This can throw: + * + * if (window.localStorage) + * + * Also localStorage/sessionStorage can be enabled after DOMContentLoaded + * so we handle it gracefully. + * + * 1) we need to wrap all usage in try/catch + * 2) we need to defer actual usage of these until + * necessary, + * + */ + +export const localStorageWrapper = { + getItem: (key) => { + try { + return localStorage.getItem(key) + } catch (e) { + console.error(e); + } + return null; + }, + setItem: (key, value) => { + try { + return localStorage.setItem(key, value) + } catch (e) { + console.error(e); + } + return; + }, +} diff --git a/client/shared/status-common.js b/client/shared/status-common.js new file mode 100644 index 0000000000..6503bfbf63 --- /dev/null +++ b/client/shared/status-common.js @@ -0,0 +1,5 @@ +// Used in Playwright Status and components + +export const STATUS_STORAGE_KEY = "status-dismissed" + +export const generateStatusTestId = (id) => `status-${id}` diff --git a/ietf/api/__init__.py b/ietf/api/__init__.py index 81f370121f..9fadab8e6f 100644 --- a/ietf/api/__init__.py +++ b/ietf/api/__init__.py @@ -21,14 +21,14 @@ from tastypie.exceptions import ApiFieldError from tastypie.fields import ApiField - _api_list = [] +OMITTED_APPS_APIS = ["ietf.status"] def populate_api_list(): + _module_dict = globals() for app_config in django_apps.get_app_configs(): - _module_dict = globals() - if '.' in app_config.name: + if '.' in app_config.name and app_config.name not in OMITTED_APPS_APIS: _root, _name = app_config.name.split('.', 1) if _root == 'ietf': if not '.' in _name: diff --git a/ietf/api/tests.py b/ietf/api/tests.py index fd8eb52cd6..20c3e2cb44 100644 --- a/ietf/api/tests.py +++ b/ietf/api/tests.py @@ -48,6 +48,7 @@ 'ietf.secr.meetings', 'ietf.secr.proceedings', 'ietf.ipr', + 'ietf.status', ) class CustomApiTests(TestCase): diff --git a/ietf/settings.py b/ietf/settings.py index 7572b15213..db53efe0a5 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -479,6 +479,7 @@ def skip_unreadable_post(record): 'ietf.release', 'ietf.review', 'ietf.stats', + 'ietf.status', 'ietf.submit', 'ietf.sync', 'ietf.utils', diff --git a/ietf/static/css/ietf.scss b/ietf/static/css/ietf.scss index 5bd520f041..e2d5cb3959 100644 --- a/ietf/static/css/ietf.scss +++ b/ietf/static/css/ietf.scss @@ -1189,6 +1189,13 @@ blockquote { border-left: solid 1px var(--bs-body-color); } +iframe.status { + background-color:transparent; + border:none; + width:100%; + height:3.5em; +} + .overflow-shadows { transition: box-shadow 0.5s; } diff --git a/ietf/status/__init__.py b/ietf/status/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ietf/status/admin.py b/ietf/status/admin.py new file mode 100644 index 0000000000..f9c4e891a7 --- /dev/null +++ b/ietf/status/admin.py @@ -0,0 +1,19 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +# -*- coding: utf-8 -*- + +from datetime import datetime +from django.contrib import admin +from django.template.defaultfilters import slugify +from .models import Status + +class StatusAdmin(admin.ModelAdmin): + list_display = ['title', 'body', 'active', 'date', 'by', 'page'] + raw_id_fields = ['by'] + + def get_changeform_initial_data(self, request): + date = datetime.now() + return { + "slug": slugify(f"{date.year}-{date.month}-{date.day}-"), + } + +admin.site.register(Status, StatusAdmin) diff --git a/ietf/status/apps.py b/ietf/status/apps.py new file mode 100644 index 0000000000..ba64a41afc --- /dev/null +++ b/ietf/status/apps.py @@ -0,0 +1,9 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +# -*- coding: utf-8 -*- + +from django.apps import AppConfig + + +class StatusConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "ietf.status" diff --git a/ietf/status/migrations/0001_initial.py b/ietf/status/migrations/0001_initial.py new file mode 100644 index 0000000000..5185189496 --- /dev/null +++ b/ietf/status/migrations/0001_initial.py @@ -0,0 +1,75 @@ +# Generated by Django 4.2.13 on 2024-07-21 22:47 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("person", "0002_alter_historicalperson_ascii_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="Status", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("date", models.DateTimeField(default=django.utils.timezone.now)), + ("slug", models.SlugField(unique=True)), + ( + "title", + models.CharField( + help_text="Your site status notification title.", + max_length=255, + verbose_name="Status title", + ), + ), + ( + "body", + models.CharField( + help_text="Your site status notification body.", + max_length=255, + verbose_name="Status body", + ), + ), + ( + "active", + models.BooleanField( + default=True, + help_text="Only active messages will be shown.", + verbose_name="Active?", + ), + ), + ( + "page", + models.TextField( + blank=True, + help_text="More detail shown after people click 'Read more'. If empty no 'read more' will be shown", + null=True, + verbose_name="More detail (markdown)", + ), + ), + ( + "by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="person.person" + ), + ), + ], + options={ + "verbose_name_plural": "statuses", + }, + ), + ] diff --git a/ietf/status/migrations/__init__.py b/ietf/status/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ietf/status/models.py b/ietf/status/models.py new file mode 100644 index 0000000000..b3f97d989e --- /dev/null +++ b/ietf/status/models.py @@ -0,0 +1,24 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +# -*- coding: utf-8 -*- + +from django.utils import timezone +from django.db import models +from django.db.models import ForeignKey + +import debug # pyflakes:ignore + +class Status(models.Model): + name = 'Status' + + date = models.DateTimeField(default=timezone.now) + slug = models.SlugField(blank=False, null=False, unique=True) + title = models.CharField(max_length=255, verbose_name="Status title", help_text="Your site status notification title.") + body = models.CharField(max_length=255, verbose_name="Status body", help_text="Your site status notification body.", unique=False) + active = models.BooleanField(default=True, verbose_name="Active?", help_text="Only active messages will be shown.") + by = ForeignKey('person.Person', on_delete=models.CASCADE) + page = models.TextField(blank=True, null=True, verbose_name="More detail (markdown)", help_text="More detail shown after people click 'Read more'. If empty no 'read more' will be shown") + + def __str__(self): + return "{} {} {} {}".format(self.date, self.active, self.by, self.title) + class Meta: + verbose_name_plural = "statuses" diff --git a/ietf/status/tests.py b/ietf/status/tests.py new file mode 100644 index 0000000000..9c0dd9114e --- /dev/null +++ b/ietf/status/tests.py @@ -0,0 +1,120 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +# -*- coding: utf-8 -*- + +import debug # pyflakes:ignore + +from django.urls import reverse as urlreverse +from ietf.utils.test_utils import TestCase +from ietf.person.models import Person +from ietf.status.models import Status + +class StatusTests(TestCase): + def test_status_latest_html(self): + status = Status.objects.create( + title = "my title 1", + body = "my body 1", + active = True, + by = Person.objects.get(user__username='ad'), + slug = "2024-1-1-my-title-1" + ) + status.save() + + url = urlreverse('ietf.status.views.status_latest_html') + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'my title 1') + self.assertContains(r, 'my body 1') + + status.delete() + + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertNotContains(r, 'my title 1') + self.assertNotContains(r, 'my body 1') + + def test_status_latest_json(self): + url = urlreverse('ietf.status.views.status_latest_json') + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + data = r.json() + self.assertFalse(data["hasMessage"]) + + status = Status.objects.create( + title = "my title 1", + body = "my body 1", + active = True, + by = Person.objects.get(user__username='ad'), + slug = "2024-1-1-my-title-1" + ) + status.save() + + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + data = r.json() + self.assertTrue(data["hasMessage"]) + self.assertEqual(data["title"], "my title 1") + self.assertEqual(data["body"], "my body 1") + self.assertEqual(data["slug"], '2024-1-1-my-title-1') + self.assertEqual(data["url"], '/status/2024-1-1-my-title-1') + + status.delete() + + def test_status_latest_redirect(self): + url = urlreverse('ietf.status.views.status_latest_redirect') + r = self.client.get(url) + # without a Status it should return Not Found + self.assertEqual(r.status_code, 404) + + status = Status.objects.create( + title = "my title 1", + body = "my body 1", + active = True, + by = Person.objects.get(user__username='ad'), + slug = "2024-1-1-my-title-1" + ) + status.save() + + r = self.client.get(url) + # with a Status it should redirect + self.assertEqual(r.status_code, 302) + self.assertEqual(r.headers["Location"], "/status/2024-1-1-my-title-1") + + status.delete() + + def test_status_page(self): + slug = "2024-1-1-my-unique-slug" + r = self.client.get(f'/status/{slug}/') + # without a Status it should return Not Found + self.assertEqual(r.status_code, 404) + + # status without `page` markdown should still 200 + status = Status.objects.create( + title = "my title 1", + body = "my body 1", + active = True, + by = Person.objects.get(user__username='ad'), + slug = slug + ) + status.save() + + r = self.client.get(f'/status/{slug}/') + self.assertEqual(r.status_code, 200) + + status.delete() + + test_string = 'a string that' + status = Status.objects.create( + title = "my title 1", + body = "my body 1", + active = True, + by = Person.objects.get(user__username='ad'), + slug = slug, + page = f"# {test_string}" + ) + status.save() + + r = self.client.get(f'/status/{slug}/') + self.assertEqual(r.status_code, 200) + self.assertContains(r, test_string) + + status.delete() diff --git a/ietf/status/urls.py b/ietf/status/urls.py new file mode 100644 index 0000000000..060c0257e5 --- /dev/null +++ b/ietf/status/urls.py @@ -0,0 +1,12 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +# -*- coding: utf-8 -*- + +from ietf.status import views +from ietf.utils.urls import url + +urlpatterns = [ + url(r"^$", views.status_latest_redirect), + url(r"^latest$", views.status_latest_html), + url(r"^latest.json$", views.status_latest_json), + url(r"(?P.*)", views.status_page) +] diff --git a/ietf/status/views.py b/ietf/status/views.py new file mode 100644 index 0000000000..9037d01dc2 --- /dev/null +++ b/ietf/status/views.py @@ -0,0 +1,46 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +# -*- coding: utf-8 -*- + +from django.urls import reverse as urlreverse +from django.http import HttpResponseRedirect, HttpResponseNotFound, JsonResponse +from ietf.utils import markdown +from django.shortcuts import render, get_object_or_404 +from ietf.status.models import Status + +import debug # pyflakes:ignore + +def get_last_active_status(): + status = Status.objects.filter(active=True).order_by("-date").first() + if status is None: + return { "hasMessage": False } + + context = { + "hasMessage": True, + "id": status.id, + "slug": status.slug, + "title": status.title, + "body": status.body, + "url": urlreverse("ietf.status.views.status_page", kwargs={ "slug": status.slug }), + "date": status.date.isoformat() + } + return context + +def status_latest_html(request): + return render(request, "status/latest.html", context=get_last_active_status()) + +def status_page(request, slug): + sanitised_slug = slug.rstrip("/") + status = get_object_or_404(Status, slug=sanitised_slug) + return render(request, "status/status.html", context={ + 'status': status, + 'status_page_html': markdown.markdown(status.page or ""), + }) + +def status_latest_json(request): + return JsonResponse(get_last_active_status()) + +def status_latest_redirect(request): + context = get_last_active_status() + if context["hasMessage"] == True: + return HttpResponseRedirect(context["url"]) + return HttpResponseNotFound() diff --git a/ietf/templates/base.html b/ietf/templates/base.html index f426d361ce..ceb1d2df08 100644 --- a/ietf/templates/base.html +++ b/ietf/templates/base.html @@ -34,6 +34,7 @@ {% analytical_body_top %} + {% include "base/status.html" %} Skip to main content {% block precontent %}{% endblock %} -
+
{% if request.COOKIES.left_menu == "on" and not hide_menu %}
@@ -114,7 +115,7 @@ {% block content_end %}{% endblock %}
-
+ {% block footer %}