Skip to content

Commit 2a8321a

Browse files
refactor: store OS-banner dismissal in an extensible user_state_details JSON field
Replace the dedicated UserContactInfo.os_message_dismissed_hash column with a single user_state_details JSONField that holds an open-ended set of small, per-user UI flags (dismissed banners, 'don't show again' toggles, ...). The banner-dismissal hash now lives under the OS_MESSAGE_DISMISSED_KEY key. This avoids adding a new column (and migration) for every minor per-user toggle; future ephemeral flags of this kind become a new JSON key with no schema change. Behavioral/queryable fields (block_execution, token_*, ...) intentionally stay as columns.
1 parent b4d948e commit 2a8321a

7 files changed

Lines changed: 41 additions & 27 deletions

File tree

dojo/announcement/os_message.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99

1010
logger = logging.getLogger(__name__)
1111

12+
# Key under UserContactInfo.user_state_details holding the hash of the most
13+
# recently dismissed open-source promo banner.
14+
OS_MESSAGE_DISMISSED_KEY = "os_message_dismissed_hash"
15+
1216
BUCKET_URL = "https://storage.googleapis.com/defectdojo-os-messages-prod/open_source_message.md"
1317
CACHE_SECONDS = 3600
1418
HTTP_TIMEOUT_SECONDS = 2

dojo/announcement/views.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from django.utils.translation import gettext_lazy as _
1111
from django.views.decorators.http import require_POST
1212

13+
from dojo.announcement.os_message import OS_MESSAGE_DISMISSED_KEY
1314
from dojo.forms import AnnouncementCreateForm, AnnouncementRemoveForm
1415
from dojo.models import Announcement, UserAnnouncement, UserContactInfo
1516
from dojo.utils import add_breadcrumb
@@ -97,9 +98,11 @@ def dismiss_os_message(request):
9798
token = request.POST.get("token", "").strip()
9899
if token and re.fullmatch(r"[0-9a-f]{1,64}", token):
99100
contact = UserContactInfo.objects.get_or_create(user=request.user)[0]
100-
if contact.os_message_dismissed_hash != token:
101-
contact.os_message_dismissed_hash = token
102-
contact.save(update_fields=["os_message_dismissed_hash"])
101+
state = contact.user_state_details or {}
102+
if state.get(OS_MESSAGE_DISMISSED_KEY) != token:
103+
state[OS_MESSAGE_DISMISSED_KEY] = token
104+
contact.user_state_details = state
105+
contact.save(update_fields=["user_state_details"])
103106
if request.headers.get("x-requested-with") == "XMLHttpRequest":
104107
return HttpResponse(status=204)
105108
referer = request.META.get("HTTP_REFERER")

dojo/context_processors.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from django.contrib import messages
66
from django.urls import NoReverseMatch, reverse
77

8-
from dojo.announcement.os_message import get_os_banner
8+
from dojo.announcement.os_message import OS_MESSAGE_DISMISSED_KEY, get_os_banner
99
from dojo.labels import get_labels
1010
from dojo.models import System_Settings, UserAnnouncement
1111

@@ -80,7 +80,9 @@ def _should_show_ui_toggle_banner(request):
8080

8181
def _os_message_dismissed(user, token):
8282
contact = getattr(user, "usercontactinfo", None)
83-
return contact is not None and contact.os_message_dismissed_hash == token
83+
if contact is None:
84+
return False
85+
return (contact.user_state_details or {}).get(OS_MESSAGE_DISMISSED_KEY) == token
8486

8587

8688
def bind_system_settings(request):

dojo/db_migrations/0270_usercontactinfo_os_message_dismissed_hash.py

Lines changed: 0 additions & 18 deletions
This file was deleted.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2.14 on 2026-06-25 00:00
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("dojo", "0269_normalize_blank_finding_components"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="usercontactinfo",
15+
name="user_state_details",
16+
field=models.JSONField(blank=True, default=dict, editable=False, help_text="Extensible per-user UI state (dismissed banners, 'don't show again' flags, ...)."),
17+
),
18+
]

dojo/models.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,11 @@ class UserContactInfo(models.Model):
260260
ui_use_tailwind = models.BooleanField(default=False, verbose_name=_("Use new UI (beta)"), help_text=_("Opt in to the new Tailwind-based UI. Leave off for the classic UI."))
261261
token_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent API token reset for this user."))
262262
password_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent password reset for this user."))
263-
os_message_dismissed_hash = models.CharField(max_length=64, blank=True, default="", editable=False, help_text=_("Hash of the most recently dismissed open-source promo banner; the banner reappears when the message changes."))
263+
# Extensible per-user UI state: dismissed banners, "seen"/"don't show again"
264+
# flags, and similar small ephemeral preferences. Store new flags of this kind
265+
# as keys here instead of adding a dedicated column per flag (avoids a migration
266+
# and a schema column for every minor toggle). Not for queryable/behavioral data.
267+
user_state_details = models.JSONField(default=dict, blank=True, editable=False, help_text=_("Extensible per-user UI state (dismissed banners, 'don't show again' flags, ...)."))
264268

265269

266270
class System_Settings(models.Model):

unittests/test_os_message.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ def _authed_request(self, *, dismissed_hash="", ui_use_tailwind=True):
276276
request.user = SimpleNamespace(
277277
is_authenticated=True,
278278
usercontactinfo=SimpleNamespace(
279-
os_message_dismissed_hash=dismissed_hash,
279+
user_state_details={os_message.OS_MESSAGE_DISMISSED_KEY: dismissed_hash},
280280
ui_use_tailwind=ui_use_tailwind,
281281
),
282282
)
@@ -331,7 +331,7 @@ def test_post_persists_hash_on_usercontactinfo(self):
331331
response = self.client.post(self.url, {"token": "abc123def456"}, HTTP_X_REQUESTED_WITH="XMLHttpRequest")
332332
self.assertEqual(response.status_code, 204)
333333
contact = UserContactInfo.objects.get(user=self.user)
334-
self.assertEqual(contact.os_message_dismissed_hash, "abc123def456")
334+
self.assertEqual(contact.user_state_details.get(os_message.OS_MESSAGE_DISMISSED_KEY), "abc123def456")
335335

336336
def test_get_not_allowed(self):
337337
self.assertEqual(self.client.get(self.url).status_code, 405)
@@ -340,7 +340,8 @@ def test_invalid_token_is_ignored(self):
340340
response = self.client.post(self.url, {"token": "NOT-HEX!"}, HTTP_X_REQUESTED_WITH="XMLHttpRequest")
341341
self.assertEqual(response.status_code, 204)
342342
contact = UserContactInfo.objects.filter(user=self.user).first()
343-
self.assertIn(getattr(contact, "os_message_dismissed_hash", ""), ("", None))
343+
state = getattr(contact, "user_state_details", {}) or {}
344+
self.assertNotIn(os_message.OS_MESSAGE_DISMISSED_KEY, state)
344345

345346
def test_requires_authentication(self):
346347
self.client.logout()

0 commit comments

Comments
 (0)