Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 5.2.14 on 2026-06-10 17:14

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('cms', '0025_advisoryindexpage_bulletinindexpage_and_more'),
]

operations = [
migrations.AddField(
model_name='advisory',
name='last_notified_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='bulletin',
name='last_notified_at',
field=models.DateTimeField(blank=True, null=True),
),
]
7 changes: 5 additions & 2 deletions src/backend/apps/cms/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ def rendered_body(self):
# Geo fields
geometry = models.MultiPolygonField()

last_notified_at = models.DateTimeField(null=True, blank=True)

# Editor panels configuration
content_panels = [
FieldPanel("title", help_text=HelpText.GENERIC_TITLE),
Expand All @@ -209,7 +211,7 @@ def rendered_body(self):
FieldPanel("body", help_text=HelpText.GENERIC_BODY),
FieldPanel('created_at', read_only=True, heading="Created"),
FieldPanel('first_published_at', read_only=True, heading="Published"),
FieldPanel('last_published_at', read_only=True, heading="Updated"),
FieldPanel('last_notified_at', read_only=True, heading="Updated"),
]
promote_panels = []

Expand Down Expand Up @@ -252,6 +254,7 @@ class Bulletin(Page, BaseModel):
body = StreamField(RichContent())
image = models.ForeignKey(Image, on_delete=models.SET_NULL, null=True, blank=False)
image_alt_text = models.CharField(max_length=125, default='', blank=False)
last_notified_at = models.DateTimeField(null=True, blank=True)

def rendered_body(self):
blocks = [wagtailcore_tags.richtext(block.render()) for block in self.body]
Expand Down Expand Up @@ -280,7 +283,7 @@ def save(self, *args, **kwargs):
FieldPanel("body", help_text=HelpText.GENERIC_BODY),
FieldPanel('created_at', read_only=True, heading="Created"),
FieldPanel('first_published_at', read_only=True, heading="Published"),
FieldPanel('last_published_at', read_only=True, heading="Updated"),
FieldPanel('last_notified_at', read_only=True, heading="Updated"),
]
promote_panels = []

Expand Down
30 changes: 30 additions & 0 deletions src/backend/apps/cms/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
from django.template.loader import render_to_string
from django.views.decorators.csrf import csrf_exempt
from rest_framework import viewsets
from django.contrib import messages
from django.shortcuts import redirect
from wagtail.models import Page


class CMSViewSet(viewsets.ReadOnlyModelViewSet):
Expand Down Expand Up @@ -146,3 +149,30 @@
return render(request, 'wagtailadmin/access_denied.html', context={
"is_non_idir_login": True,
})


def publish_minor_update(request, page_id):
if request.method != "POST":
return redirect(f"/drivebc-cms/pages/{page_id}/edit/")

Check warning

Code scanning / CodeQL

URL redirection from remote source Medium

Untrusted URL redirection depends on a
user-provided value
.

page = Page.objects.get(pk=page_id).specific
latest_revision = page.get_latest_revision()

if latest_revision:
# Snapshot the current last_notified_at before publishing
last_notified_at = page.last_notified_at

# Publish the revision (updates content but triggers after_publish_page)
latest_revision.publish(changed=False)

# Restore last_notified_at and also revert last_published_at
# so the "Updated" panel field doesn't change
Page.objects.filter(pk=page.pk).update(
last_published_at=last_notified_at
)
type(page).objects.filter(pk=page.pk).update(
last_notified_at=last_notified_at
)

messages.success(request, f'"{page.title}" published as a minor update.')
return redirect(f"/drivebc-cms/pages/{page_id}/edit/")

Check warning

Code scanning / CodeQL

URL redirection from remote source Medium

Untrusted URL redirection depends on a
user-provided value
.
74 changes: 71 additions & 3 deletions src/backend/apps/cms/wagtail_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from wagtail_modeladmin.options import ModelAdmin, modeladmin_register

from .models import Advisory, Bulletin, SubPage, get_or_create_advisory_index, get_or_create_bulletin_index
from .views import access_requested
from .views import access_requested, publish_minor_update

# from wagtail.admin.ui.components import ActionMenuItem
from wagtail.admin.action_menu import ActionMenuItem
Expand All @@ -28,14 +28,21 @@

@hooks.register("after_publish_page")
def post_edit_hook(request, page):
# Only process published advisory pages
from django.utils import timezone

if page.specific_class == Advisory:
try:
Advisory.objects.filter(pk=page.pk).update(last_notified_at=timezone.now())
send_advisory_notifications(page.id)

except Exception:
logger.error(request, 'There was a problem sending an advisory notification')

elif page.specific_class == Bulletin:
try:
Bulletin.objects.filter(pk=page.pk).update(last_notified_at=timezone.now())
except Exception:
logger.error(request, 'There was a problem updating bulletin notification timestamp')


@hooks.register("insert_global_admin_css")
def insert_global_admin_css():
Expand Down Expand Up @@ -207,6 +214,49 @@ def render_html(self, parent_context):
preview_url,
)

class PublishMinorUpdateMenuItem(ActionMenuItem):
label = "Publish minor update"
name = "publish-minor-update"
icon_name = "upload"
order = 15

def render_html(self, parent_context):
context = parent_context.get("context", parent_context)
page = context.get("page")

if not page:
return ""

url = reverse("cms-publish-minor-update", args=[page.pk])

if isinstance(page.specific, Advisory):
redirect_url = "/drivebc-cms/cms/advisory/"
elif isinstance(page.specific, Bulletin):
redirect_url = "/drivebc-cms/cms/bulletin/"
else:
redirect_url = "/drivebc-cms/"

return format_html(
"""
<li class="action-menu__item">
<button
type="button"
class="action button"
style="width: 100%; text-align: left;"
onclick="fetch('{}', {{
method: 'POST',
headers: {{'X-CSRFToken': document.cookie.match(/csrftoken=([^;]+)/)[1]}},
}}).then(() => window.location.href = '{}'); return false;"
>
<svg class="icon icon-upload icon" aria-hidden="true"><use href="#icon-upload"></use></svg>
Publish minor update
</button>
</li>
""",
url,
redirect_url,
)


@hooks.register("register_page_action_menu_item")
def register_copy_preview_button():
Expand Down Expand Up @@ -281,3 +331,21 @@ def move_new_advisory_to_top(request, page):
if page.specific_class == Advisory:
parent = page.get_parent()
page.move(parent, pos="first-child")

@hooks.register("register_page_action_menu_item")
def register_publish_minor_update():
return PublishMinorUpdateMenuItem(order=25)

@hooks.register("construct_page_action_menu")
def customize_page_action_menu(menu_items, request, context):
for item in menu_items:
if item.name == "action-publish":
item.label = "Publish with notifications"


@hooks.register('register_admin_urls')
def add_access_requested_url():
return [
path('access-requested', access_requested, name='cms-access-requested'),
path('publish-minor-update/<int:page_id>/', publish_minor_update, name='cms-publish-minor-update'),
]
47 changes: 36 additions & 11 deletions src/frontend/src/Components/advisories/AdvisoriesList.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// React
import React, { useContext } from 'react';
import React, { useContext, useRef } from 'react';

// Navigation
import { useNavigate } from 'react-router-dom';
Expand Down Expand Up @@ -30,7 +30,8 @@ export default function AdvisoriesList(props) {
const navigate = useNavigate();

// Props
const { advisories, showDescription, showTimestamp, showPublished, isAdvisoriesListPage, showLoader } = props;
const { advisories, showDescription, showTimestamp, showPublished, isAdvisoriesListPage, showLoader, trackedAdvisories = {}, advisoryRefs } = props;
const { dismissHighlight } = props;
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed

function handleClick(advisory, keyEvent) {
// Ignore key presses that aren't enter or space
Expand All @@ -39,6 +40,12 @@ export default function AdvisoriesList(props) {
}

trackEvent('click', 'advisories-list', 'Advisory', advisory.title, advisory.teaser);
// dismissedHighlightsRef.current.add(advisory.id);
// sessionStorage.setItem(
// 'dismissedHighlights',
// JSON.stringify([...dismissedHighlightsRef.current])
// );
dismissHighlight(advisory.id); // use parent's function
navigate(`/advisories/${advisory.slug}`);
}

Expand All @@ -50,7 +57,12 @@ export default function AdvisoriesList(props) {
{!!sortedAdvisories && sortedAdvisories.map((advisory, index) => {
if (isAdvisoriesListPage) {
return (
<div className="advisory-li" key={advisory.id}>
<div
className={`advisory-li${trackedAdvisories[advisory.id]?.highlight ? ' highlighted' : ''}`}
key={advisory.id}
data-key={advisory.id}
ref={(el) => { if (advisoryRefs) advisoryRefs.current[advisory.id] = el; }}
>
<div className="advisory-li__content">
<div className="advisory-li__content__partition advisory-li-title-container">
<div className="advisory-li-title link-div"
Expand All @@ -65,8 +77,11 @@ export default function AdvisoriesList(props) {
<Skeleton width={260} height={10} className="hidden-mobile" /> :

<div className="timestamp-container">
<span className="advisory-li-state">{advisory.first_published_at != advisory.last_published_at ? "Updated" : "Published" }</span>
<FriendlyTime date={advisory.latest_revision_created_at} />
<span className="advisory-li-state">{advisory.last_notified_at != advisory.first_published_at ? "Updated" : "Published" }</span>
<FriendlyTime date={advisory.last_notified_at} />
{trackedAdvisories[advisory.id]?.highlight &&
<div className="updated-pill">Updated</div>
}
</div>
}
</div>
Expand Down Expand Up @@ -95,8 +110,11 @@ export default function AdvisoriesList(props) {
<Skeleton width={320} height={10} className='hidden-desktop' /> :

<div className="advisory-li__content__partition timestamp-container timestamp-container--mobile">
<span className="advisory-li-state">{advisory.first_published_at != advisory.last_published_at ? "Updated" : "Published"}</span>
<FriendlyTime date={advisory.latest_revision_created_at}/>
<span className="advisory-li-state">{advisory.last_notified_at != advisory.first_published_at ? "Updated" : "Published"}</span>
<FriendlyTime date={advisory.last_notified_at}/>
{trackedAdvisories[advisory.id]?.highlight &&
<div className="updated-pill">Updated</div>
}
</div>
}

Expand All @@ -122,7 +140,8 @@ export default function AdvisoriesList(props) {

} else {
return (
<div className={`advisory-li link-div ${!cmsContext.readAdvisories.includes(advisory.id.toString() + '-' + advisory.live_revision.toString()) ? 'unread' : ''}`}
// <div className={`advisory-li link-div ${!cmsContext.readAdvisories.includes(advisory.id.toString() + '-' + advisory.live_revision.toString()) ? 'unread' : ''}`}
<div className={`advisory-li link-div ${!cmsContext.readAdvisories.includes(advisory.id.toString() + '-' + advisory.last_notified_at?.toString()) ? 'unread' : ''}`}
key={advisory.id}
onClick={() => handleClick(advisory)}
onKeyDown={(keyEvent) => handleClick(advisory, keyEvent)}
Expand All @@ -139,8 +158,11 @@ export default function AdvisoriesList(props) {
{(showTimestamp && showPublished) &&
<div className="timestamp-container">
{!cmsContext.readAdvisories.includes(advisory.id.toString() + '-' + advisory.live_revision.toString()) && <div className="unread-display"></div>}
<span className="advisory-li-state">{advisory.first_published_at != advisory.last_published_at ? "Updated" : "Published" }</span>
<FriendlyTime date={advisory.latest_revision_created_at} />
<span className="advisory-li-state">{advisory.last_notified_at != advisory.first_published_at ? "Updated" : "Published" }</span>
<FriendlyTime date={advisory.last_notified_at} />
{trackedAdvisories[advisory.id]?.highlight &&
<div className="updated-pill">Updated</div>
}
</div>
}

Expand All @@ -165,7 +187,10 @@ export default function AdvisoriesList(props) {
{showTimestamp &&
<div className="timestamp-container timestamp-container--mobile">
<span className="advisory-li-state">{advisory.first_published_at != advisory.last_published_at ? "Updated" : "Published" }</span>
<FriendlyTime date={advisory.latest_revision_created_at} />
<FriendlyTime date={advisory.last_notified_at} />
{trackedAdvisories[advisory.id]?.highlight &&
<div className="updated-pill">Updated</div>
}
</div>
}
</div>
Expand Down
29 changes: 18 additions & 11 deletions src/frontend/src/Components/bulletins/BulletinsList.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,16 @@ import Skeleton from 'react-loading-skeleton';
// Static files
import logo from '../../images/dbc-logo--white.svg';

export default function Bulletins(props) {
// State, props and context
const { bulletins, showLoader } = props;
export default function BulletinsList(props) {
// Props
const { bulletins, showLoader, trackedBulletins = {}, bulletinRefs, dismissHighlight } = props;

// Navigation
const navigate = useNavigate();

function handleClick(bulletin) {
trackEvent('click', 'bulletins-list', 'Bulletin', bulletin.title, bulletin.teaser);
if (dismissHighlight) dismissHighlight(bulletin.id);
navigate(`/bulletins/${bulletin.slug}`);
}

Expand All @@ -33,9 +34,14 @@ export default function Bulletins(props) {
// Rendering
return (
<ul className='bulletins-list'>
{!!sortedBulletins && sortedBulletins.map((bulletin, index) => {
{!!sortedBulletins && sortedBulletins.map((bulletin) => {
return (
<li className='bulletin-li' key={bulletin.id} onClick={() => handleClick(bulletin)}
<li
className={`bulletin-li${trackedBulletins[bulletin.id]?.highlight ? ' highlighted' : ''}`}
key={bulletin.id}
data-key={bulletin.id}
ref={(el) => { if (bulletinRefs) bulletinRefs.current[bulletin.id] = el; }}
onClick={() => handleClick(bulletin)}
onKeyDown={(keyEvent) => {
if (['Enter', 'NumpadEnter'].includes(keyEvent.key)) {
handleClick(bulletin);
Expand All @@ -45,7 +51,6 @@ export default function Bulletins(props) {
<div className='bulletin-li-title-container' tabIndex={0}>
{showLoader ?
<p><Skeleton height={30} /></p> :

<h2 className='bulletin-li-title'>{bulletin.title}</h2>
}

Expand All @@ -54,18 +59,21 @@ export default function Bulletins(props) {
<Skeleton height={10} />
<Skeleton height={10} />
</p> :

(bulletin.teaser &&
<div className="bulletin-li-body">{bulletin.teaser}</div>
)
}

{showLoader ?
<p><Skeleton width={150} height={10} /></p> :

<div className='timestamp-container'>
<span className='bulletin-li-state'>{bulletin.first_published_at != bulletin.last_published_at ? 'Updated' : 'Published' }</span>
<FriendlyTime date={bulletin.latest_revision_created_at} />
<span className='bulletin-li-state'>
{bulletin.first_published_at != bulletin.last_notified_at ? 'Updated' : 'Published'}
</span>
<FriendlyTime date={bulletin.last_notified_at} />
{trackedBulletins[bulletin.id]?.highlight &&
<div className="updated-pill">Updated</div>
}
</div>
}
</div>
Expand All @@ -74,7 +82,6 @@ export default function Bulletins(props) {
<div className={bulletin.image_url ? 'bulletin-li-thumbnail' : 'bulletin-li-thumbnail-default'}>
{showLoader ?
<Skeleton width={320} height={200} /> :

<img className='thumbnail-logo' src={bulletin.image_url ? bulletin.image_url : logo} alt={bulletin.image_alt_text} />
}
</div>
Expand Down
Loading
Loading