feat: Site status message (#7659)

* Status WIP

* feat: Status

* fix: Status tests

* feat: status redirect

* chore: Status tests

* chore: Status tests

* feat: Status tests

* chore: Status playwright tests

* fix: PR feedback, mostly Vue and copyright dates

* fix: Status model migration tidy up

* chore: Status - one migration

* feat: status on doc/html pages

* chore: Resetting Status migration

* chore: removing unused FieldError

* fix: Update Status test to remove 'by'

* chore: fixing API test to exclude 'status'

* chore: fixing status_page test

* feat: Site Status PR feedback. URL coverage debugging

* Adding ietf.status to Tastypie omitted apps

* feat: Site Status PR feedback

* chore: correct copyright year on newly created files

* chore: repair merge damage

* chore: repair more merge damage

* fix: reconcile the api init refactor with ignoring apps

---------

Co-authored-by: Matthew Holloway <Matthew Holloway>
Co-authored-by: Robert Sparks <rjsparks@nostrum.com>
This commit is contained in:
Matthew Holloway 2024-08-08 06:36:21 +12:00 committed by GitHub
parent 30970749e3
commit e5e6c9bc89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 574 additions and 8 deletions

View file

@ -1,12 +1,13 @@
<template lang="pug">
n-theme
n-message-provider
component(:is='currentComponent', :component-id='props.componentId')
n-notification-provider
n-message-provider
component(:is='currentComponent', :component-id='props.componentId')
</template>
<script setup>
import { defineAsyncComponent, markRaw, onMounted, ref } from 'vue'
import { NMessageProvider } from 'naive-ui'
import { NMessageProvider, NNotificationProvider } from 'naive-ui'
import NTheme from './components/n-theme.vue'
@ -15,6 +16,7 @@ import NTheme from './components/n-theme.vue'
const availableComponents = {
ChatLog: defineAsyncComponent(() => import('./components/ChatLog.vue')),
Polls: defineAsyncComponent(() => import('./components/Polls.vue')),
Status: defineAsyncComponent(() => import('./components/Status.vue'))
}
// PROPS

View file

@ -0,0 +1,80 @@
<script setup>
import { h, onMounted } from 'vue'
import { useNotification } from 'naive-ui'
import { localStorageWrapper } from '../shared/local-storage-wrapper'
import { JSONWrapper } from '../shared/json-wrapper'
import { STATUS_STORAGE_KEY, generateStatusTestId } from '../shared/status-common'
const getDismissedStatuses = () => {
const jsonString = localStorageWrapper.getItem(STATUS_STORAGE_KEY)
const jsonValue = JSONWrapper.parse(jsonString, [])
if(Array.isArray(jsonValue)) {
return jsonValue
}
return []
}
const dismissStatus = (id) => {
const dissmissed = [id, ...getDismissedStatuses()]
localStorageWrapper.setItem(STATUS_STORAGE_KEY, JSONWrapper.stringify(dissmissed))
return true
}
let notificationInstances = {} // keyed by status.id
let notification
const pollStatusAPI = () => {
fetch('/status/latest.json')
.then(resp => resp.json())
.then(status => {
if(status === null || status.hasMessage === false) {
console.debug("No status message")
return
}
const dismissedStatuses = getDismissedStatuses()
if(dismissedStatuses.includes(status.id)) {
console.debug(`Not showing site status ${status.id} because it was already dismissed. Dismissed Ids:`, dismissedStatuses)
return
}
const isSameStatusPage = Boolean(document.querySelector(`[data-status-id="${status.id}"]`))
if(isSameStatusPage) {
console.debug(`Not showing site status ${status.id} because we're on the target page`)
return
}
if(notificationInstances[status.id]) {
console.debug(`Not showing site status ${status.id} because it's already been displayed`)
return
}
notificationInstances[status.id] = notification.create({
title: status.title,
content: status.body,
meta: `${status.date}`,
action: () =>
h(
'a',
{
'data-testid': generateStatusTestId(status.id),
href: status.url,
'aria-label': `Read more about ${status.title}`
},
"Read more"
),
onClose: () => {
return dismissStatus(status.id)
}
})
})
.catch(e => {
console.error(e)
})
}
onMounted(() => {
notification = useNotification()
pollStatusAPI(notification)
})
</script>

View file

@ -12,6 +12,7 @@
<link href="https://static.ietf.org/fonts/noto-sans-mono/import.css" rel="stylesheet">
</head>
<body>
<div class="vue-embed" data-component="Status"></div>
<div class="pt-3 container-fluid">
<div class="row">
<div class="col mx-lg-3" id="content">
@ -20,5 +21,6 @@
</div>
</div>
<script type="module" src="./main.js"></script>
<script type="module" src="./embedded.js"></script>
</body>
</html>

View file

@ -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)
}
},
}

View file

@ -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;
},
}

View file

@ -0,0 +1,5 @@
// Used in Playwright Status and components
export const STATUS_STORAGE_KEY = "status-dismissed"
export const generateStatusTestId = (id) => `status-${id}`

View file

@ -21,14 +21,14 @@ from tastypie.bundle import Bundle
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:

View file

@ -48,6 +48,7 @@ OMITTED_APPS = (
'ietf.secr.meetings',
'ietf.secr.proceedings',
'ietf.ipr',
'ietf.status',
)
class CustomApiTests(TestCase):

View file

@ -479,6 +479,7 @@ INSTALLED_APPS = [
'ietf.release',
'ietf.review',
'ietf.stats',
'ietf.status',
'ietf.submit',
'ietf.sync',
'ietf.utils',

View file

@ -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;
}

0
ietf/status/__init__.py Normal file
View file

19
ietf/status/admin.py Normal file
View file

@ -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)

9
ietf/status/apps.py Normal file
View file

@ -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"

View file

@ -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",
},
),
]

View file

24
ietf/status/models.py Normal file
View file

@ -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"

120
ietf/status/tests.py Normal file
View file

@ -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()

12
ietf/status/urls.py Normal file
View file

@ -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<slug>.*)", views.status_page)
]

46
ietf/status/views.py Normal file
View file

@ -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()

View file

@ -34,6 +34,7 @@
<body {% block bodyAttrs %}{% endblock %} class="navbar-offset position-relative"
data-group-menu-data-url="{% url 'ietf.group.views.group_menu_data' %}">
{% analytical_body_top %}
{% include "base/status.html" %}
<a class="visually-hidden visually-hidden-focusable" href="#content">Skip to main content</a>
<nav class="navbar navbar-expand-lg fixed-top {% if server_mode and server_mode != "production" %}bg-danger-subtle{% else %}bg-secondary-subtle{% endif %}">
<div class="container-fluid">
@ -85,7 +86,7 @@
</div>
</nav>
{% block precontent %}{% endblock %}
<div class="pt-3 container-fluid">
<main class="pt-3 container-fluid" id="main">
<div class="row">
{% if request.COOKIES.left_menu == "on" and not hide_menu %}
<div class="d-none d-md-block bg-light-subtle py-3 leftmenu small">
@ -114,7 +115,7 @@
{% block content_end %}{% endblock %}
</div>
</div>
</div>
</main>
{% block footer %}
<footer class="col-md-12 col-sm-12 border-top mt-5 py-5 bg-light-subtle text-center position-sticky">
<a href="https://www.ietf.org/" class="p-3">IETF</a>

View file

@ -0,0 +1,2 @@
<noscript><iframe class="status" title="Site status" src="/status/latest"></iframe></noscript>
<div class="vue-embed" data-component="Status"></div>

View file

@ -4,6 +4,7 @@
{% load origin %}
{% load static %}
{% load ietf_filters textfilters %}
{% load django_vite %}
{% origin %}
<html data-bs-theme="auto" lang="en">
<head>
@ -28,6 +29,7 @@
{% if html %}
<link rel="stylesheet" href="{% static 'ietf/css/document_html_txt.css' %}">
{% endif %}
{% vite_asset 'client/embedded.js' %}
<script src="{% static 'ietf/js/document_html.js' %}"></script>
<script src="{% static 'ietf/js/theme.js' %}"></script>
{% endif %}
@ -51,6 +53,7 @@
</head>
<body>
{% analytical_body_top %}
{% include "base/status.html" %}
<div class="btn-toolbar sidebar-toolbar position-fixed top-0 end-0 m-2 m-lg-3 d-print-none">
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm me-1 dropdown-toggle d-flex align-items-center"

View file

@ -0,0 +1,18 @@
{% load origin %}
{% load ietf_filters static %}
{% origin %}
<meta name="color-scheme" content="light dark">
<style type="text/css">
{# this template doesn't inherit from base.html so it has its own styles #}
body {background:transparent;font-family:sans-serif}
h1{font-size:18px;display:inline}
p{font-size:14px;display:inline}
.unimportant{opacity:0.6}
</style>
<!-- This page is intended to be iframed, and is only for non-JavaScript browsers. -->
{% if title %}
<h1>{{ title }}</h1>
<p>{{ body }} <a href="{{ url }}" target="_top" aria-label="read more about {{title}}">read more</a><br /><span class="unimportant">{{ date }}</span></p>
{% else %}
<p class="unimportant">No site status message.</p>
{% endif %}

View file

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% load origin %}
{% load ietf_filters static %}
{% block content %}
{% origin %}
<h1 data-status-id="{{ status.id }}">
{% block title %} {{ status.title }} {% endblock %}
{% if status.active == False %}
<span class="badge bg-secondary">inactive</span>
{% endif %}
</h1>
<div>
{{ status_page_html }}
</div>
{% endblock %}

View file

@ -61,6 +61,7 @@ urlpatterns = [
url(r'^sitemap-(?P<section>.+).xml$', sitemap_views.sitemap, {'sitemaps': sitemaps}),
url(r'^sitemap.xml$', sitemap_views.index, { 'sitemaps': sitemaps}),
url(r'^stats/', include('ietf.stats.urls')),
url(r'^status/', include('ietf.status.urls')),
url(r'^stream/', include(stream_urls)),
url(r'^submit/', include('ietf.submit.urls')),
url(r'^sync/', include('ietf.sync.urls')),

View file

@ -0,0 +1,61 @@
const {
test,
expect
} = require('@playwright/test')
const { STATUS_STORAGE_KEY, generateStatusTestId } = require('../../../client/shared/status-common.js')
test.describe('site status', () => {
const noStatus = {
hasMessage: false
}
const status1 = {
hasMessage: true,
id: 1,
slug: '2024-7-9fdfdf-sdfsdf',
title: 'My status title',
body: 'My status body',
url: '/status/2024-7-9fdfdf-sdfsdf',
date: '2024-07-09T07:05:13+00:00',
by: 'Exile is a cool Amiga game'
}
test('Renders server status as Notification', async ({ page }) => {
await page.route('/status/latest.json', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(status1)
})
})
await page.goto('/')
await expect(page.getByTestId(generateStatusTestId(status1.id)), 'should have status').toHaveCount(1)
})
test("Doesn't render dismissed server statuses", async ({ page }) => {
await page.route('/status/latest.json', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(status1)
})
})
await page.goto('/')
await page.evaluate(({ key, value }) => localStorage.setItem(key, value), { key: STATUS_STORAGE_KEY, value: JSON.stringify([status1.id]) })
await expect(page.getByTestId(generateStatusTestId(status1.id)), 'should have status').toHaveCount(0)
})
test('Handles no server status', async ({ page }) => {
await page.route('/status/latest.json', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(noStatus)
})
})
await page.goto('/')
await expect(page.getByTestId(generateStatusTestId(status1.id)), 'should have status').toHaveCount(0)
})
})