From 2d6c8b1e7b05d1a1bdad4537de8064e33a83a00b Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:51:30 +0000 Subject: [PATCH 1/4] feat: add separate allow_public_comments field for bots Add a new `allow_public_comments` boolean field to the User model, separate from `is_primary_bot` which is used for scoring. This gives admins fine-grained control over which bots can post public comments without affecting scoring behavior. - New field defaults to False (bots can only post private comments) - Informative error message when bots try to post public comments - Admin UI updated to expose the new field - DB constraint ensures only bot accounts can have the flag set Closes #4583 Co-authored-by: Sylvain --- comments/services/common.py | 8 +++- tests/unit/test_comments/test_services.py | 42 +++++++++++++++++++ users/admin.py | 3 +- .../0019_add_allow_public_comments.py | 31 ++++++++++++++ users/models.py | 19 +++++++-- 5 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 users/migrations/0019_add_allow_public_comments.py diff --git a/comments/services/common.py b/comments/services/common.py index e1956fd16b..3c9613b57c 100644 --- a/comments/services/common.py +++ b/comments/services/common.py @@ -90,8 +90,12 @@ def create_comment( if root: is_private = root.is_private - if not is_private and user.is_bot and not user.is_primary_bot: - raise PermissionDenied("Only your primary bot can post public comments.") + if not is_private and user.is_bot and not user.allow_public_comments: + raise PermissionDenied( + "Bots cannot post public comments by default. " + "Use is_private=true for private comments, or ask an admin to " + "enable public commenting for this bot (allow_public_comments)." + ) with transaction.atomic(): obj = Comment( diff --git a/tests/unit/test_comments/test_services.py b/tests/unit/test_comments/test_services.py index d740db9060..bb9ddf21db 100644 --- a/tests/unit/test_comments/test_services.py +++ b/tests/unit/test_comments/test_services.py @@ -3,6 +3,8 @@ from rest_framework.exceptions import ValidationError from comments.models import KeyFactorVote, KeyFactorDriver, KeyFactorBaseRate +from rest_framework.exceptions import PermissionDenied + from comments.services.common import create_comment, soft_delete_comment from comments.services.key_factors.common import ( key_factor_vote, @@ -376,3 +378,43 @@ def test_base_rate_freshness_ignores_time_decay(user1, post): freshness = calculate_freshness_base_rate(kf, votes) assert freshness == pytest.approx(1.666, abs=0.001) + + +def test_bot_cannot_post_public_comments(post): + bot = factory_user(is_bot=True, allow_public_comments=False) + with pytest.raises(PermissionDenied): + create_comment(user=bot, on_post=post, text="Public comment", is_private=False) + + +def test_bot_can_post_private_comments(post): + bot = factory_user(is_bot=True, allow_public_comments=False) + comment = create_comment( + user=bot, on_post=post, text="Private comment", is_private=True + ) + assert comment.is_private is True + + +def test_bot_with_allow_public_comments_can_post_public(post): + bot = factory_user(is_bot=True, allow_public_comments=True) + comment = create_comment( + user=bot, on_post=post, text="Public comment", is_private=False + ) + assert comment.is_private is False + + +def test_bot_cannot_reply_to_public_thread(post): + user = factory_user() + parent = create_comment(user=user, on_post=post, text="Public parent") + bot = factory_user(is_bot=True, allow_public_comments=False) + with pytest.raises(PermissionDenied): + create_comment(user=bot, on_post=post, text="Reply", parent=parent) + + +def test_bot_can_reply_to_private_thread(post): + user = factory_user() + parent = create_comment( + user=user, on_post=post, text="Private parent", is_private=True + ) + bot = factory_user(is_bot=True, allow_public_comments=False) + reply = create_comment(user=bot, on_post=post, text="Reply", parent=parent) + assert reply.is_private is True diff --git a/users/admin.py b/users/admin.py index 40846d8fe2..e8bd709d2f 100644 --- a/users/admin.py +++ b/users/admin.py @@ -187,7 +187,7 @@ def has_add_permission(self, request, obj=None): class BotInline(admin.TabularInline): model = User fk_name = "bot_owner" - fields = ["username", "email", "is_active", "is_primary_bot"] + fields = ["username", "email", "is_active", "is_primary_bot", "allow_public_comments"] readonly_fields = ["username", "email", "is_active", "is_bot"] extra = 0 show_change_link = True @@ -209,6 +209,7 @@ class UserAdmin(admin.ModelAdmin): "is_spam", "is_bot", "is_primary_bot", + "allow_public_comments", "bot_owner", "duration_joined_to_last_login", "authored_posts", diff --git a/users/migrations/0019_add_allow_public_comments.py b/users/migrations/0019_add_allow_public_comments.py new file mode 100644 index 0000000000..861fad34fe --- /dev/null +++ b/users/migrations/0019_add_allow_public_comments.py @@ -0,0 +1,31 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0018_add_auth_revoked_at"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="allow_public_comments", + field=models.BooleanField( + default=False, + help_text=( + "Allow this bot to post public comments. " + "By default, bots can only post private comments. " + "An admin can enable this for select bots." + ), + ), + ), + migrations.AddConstraint( + model_name="user", + constraint=models.CheckConstraint( + check=models.Q(("is_bot", True), ("allow_public_comments", False), _connector="OR"), + name="user_allow_public_comments_only_for_bots", + violation_error_message="allow_public_comments can only be set for bot accounts", + ), + ), + ] diff --git a/users/models.py b/users/models.py index 82dd716b35..9383e0205d 100644 --- a/users/models.py +++ b/users/models.py @@ -124,9 +124,17 @@ class InterfaceType(models.TextChoices): default=False, db_index=True, help_text=( - "Marks the user’s primary bot. Only the primary bot can post public comments, " - "be eligible for prizes, count toward peer scores, " - "and appear on leaderboards." + "Marks the user’s primary bot. The primary bot is " + "eligible for prizes, counts toward peer scores, " + "and appears on leaderboards." + ), + ) + allow_public_comments = models.BooleanField( + default=False, + help_text=( + "Allow this bot to post public comments. " + "By default, bots can only post private comments. " + "An admin can enable this for select bots." ), ) bot_owner = models.ForeignKey( @@ -173,6 +181,11 @@ class Meta: name="unique_primary_bot_per_bot_owner", violation_error_message="Bot owner could have only one primary bot", ), + models.CheckConstraint( + check=models.Q(is_bot=True) | models.Q(allow_public_comments=False), + name="user_allow_public_comments_only_for_bots", + violation_error_message="allow_public_comments can only be set for bot accounts", + ), ] def check_can_activate(self): From c388f1585815348394db4ed53f2f05ad848ce714 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:11:30 +0000 Subject: [PATCH 2/4] refactor: rename allow_public_comments to allow_public_comments_if_bot Make the field name self-documenting: it only applies to bot accounts. Co-authored-by: Sylvain --- comments/services/common.py | 4 ++-- tests/unit/test_comments/test_services.py | 12 ++++++------ users/admin.py | 4 ++-- users/migrations/0019_add_allow_public_comments.py | 8 ++++---- users/models.py | 8 ++++---- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/comments/services/common.py b/comments/services/common.py index 3c9613b57c..2e8301a7bb 100644 --- a/comments/services/common.py +++ b/comments/services/common.py @@ -90,11 +90,11 @@ def create_comment( if root: is_private = root.is_private - if not is_private and user.is_bot and not user.allow_public_comments: + if not is_private and user.is_bot and not user.allow_public_comments_if_bot: raise PermissionDenied( "Bots cannot post public comments by default. " "Use is_private=true for private comments, or ask an admin to " - "enable public commenting for this bot (allow_public_comments)." + "enable public commenting for this bot (allow_public_comments_if_bot)." ) with transaction.atomic(): diff --git a/tests/unit/test_comments/test_services.py b/tests/unit/test_comments/test_services.py index bb9ddf21db..cd9010b96a 100644 --- a/tests/unit/test_comments/test_services.py +++ b/tests/unit/test_comments/test_services.py @@ -381,21 +381,21 @@ def test_base_rate_freshness_ignores_time_decay(user1, post): def test_bot_cannot_post_public_comments(post): - bot = factory_user(is_bot=True, allow_public_comments=False) + bot = factory_user(is_bot=True, allow_public_comments_if_bot=False) with pytest.raises(PermissionDenied): create_comment(user=bot, on_post=post, text="Public comment", is_private=False) def test_bot_can_post_private_comments(post): - bot = factory_user(is_bot=True, allow_public_comments=False) + bot = factory_user(is_bot=True, allow_public_comments_if_bot=False) comment = create_comment( user=bot, on_post=post, text="Private comment", is_private=True ) assert comment.is_private is True -def test_bot_with_allow_public_comments_can_post_public(post): - bot = factory_user(is_bot=True, allow_public_comments=True) +def test_bot_with_allow_public_comments_if_bot_can_post_public(post): + bot = factory_user(is_bot=True, allow_public_comments_if_bot=True) comment = create_comment( user=bot, on_post=post, text="Public comment", is_private=False ) @@ -405,7 +405,7 @@ def test_bot_with_allow_public_comments_can_post_public(post): def test_bot_cannot_reply_to_public_thread(post): user = factory_user() parent = create_comment(user=user, on_post=post, text="Public parent") - bot = factory_user(is_bot=True, allow_public_comments=False) + bot = factory_user(is_bot=True, allow_public_comments_if_bot=False) with pytest.raises(PermissionDenied): create_comment(user=bot, on_post=post, text="Reply", parent=parent) @@ -415,6 +415,6 @@ def test_bot_can_reply_to_private_thread(post): parent = create_comment( user=user, on_post=post, text="Private parent", is_private=True ) - bot = factory_user(is_bot=True, allow_public_comments=False) + bot = factory_user(is_bot=True, allow_public_comments_if_bot=False) reply = create_comment(user=bot, on_post=post, text="Reply", parent=parent) assert reply.is_private is True diff --git a/users/admin.py b/users/admin.py index e8bd709d2f..af0f05bc41 100644 --- a/users/admin.py +++ b/users/admin.py @@ -187,7 +187,7 @@ def has_add_permission(self, request, obj=None): class BotInline(admin.TabularInline): model = User fk_name = "bot_owner" - fields = ["username", "email", "is_active", "is_primary_bot", "allow_public_comments"] + fields = ["username", "email", "is_active", "is_primary_bot", "allow_public_comments_if_bot"] readonly_fields = ["username", "email", "is_active", "is_bot"] extra = 0 show_change_link = True @@ -209,7 +209,7 @@ class UserAdmin(admin.ModelAdmin): "is_spam", "is_bot", "is_primary_bot", - "allow_public_comments", + "allow_public_comments_if_bot", "bot_owner", "duration_joined_to_last_login", "authored_posts", diff --git a/users/migrations/0019_add_allow_public_comments.py b/users/migrations/0019_add_allow_public_comments.py index 861fad34fe..2adc9d31f1 100644 --- a/users/migrations/0019_add_allow_public_comments.py +++ b/users/migrations/0019_add_allow_public_comments.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): operations = [ migrations.AddField( model_name="user", - name="allow_public_comments", + name="allow_public_comments_if_bot", field=models.BooleanField( default=False, help_text=( @@ -23,9 +23,9 @@ class Migration(migrations.Migration): migrations.AddConstraint( model_name="user", constraint=models.CheckConstraint( - check=models.Q(("is_bot", True), ("allow_public_comments", False), _connector="OR"), - name="user_allow_public_comments_only_for_bots", - violation_error_message="allow_public_comments can only be set for bot accounts", + check=models.Q(("is_bot", True), ("allow_public_comments_if_bot", False), _connector="OR"), + name="user_allow_public_comments_if_bot_only_for_bots", + violation_error_message="allow_public_comments_if_bot can only be set for bot accounts", ), ), ] diff --git a/users/models.py b/users/models.py index 9383e0205d..c22a35520a 100644 --- a/users/models.py +++ b/users/models.py @@ -129,7 +129,7 @@ class InterfaceType(models.TextChoices): "and appears on leaderboards." ), ) - allow_public_comments = models.BooleanField( + allow_public_comments_if_bot = models.BooleanField( default=False, help_text=( "Allow this bot to post public comments. " @@ -182,9 +182,9 @@ class Meta: violation_error_message="Bot owner could have only one primary bot", ), models.CheckConstraint( - check=models.Q(is_bot=True) | models.Q(allow_public_comments=False), - name="user_allow_public_comments_only_for_bots", - violation_error_message="allow_public_comments can only be set for bot accounts", + check=models.Q(is_bot=True) | models.Q(allow_public_comments_if_bot=False), + name="user_allow_public_comments_if_bot_only_for_bots", + violation_error_message="allow_public_comments_if_bot can only be set for bot accounts", ), ] From ee3ed2c3905ab5e65db270bd6db0f8d38116f197 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:49:54 +0000 Subject: [PATCH 3/4] fix: ruff formatting and missing migration for is_primary_bot help_text - Fix line-too-long in users/admin.py (BotInline fields list) - Fix line-too-long in users/models.py (constraint check expression) - Add migration 0020 for is_primary_bot help_text change Co-authored-by: Sylvain --- users/admin.py | 8 ++++++- .../0020_alter_user_is_primary_bot.py | 24 +++++++++++++++++++ users/models.py | 3 ++- 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 users/migrations/0020_alter_user_is_primary_bot.py diff --git a/users/admin.py b/users/admin.py index af0f05bc41..67c347a7c3 100644 --- a/users/admin.py +++ b/users/admin.py @@ -187,7 +187,13 @@ def has_add_permission(self, request, obj=None): class BotInline(admin.TabularInline): model = User fk_name = "bot_owner" - fields = ["username", "email", "is_active", "is_primary_bot", "allow_public_comments_if_bot"] + fields = [ + "username", + "email", + "is_active", + "is_primary_bot", + "allow_public_comments_if_bot", + ] readonly_fields = ["username", "email", "is_active", "is_bot"] extra = 0 show_change_link = True diff --git a/users/migrations/0020_alter_user_is_primary_bot.py b/users/migrations/0020_alter_user_is_primary_bot.py new file mode 100644 index 0000000000..88c4a9b26a --- /dev/null +++ b/users/migrations/0020_alter_user_is_primary_bot.py @@ -0,0 +1,24 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0019_add_allow_public_comments"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="is_primary_bot", + field=models.BooleanField( + db_index=True, + default=False, + help_text=( + "Marks the user's primary bot. The primary" + " bot is eligible for prizes, counts toward" + " peer scores, and appears on leaderboards." + ), + ), + ), + ] diff --git a/users/models.py b/users/models.py index c22a35520a..e8983dd59b 100644 --- a/users/models.py +++ b/users/models.py @@ -182,7 +182,8 @@ class Meta: violation_error_message="Bot owner could have only one primary bot", ), models.CheckConstraint( - check=models.Q(is_bot=True) | models.Q(allow_public_comments_if_bot=False), + check=models.Q(is_bot=True) + | models.Q(allow_public_comments_if_bot=False), name="user_allow_public_comments_if_bot_only_for_bots", violation_error_message="allow_public_comments_if_bot can only be set for bot accounts", ), From 4275466db89a25e012f7bfd59f9698b704baa057 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:32:57 +0000 Subject: [PATCH 4/4] fix: replace curly apostrophe in is_primary_bot help_text The help_text in the model used a Unicode right-single-quotation-mark (U+2019), while migration 0020 used a regular ASCII apostrophe. Django's makemigrations detected this mismatch and demanded a new migration. Aligning the model with the migration resolves the "Check migrations" CI failure. Co-authored-by: Hlib --- users/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/users/models.py b/users/models.py index e8983dd59b..68844d092c 100644 --- a/users/models.py +++ b/users/models.py @@ -124,7 +124,7 @@ class InterfaceType(models.TextChoices): default=False, db_index=True, help_text=( - "Marks the user’s primary bot. The primary bot is " + "Marks the user's primary bot. The primary bot is " "eligible for prizes, counts toward peer scores, " "and appears on leaderboards." ),