Skip to content

Commit 43b2238

Browse files
Maffoochclaude
andauthored
Add centralized banner system with OS messaging support (#14708)
* Add OSS subscriber for Open Source Messaging banner Fetches a markdown message from the DaaS-published GCS bucket, renders the bleached headline and optional expanded section through the existing additional_banners template loop. Cached for 1h; any fetch/parse failure silently yields no banner. No Django settings introduced — disabling the banner requires forking. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Enable nl2br in expanded markdown and fold module into dojo.announcement Single newlines in the expanded body now render as <br>, so authored markdown lays out multi-line. Module folded into the existing dojo/announcement/ app and test patch paths updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Use <button> for banner toggle and clean focus styling Anchor-based toggle picked up Bootstrap alert link styles and a lingering focus outline after click, which showed as a stray glyph next to the caret. A plain <button type="button"> avoids link decoration entirely; focus outline and transition are also dropped so the caret flips instantly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Replace DD_CLOUD_BANNER with centralized additional_banners system Migrate all promotional messaging to the additional_banners context processor pattern. Product announcements now store banners in the session for rendering via the unified template loop. Each banner carries a source field (os, product_announcement) so downstream repos can filter by origin. - Remove DD_CREATE_CLOUD_BANNER setting and env var entirely - Repurpose ProductAnnouncementManager to use session-based banners - Remove evaluate_pro_proposition celery task and beat schedule - Remove create_announcement_banner from initialization command - Simplify announcement signal to remove cloud-specific logic - Add SHOW_PLG_LINK context variable for PLG menu item control - Rename os-banner-* CSS classes to generic banner-* classes - Add data-source attribute to banner template markup - Switch OS message bucket URL from dev to prod - Add 52 tests covering context processor and product announcements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove unused import and add docstring to TestBannerDictSchema * Fix ruff FURB189: use UserDict instead of dict subclass Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 412570f commit 43b2238

File tree

11 files changed

+670
-109
lines changed

11 files changed

+670
-109
lines changed

dojo/announcement/os_message.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import logging
2+
3+
import bleach
4+
import markdown
5+
import requests
6+
from django.core.cache import cache
7+
8+
logger = logging.getLogger(__name__)
9+
10+
BUCKET_URL = "https://storage.googleapis.com/defectdojo-os-messages-prod/open_source_message.md"
11+
CACHE_SECONDS = 3600
12+
HTTP_TIMEOUT_SECONDS = 2
13+
CACHE_KEY = "os_message:v1"
14+
15+
INLINE_TAGS = ["strong", "em", "a"]
16+
INLINE_ATTRS = {"a": ["href", "title"]}
17+
18+
# Keep BLOCK_TAGS / BLOCK_ATTRS in sync with the DaaS publisher's
19+
# MARKDOWNIFY["default"]["WHITELIST_TAGS"] / WHITELIST_ATTRS so previews
20+
# on DaaS and rendering in OSS stay byte-identical.
21+
BLOCK_TAGS = [
22+
"p", "ul", "ol", "li", "a", "strong", "em", "code", "pre",
23+
"blockquote", "h2", "h3", "h4", "hr", "br", "b", "i",
24+
"abbr", "acronym",
25+
]
26+
BLOCK_ATTRS = {
27+
"a": ["href", "title"],
28+
"abbr": ["title"],
29+
"acronym": ["title"],
30+
}
31+
32+
_MISS = object()
33+
34+
35+
def fetch_os_message():
36+
cached = cache.get(CACHE_KEY, default=_MISS)
37+
if cached is not _MISS:
38+
return cached
39+
40+
try:
41+
response = requests.get(BUCKET_URL, timeout=HTTP_TIMEOUT_SECONDS)
42+
except Exception:
43+
logger.debug("os_message: fetch failed", exc_info=True)
44+
cache.set(CACHE_KEY, None, CACHE_SECONDS)
45+
return None
46+
47+
if response.status_code != 200 or not response.text.strip():
48+
cache.set(CACHE_KEY, None, CACHE_SECONDS)
49+
return None
50+
51+
cache.set(CACHE_KEY, response.text, CACHE_SECONDS)
52+
return response.text
53+
54+
55+
def _strip_outer_p(html):
56+
stripped = html.strip()
57+
if stripped.startswith("<p>") and stripped.endswith("</p>"):
58+
return stripped[3:-4]
59+
return stripped
60+
61+
62+
def parse_os_message(text):
63+
lines = text.splitlines()
64+
65+
headline_source = None
66+
body_start = None
67+
for index, line in enumerate(lines):
68+
if line.startswith("# "):
69+
headline_source = line[2:].strip()
70+
body_start = index + 1
71+
break
72+
73+
if not headline_source:
74+
return None
75+
76+
headline_source = headline_source[:100]
77+
headline_rendered = markdown.markdown(headline_source)
78+
headline_cleaned = bleach.clean(
79+
headline_rendered,
80+
tags=INLINE_TAGS,
81+
attributes=INLINE_ATTRS,
82+
strip=True,
83+
)
84+
headline_html = _strip_outer_p(headline_cleaned)
85+
86+
expanded_html = None
87+
expanded_marker = "## Expanded Message"
88+
expanded_body_lines = None
89+
for offset, line in enumerate(lines[body_start:], start=body_start):
90+
if line.strip() == expanded_marker:
91+
expanded_body_lines = lines[offset + 1:]
92+
break
93+
94+
if expanded_body_lines is not None:
95+
expanded_source = "\n".join(expanded_body_lines).strip()
96+
if expanded_source:
97+
expanded_rendered = markdown.markdown(
98+
expanded_source,
99+
extensions=["extra", "fenced_code", "nl2br"],
100+
)
101+
expanded_html = bleach.clean(
102+
expanded_rendered,
103+
tags=BLOCK_TAGS,
104+
attributes=BLOCK_ATTRS,
105+
strip=True,
106+
)
107+
108+
return {"message": headline_html, "expanded_html": expanded_html}
109+
110+
111+
def get_os_banner():
112+
try:
113+
text = fetch_os_message()
114+
if not text:
115+
return None
116+
return parse_os_message(text)
117+
except Exception:
118+
logger.debug("os_message: get_os_banner failed", exc_info=True)
119+
return None

dojo/announcement/signals.py

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from django.conf import settings
21
from django.db.models.signals import post_save
32
from django.dispatch import receiver
43

@@ -7,22 +6,11 @@
76

87
@receiver(post_save, sender=Dojo_User)
98
def add_announcement_to_new_user(sender, instance, **kwargs):
10-
announcements = Announcement.objects.all()
11-
if announcements.count() > 0:
12-
dojo_user = Dojo_User.objects.get(id=instance.id)
13-
announcement = announcements.first()
14-
cloud_announcement = (
15-
"DefectDojo Pro Cloud and On-Premise Subscriptions Now Available!"
16-
in announcement.message
9+
announcement = Announcement.objects.first()
10+
if announcement is not None:
11+
UserAnnouncement.objects.get_or_create(
12+
user=instance, announcement=announcement,
1713
)
18-
if not cloud_announcement or settings.CREATE_CLOUD_BANNER:
19-
user_announcements = UserAnnouncement.objects.filter(
20-
user=dojo_user, announcement=announcement,
21-
)
22-
if user_announcements.count() == 0:
23-
UserAnnouncement.objects.get_or_create(
24-
user=dojo_user, announcement=announcement,
25-
)
2614

2715

2816
@receiver(post_save, sender=Announcement)

dojo/context_processors.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
from django.conf import settings
66
from django.contrib import messages
77

8+
from dojo.announcement.os_message import get_os_banner
89
from dojo.labels import get_labels
910
from dojo.models import Alerts, System_Settings, UserAnnouncement
1011

1112

1213
def globalize_vars(request):
1314
# return the value you want as a dictionnary. you may add multiple values in there.
14-
return {
15+
context = {
1516
"SHOW_LOGIN_FORM": settings.SHOW_LOGIN_FORM,
1617
"FORGOT_PASSWORD": settings.FORGOT_PASSWORD,
1718
"FORGOT_USERNAME": settings.FORGOT_USERNAME,
@@ -35,11 +36,32 @@ def globalize_vars(request):
3536
"DOCUMENTATION_URL": settings.DOCUMENTATION_URL,
3637
"API_TOKENS_ENABLED": settings.API_TOKENS_ENABLED,
3738
"API_TOKEN_AUTH_ENDPOINT_ENABLED": settings.API_TOKEN_AUTH_ENDPOINT_ENABLED,
38-
"CREATE_CLOUD_BANNER": settings.CREATE_CLOUD_BANNER,
39+
"SHOW_PLG_LINK": True,
3940
# V3 Feature Flags
4041
"V3_FEATURE_LOCATIONS": settings.V3_FEATURE_LOCATIONS,
4142
}
4243

44+
additional_banners = []
45+
46+
if (os_banner := get_os_banner()) is not None:
47+
additional_banners.append({
48+
"source": "os",
49+
"message": os_banner["message"],
50+
"style": "info",
51+
"url": "",
52+
"link_text": "",
53+
"expanded_html": os_banner["expanded_html"],
54+
})
55+
56+
if hasattr(request, "session"):
57+
for banner in request.session.pop("_product_banners", []):
58+
additional_banners.append(banner)
59+
60+
if additional_banners:
61+
context["additional_banners"] = additional_banners
62+
63+
return context
64+
4365

4466
def bind_system_settings(request):
4567
"""Load system settings and display warning if there's a database error."""

dojo/management/commands/complete_initialization.py

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from django.db.utils import ProgrammingError
1515

1616
from dojo.auditlog import configure_pghistory_triggers
17-
from dojo.models import Announcement, Dojo_User, UserAnnouncement
1817

1918

2019
class Command(BaseCommand):
@@ -38,13 +37,11 @@ def handle(self, *args: Any, **options: Any) -> None:
3837

3938
if self.admin_user_exists():
4039
self.stdout.write("Admin user already exists; skipping first-boot setup")
41-
self.create_announcement_banner()
4240
self.initialize_data()
4341
return
4442

4543
self.ensure_admin_secrets()
4644
self.first_boot_setup()
47-
self.create_announcement_banner()
4845
self.initialize_data()
4946

5047
# ------------------------------------------------------------------
@@ -58,29 +55,6 @@ def initialize_data(self) -> None:
5855
self.stdout.write("Initializing non-standard permissions")
5956
call_command("initialize_permissions")
6057

61-
def create_announcement_banner(self) -> None:
62-
if os.getenv("DD_CREATE_CLOUD_BANNER"):
63-
return
64-
65-
self.stdout.write("Creating announcement banner")
66-
67-
announcement, _ = Announcement.objects.get_or_create(id=1)
68-
announcement.message = (
69-
'<a href="https://cloud.defectdojo.com/accounts/onboarding/plg_step_1" '
70-
'target="_blank">'
71-
"DefectDojo Pro Cloud and On-Premise Subscriptions Now Available! "
72-
"Create an account to try Pro for free!"
73-
"</a>"
74-
)
75-
announcement.dismissable = True
76-
announcement.save()
77-
78-
for user in Dojo_User.objects.all():
79-
UserAnnouncement.objects.get_or_create(
80-
user=user,
81-
announcement=announcement,
82-
)
83-
8458
# ------------------------------------------------------------------
8559
# Auditlog consistency
8660
# ------------------------------------------------------------------

dojo/product_announcements.py

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11

22
import logging
33

4-
from django.conf import settings
5-
from django.contrib import messages
64
from django.http import HttpRequest, HttpResponse
75
from django.utils.safestring import mark_safe
86
from django.utils.translation import gettext_lazy as _
@@ -30,12 +28,8 @@ def __init__(
3028
response_data: dict | None = None,
3129
**kwargs: dict,
3230
):
33-
"""Skip all this if the CREATE_CLOUD_BANNER is not set"""
34-
if not settings.CREATE_CLOUD_BANNER:
35-
return
36-
# Fill in the vars if the were supplied correctly
3731
if request is not None and isinstance(request, HttpRequest):
38-
self._add_django_message(
32+
self._add_session_banner(
3933
request=request,
4034
message=mark_safe(f"{self.base_message} {self.ui_outreach}"),
4135
)
@@ -51,18 +45,21 @@ def __init__(
5145
msg = "At least one of request, response, or response_data must be supplied"
5246
raise ValueError(msg)
5347

54-
def _add_django_message(self, request: HttpRequest, message: str):
55-
"""Add a message to the UI"""
48+
def _add_session_banner(self, request: HttpRequest, message: str):
49+
"""Store a banner in the session for rendering via additional_banners."""
5650
try:
57-
messages.add_message(
58-
request=request,
59-
level=messages.INFO,
60-
message=_(message),
61-
extra_tags="alert-info",
62-
)
51+
banners = request.session.get("_product_banners", [])
52+
banners.append({
53+
"source": "product_announcement",
54+
"message": str(_(message)),
55+
"style": "info",
56+
"url": "",
57+
"link_text": "",
58+
"expanded_html": None,
59+
})
60+
request.session["_product_banners"] = banners
6361
except Exception:
64-
# make sure we catch any exceptions that might happen: https://github.com/DefectDojo/django-DefectDojo/issues/14041
65-
logger.exception(f"Error adding message to Django: {message}")
62+
logger.exception(f"Error storing product announcement banner: {message}")
6663

6764
def _add_api_response_key(self, message: str, data: dict) -> dict:
6865
"""Update the response data in place"""

dojo/settings/settings.dist.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -356,8 +356,6 @@
356356
DD_HASHCODE_FIELDS_PER_SCANNER=(str, ""),
357357
# Set deduplication algorithms per parser, via en env variable that contains a JSON string
358358
DD_DEDUPLICATION_ALGORITHM_PER_PARSER=(str, ""),
359-
# Dictates whether cloud banner is created or not
360-
DD_CREATE_CLOUD_BANNER=(bool, True),
361359
# With this setting turned on, Dojo maintains an audit log of changes made to entities (Findings, Tests, Engagements, Products, ...)
362360
# If you run big import you may want to disable this because there's a performance hit during (re-)imports.
363361
DD_ENABLE_AUDITLOG=(bool, True),
@@ -1339,13 +1337,6 @@ def saml2_attrib_map_format(din):
13391337
"expires": int(60 * 1 * 1.2), # If a task is not executed within 72 seconds, it should be dropped from the queue. Two more tasks should be scheduled in the meantime.
13401338
},
13411339
},
1342-
"trigger_evaluate_pro_proposition": {
1343-
"task": "dojo.tasks.evaluate_pro_proposition",
1344-
"schedule": timedelta(hours=8),
1345-
"options": {
1346-
"expires": int(60 * 60 * 8 * 1.2), # If a task is not executed within 9.6 hours, it should be dropped from the queue. Two more tasks should be scheduled in the meantime.
1347-
},
1348-
},
13491340
"clear_sessions": {
13501341
"task": "dojo.tasks.clear_sessions",
13511342
"schedule": crontab(hour=0, minute=0, day_of_week=0),
@@ -2083,9 +2074,6 @@ def saml2_attrib_map_format(din):
20832074
AUDITLOG_DISABLE_ON_RAW_SAVE = False
20842075
# You can set extra Jira headers by suppling a dictionary in header: value format (pass as env var like "headr_name=value,another_header=anohter_value")
20852076
ADDITIONAL_HEADERS = env("DD_ADDITIONAL_HEADERS")
2086-
# Dictates whether cloud banner is created or not
2087-
CREATE_CLOUD_BANNER = env("DD_CREATE_CLOUD_BANNER")
2088-
20892077
# ------------------------------------------------------------------------------
20902078
# Auditlog
20912079
# ------------------------------------------------------------------------------

dojo/static/dojo/css/dojo.css

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,6 +1124,41 @@ div.custom-search-form {
11241124
.announcement-banner {
11251125
margin: 0px -15px;
11261126
border-radius: 0px 0px 4px 4px;
1127+
color: #000;
1128+
}
1129+
1130+
.announcement-banner a {
1131+
color: #0645ad;
1132+
text-decoration: underline;
1133+
}
1134+
1135+
.announcement-banner strong,
1136+
.announcement-banner b {
1137+
color: #222;
1138+
}
1139+
1140+
.banner-toggle {
1141+
background: transparent;
1142+
border: 0;
1143+
padding: 0;
1144+
margin-left: 6px;
1145+
color: inherit;
1146+
cursor: pointer;
1147+
line-height: 1;
1148+
}
1149+
1150+
.banner-toggle:focus,
1151+
.banner-toggle:active {
1152+
outline: none;
1153+
box-shadow: none;
1154+
}
1155+
1156+
.banner-toggle:not(.collapsed) .fa-caret-down {
1157+
transform: rotate(180deg);
1158+
}
1159+
1160+
.banner-expanded {
1161+
margin-top: 8px;
11271162
}
11281163

11291164
@media (min-width: 795px) {

0 commit comments

Comments
 (0)