Skip to content
Merged
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
8 changes: 8 additions & 0 deletions core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,14 @@ class AnnouncementAdmin(admin.ModelAdmin):
search_fields = ["title", "summary", "body"]
actions = [publish_selected_announcements, unpublish_selected_announcements]

def get_readonly_fields(self, request, obj=None):
# published_at is set automatically the first time an announcement is
# published (see Announcement.save()) - once set, edit it via
# unpublish/republish rather than by hand.
if obj is not None and obj.published_at is not None:
return ["published_at"]
return []


@admin.register(PlayerAnnouncementState)
class PlayerAnnouncementStateAdmin(admin.ModelAdmin):
Expand Down
20 changes: 20 additions & 0 deletions core/migrations/0017_alter_announcement_body.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 5.2.17 on 2026-08-14 11:42

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("core", "0016_gamesettings_daily_goals_completion_bonus_ap"),
]

operations = [
migrations.AlterField(
model_name="announcement",
name="body",
field=models.TextField(
help_text="Supports Markdown (bold, links, lists, etc.)."
),
),
]
8 changes: 7 additions & 1 deletion core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from django.core.files.storage import Storage
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.deconstruct import deconstructible


Expand Down Expand Up @@ -233,7 +234,7 @@ def as_dict(cls):
class Announcement(models.Model):
title = models.CharField(max_length=200)
summary = models.CharField(max_length=300, blank=True)
body = models.TextField()
body = models.TextField(help_text="Supports Markdown (bold, links, lists, etc.).")
is_published = models.BooleanField(default=False)
published_at = models.DateTimeField(null=True, blank=True, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
Expand All @@ -247,6 +248,11 @@ class Meta:
def __str__(self):
return self.title

def save(self, *args, **kwargs):
if self.is_published and self.published_at is None:
self.published_at = timezone.now()
super().save(*args, **kwargs)


class PlayerAnnouncementState(models.Model):
player = models.ForeignKey(
Expand Down
61 changes: 59 additions & 2 deletions core/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@

from django.core.exceptions import ValidationError
from django.test import SimpleTestCase, TestCase, override_settings
from django.contrib import admin as django_admin
from django.contrib.auth import get_user_model
from rest_framework import status
from rest_framework.test import APITestCase

from django.utils import timezone

from core.checks import REQUIRED_PROD_SETTINGS, check_required_prod_settings
from core.admin import FeatureFlagForm
from core.models import FeatureFlag, GameSettings
from core.admin import AnnouncementAdmin, FeatureFlagForm
from core.models import Announcement, FeatureFlag, GameSettings
from users.services.login_services import (
calculate_daily_login_reward,
LOGIN_STATE_ALREADY_LOGGED_TODAY,
Expand Down Expand Up @@ -298,3 +301,57 @@ def test_editing_existing_flag_keeps_its_own_key_selectable(self, mock_path):
choice_keys = [key for key, _ in form.fields["key"].choices]
self.assertIn("tasksFeature", choice_keys)
self.assertNotIn("activityList", choice_keys)


class AnnouncementSaveTest(TestCase):
def test_publishing_sets_published_at_if_unset(self):
announcement = Announcement.objects.create(
title="Hi", body="Body", is_published=True
)
self.assertIsNotNone(announcement.published_at)

def test_publishing_does_not_overwrite_existing_published_at(self):
original = timezone.now() - timezone.timedelta(days=3)
announcement = Announcement.objects.create(
title="Hi", body="Body", is_published=True, published_at=original
)
self.assertEqual(announcement.published_at, original)

def test_creating_unpublished_leaves_published_at_unset(self):
announcement = Announcement.objects.create(
title="Hi", body="Body", is_published=False
)
self.assertIsNone(announcement.published_at)

def test_saving_again_after_publish_does_not_change_published_at(self):
announcement = Announcement.objects.create(
title="Hi", body="Body", is_published=True
)
first_published_at = announcement.published_at

announcement.title = "Updated"
announcement.save()

self.assertEqual(announcement.published_at, first_published_at)


class AnnouncementAdminReadonlyFieldsTest(TestCase):
def setUp(self):
self.admin = AnnouncementAdmin(Announcement, django_admin.site)

def test_published_at_editable_when_unset(self):
announcement = Announcement.objects.create(title="Hi", body="Body")
self.assertNotIn(
"published_at", self.admin.get_readonly_fields(None, announcement)
)

def test_published_at_readonly_once_set(self):
announcement = Announcement.objects.create(
title="Hi", body="Body", is_published=True
)
self.assertIn(
"published_at", self.admin.get_readonly_fields(None, announcement)
)

def test_published_at_editable_for_new_unsaved_announcement(self):
self.assertNotIn("published_at", self.admin.get_readonly_fields(None, None))
Loading
Loading