diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index 1b3715e7..24bdd317 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -256,3 +256,14 @@ class InvitationFactory(factory.django.DjangoModelFactory): document = factory.SubFactory(DocumentFactory) role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices]) issuer = factory.SubFactory(UserFactory) + + +class CommentFactory(factory.django.DjangoModelFactory): + """A factory to create comments for a document""" + + class Meta: + model = models.Comment + + document = factory.SubFactory(DocumentFactory) + user = factory.SubFactory(UserFactory) + content = factory.Faker("text") diff --git a/src/backend/core/migrations/0025_alter_document_link_role_alter_documentaccess_role_and_more.py b/src/backend/core/migrations/0025_alter_document_link_role_alter_documentaccess_role_and_more.py new file mode 100644 index 00000000..a34ad05b --- /dev/null +++ b/src/backend/core/migrations/0025_alter_document_link_role_alter_documentaccess_role_and_more.py @@ -0,0 +1,146 @@ +# Generated by Django 5.2.4 on 2025-08-26 08:11 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0024_add_is_masked_field_to_link_trace"), + ] + + operations = [ + migrations.AlterField( + model_name="document", + name="link_role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commentator", "Commentator"), + ("editor", "Editor"), + ], + default="reader", + max_length=20, + ), + ), + migrations.AlterField( + model_name="documentaccess", + name="role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commentator", "Commentator"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + migrations.AlterField( + model_name="documentaskforaccess", + name="role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commentator", "Commentator"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + migrations.AlterField( + model_name="invitation", + name="role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commentator", "Commentator"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + migrations.AlterField( + model_name="templateaccess", + name="role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commentator", "Commentator"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + migrations.CreateModel( + name="Comment", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="primary key for the record as UUID", + primary_key=True, + serialize=False, + verbose_name="id", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + help_text="date and time at which a record was created", + verbose_name="created on", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="date and time at which a record was last updated", + verbose_name="updated on", + ), + ), + ("content", models.TextField()), + ( + "document", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to="core.document", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="comments", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Comment", + "verbose_name_plural": "Comments", + "db_table": "impress_comment", + "ordering": ("-created_at",), + }, + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index d8127b77..c4e06f87 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1277,6 +1277,48 @@ class DocumentAskForAccess(BaseModel): self.document.send_email(subject, [email], context, language) +class Comment(BaseModel): + """User comment on a document.""" + + document = models.ForeignKey( + Document, + on_delete=models.CASCADE, + related_name="comments", + ) + user = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="comments", + null=True, + blank=True, + ) + content = models.TextField() + + class Meta: + db_table = "impress_comment" + ordering = ("-created_at",) + verbose_name = _("Comment") + verbose_name_plural = _("Comments") + + def __str__(self): + author = self.user or _("Anonymous") + return f"{author!s} on {self.document!s}" + + def get_abilities(self, user): + """Compute and return abilities for a given user.""" + role = self.document.get_role(user) + can_comment = self.document.get_abilities(user)["comment"] + return { + "destroy": self.user == user + or role in [RoleChoices.OWNER, RoleChoices.ADMIN], + "update": self.user == user + or role in [RoleChoices.OWNER, RoleChoices.ADMIN], + "partial_update": self.user == user + or role in [RoleChoices.OWNER, RoleChoices.ADMIN], + "retrieve": can_comment, + } + + class Template(BaseModel): """HTML and CSS code used for formatting the print around the MarkDown body.""" diff --git a/src/backend/core/tests/test_models_comment.py b/src/backend/core/tests/test_models_comment.py new file mode 100644 index 00000000..dac0b36c --- /dev/null +++ b/src/backend/core/tests/test_models_comment.py @@ -0,0 +1,273 @@ +"""Test the comment model.""" + +import random + +from django.contrib.auth.models import AnonymousUser + +import pytest + +from core import factories +from core.models import LinkReachChoices, LinkRoleChoices, RoleChoices + +pytestmark = pytest.mark.django_db + + +@pytest.mark.parametrize( + "role,can_comment", + [ + (LinkRoleChoices.READER, False), + (LinkRoleChoices.COMMENTATOR, True), + (LinkRoleChoices.EDITOR, True), + ], +) +def test_comment_get_abilities_anonymous_user_public_document(role, can_comment): + """Anonymous users cannot comment on a document.""" + document = factories.DocumentFactory( + link_role=role, link_reach=LinkReachChoices.PUBLIC + ) + comment = factories.CommentFactory(document=document) + user = AnonymousUser() + + assert comment.get_abilities(user) == { + "destroy": False, + "update": False, + "partial_update": False, + "retrieve": can_comment, + } + + +@pytest.mark.parametrize( + "link_reach", [LinkReachChoices.RESTRICTED, LinkReachChoices.AUTHENTICATED] +) +def test_comment_get_abilities_anonymous_user_restricted_document(link_reach): + """Anonymous users cannot comment on a restricted document.""" + document = factories.DocumentFactory(link_reach=link_reach) + comment = factories.CommentFactory(document=document) + user = AnonymousUser() + + assert comment.get_abilities(user) == { + "destroy": False, + "update": False, + "partial_update": False, + "retrieve": False, + } + + +@pytest.mark.parametrize( + "link_role,link_reach,can_comment", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC, True), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED, True), + (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED, True), + ], +) +def test_comment_get_abilities_user_reader(link_role, link_reach, can_comment): + """Readers cannot comment on a document.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.READER)] + ) + comment = factories.CommentFactory(document=document) + + assert comment.get_abilities(user) == { + "destroy": False, + "update": False, + "partial_update": False, + "retrieve": can_comment, + } + + +@pytest.mark.parametrize( + "link_role,link_reach,can_comment", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC, True), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED, True), + (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED, True), + ], +) +def test_comment_get_abilities_user_reader_own_comment( + link_role, link_reach, can_comment +): + """User with reader role on a document has all accesses to its own comment.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.READER)] + ) + comment = factories.CommentFactory( + document=document, user=user if can_comment else None + ) + + assert comment.get_abilities(user) == { + "destroy": can_comment, + "update": can_comment, + "partial_update": can_comment, + "retrieve": can_comment, + } + + +@pytest.mark.parametrize( + "link_role,link_reach", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED), + ], +) +def test_comment_get_abilities_user_commentator(link_role, link_reach): + """Commentators can comment on a document.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_role=link_role, + link_reach=link_reach, + users=[(user, RoleChoices.COMMENTATOR)], + ) + comment = factories.CommentFactory(document=document) + + assert comment.get_abilities(user) == { + "destroy": False, + "update": False, + "partial_update": False, + "retrieve": True, + } + + +@pytest.mark.parametrize( + "link_role,link_reach", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED), + ], +) +def test_comment_get_abilities_user_commentator_own_comment(link_role, link_reach): + """Commentators have all accesses to its own comment.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_role=link_role, + link_reach=link_reach, + users=[(user, RoleChoices.COMMENTATOR)], + ) + comment = factories.CommentFactory(document=document, user=user) + + assert comment.get_abilities(user) == { + "destroy": True, + "update": True, + "partial_update": True, + "retrieve": True, + } + + +@pytest.mark.parametrize( + "link_role,link_reach", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED), + ], +) +def test_comment_get_abilities_user_editor(link_role, link_reach): + """Editors can comment on a document.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.EDITOR)] + ) + comment = factories.CommentFactory(document=document) + + assert comment.get_abilities(user) == { + "destroy": False, + "update": False, + "partial_update": False, + "retrieve": True, + } + + +@pytest.mark.parametrize( + "link_role,link_reach", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED), + ], +) +def test_comment_get_abilities_user_editor_own_comment(link_role, link_reach): + """Editors have all accesses to its own comment.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.EDITOR)] + ) + comment = factories.CommentFactory(document=document, user=user) + + assert comment.get_abilities(user) == { + "destroy": True, + "update": True, + "partial_update": True, + "retrieve": True, + } + + +def test_comment_get_abilities_user_admin(): + """Admins have all accesses to a comment.""" + user = factories.UserFactory() + document = factories.DocumentFactory(users=[(user, RoleChoices.ADMIN)]) + comment = factories.CommentFactory( + document=document, user=random.choice([user, None]) + ) + + assert comment.get_abilities(user) == { + "destroy": True, + "update": True, + "partial_update": True, + "retrieve": True, + } + + +def test_comment_get_abilities_user_owner(): + """Owners have all accesses to a comment.""" + user = factories.UserFactory() + document = factories.DocumentFactory(users=[(user, RoleChoices.OWNER)]) + comment = factories.CommentFactory( + document=document, user=random.choice([user, None]) + ) + + assert comment.get_abilities(user) == { + "destroy": True, + "update": True, + "partial_update": True, + "retrieve": True, + }