diff --git a/comments/services/common.py b/comments/services/common.py index e1956fd16b..2e8301a7bb 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_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_if_bot)." + ) 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..cd9010b96a 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_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_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_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 + ) + 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_if_bot=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_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 40846d8fe2..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"] + 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,6 +215,7 @@ class UserAdmin(admin.ModelAdmin): "is_spam", "is_bot", "is_primary_bot", + "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 new file mode 100644 index 0000000000..2adc9d31f1 --- /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_if_bot", + 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_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/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 82dd716b35..68844d092c 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_if_bot = 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,12 @@ 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_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", + ), ] def check_can_activate(self):