diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index b9cb6e3b..b119d15a 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -25,16 +25,11 @@ class UserSerializer(serializers.ModelSerializer): read_only_fields = ["id", "is_device", "is_staff"] -class TemplateAccessSerializer(serializers.ModelSerializer): +class BaseAccessSerializer(serializers.ModelSerializer): """Serialize template accesses.""" abilities = serializers.SerializerMethodField(read_only=True) - class Meta: - model = models.TemplateAccess - fields = ["id", "user", "team", "role", "abilities"] - read_only_fields = ["id", "abilities"] - def update(self, instance, validated_data): """Make "user" field is readonly but only on update.""" validated_data.pop("user", None) @@ -71,55 +66,88 @@ class TemplateAccessSerializer(serializers.ModelSerializer): else: teams = user.get_teams() try: - template_id = self.context["template_id"] + resource_id = self.context["resource_id"] except KeyError as exc: raise exceptions.ValidationError( - "You must set a template ID in kwargs to create a new template access." + "You must set a resource ID in kwargs to create a new access." ) from exc - if not models.TemplateAccess.objects.filter( + if not self.Meta.model.objects.filter( # pylint: disable=no-member Q(user=user) | Q(team__in=teams), - template=template_id, role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN], ).exists(): raise exceptions.PermissionDenied( - "You are not allowed to manage accesses for this template." + "You are not allowed to manage accesses for this resource." ) if ( role == models.RoleChoices.OWNER - and not models.TemplateAccess.objects.filter( + and not self.Meta.model.objects.filter( # pylint: disable=no-member Q(user=user) | Q(team__in=teams), - template=template_id, role=models.RoleChoices.OWNER, + **{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member ).exists() ): raise exceptions.PermissionDenied( - "Only owners of a template can assign other users as owners." + "Only owners of a resource can assign other users as owners." ) - attrs["template_id"] = self.context["template_id"] + # pylint: disable=no-member + attrs[f"{self.Meta.resource_field_name}_id"] = self.context["resource_id"] return attrs -class TemplateSerializer(serializers.ModelSerializer): - """Serialize templates.""" +class DocumentAccessSerializer(BaseAccessSerializer): + """Serialize document accesses.""" + + class Meta: + model = models.DocumentAccess + resource_field_name = "document" + fields = ["id", "user", "team", "role", "abilities"] + read_only_fields = ["id", "abilities"] + + +class TemplateAccessSerializer(BaseAccessSerializer): + """Serialize template accesses.""" + + class Meta: + model = models.TemplateAccess + resource_field_name = "template" + fields = ["id", "user", "team", "role", "abilities"] + read_only_fields = ["id", "abilities"] + + +class BaseResourceSerializer(serializers.ModelSerializer): + """Serialize documents.""" abilities = serializers.SerializerMethodField(read_only=True) accesses = TemplateAccessSerializer(many=True, read_only=True) + def get_abilities(self, document) -> dict: + """Return abilities of the logged-in user on the instance.""" + request = self.context.get("request") + if request: + return document.get_abilities(request.user) + return {} + + +class DocumentSerializer(BaseResourceSerializer): + """Serialize documents.""" + + class Meta: + model = models.Document + fields = ["id", "title", "accesses", "abilities"] + read_only_fields = ["id", "accesses", "abilities"] + + +class TemplateSerializer(BaseResourceSerializer): + """Serialize templates.""" + class Meta: model = models.Template fields = ["id", "title", "accesses", "abilities"] read_only_fields = ["id", "accesses", "abilities"] - def get_abilities(self, template) -> dict: - """Return abilities of the logged-in user on the instance.""" - request = self.context.get("request") - if request: - return template.get_abilities(request.user) - return {} - # pylint: disable=abstract-method class DocumentGenerationSerializer(serializers.Serializer): diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 242c47a8..8539ca77 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -135,7 +135,201 @@ class UserViewSet( ) +class ResourceViewsetMixin: + """Mixin with methods common to all resource viewsets that are managed with accesses.""" + + def get_queryset(self): + """Custom queryset to get user related resources.""" + queryset = super().get_queryset() + if not self.request.user.is_authenticated: + return queryset.filter(is_public=True) + + user = self.request.user + teams = user.get_teams() + + user_roles_query = ( + self.access_model_class.objects.filter( + Q(user=user) | Q(team__in=teams), + **{self.resource_field_name: OuterRef("pk")}, + ) + .values(self.resource_field_name) + .annotate(roles_array=ArrayAgg("role")) + .values("roles_array") + ) + return ( + queryset.filter( + Q(accesses__user=user) | Q(accesses__team__in=teams) | Q(is_public=True) + ) + .annotate(user_roles=Subquery(user_roles_query)) + .distinct() + ) + + def perform_create(self, serializer): + """Set the current user as owner of the newly created object.""" + obj = serializer.save() + self.access_model_class.objects.create( + user=self.request.user, + role=models.RoleChoices.OWNER, + **{self.resource_field_name: obj}, + ) + + +class ResourceAccessViewsetMixin: + """Mixin with methods common to all access viewsets.""" + + def get_permissions(self): + """User only needs to be authenticated to list resource accesses""" + if self.action == "list": + permission_classes = [permissions.IsAuthenticated] + else: + return super().get_permissions() + + return [permission() for permission in permission_classes] + + def get_serializer_context(self): + """Extra context provided to the serializer class.""" + context = super().get_serializer_context() + context["resource_id"] = self.kwargs["resource_id"] + return context + + def get_queryset(self): + """Return the queryset according to the action.""" + queryset = super().get_queryset() + queryset = queryset.filter( + **{self.resource_field_name: self.kwargs["resource_id"]} + ) + + if self.action == "list": + user = self.request.user + teams = user.get_teams() + + user_roles_query = ( + queryset.filter( + Q(user=user) | Q(team__in=teams), + **{self.resource_field_name: self.kwargs["resource_id"]}, + ) + .values(self.resource_field_name) + .annotate(roles_array=ArrayAgg("role")) + .values("roles_array") + ) + + # Limit to resource access instances related to a resource THAT also has + # a resource access + # instance for the logged-in user (we don't want to list only the resource + # access instances pointing to the logged-in user) + queryset = ( + queryset.filter( + Q(**{f"{self.resource_field_name}__accesses__user": user}) + | Q(**{f"{self.resource_field_name}__accesses__team__in": teams}), + **{self.resource_field_name: self.kwargs["resource_id"]}, + ) + .annotate(user_roles=Subquery(user_roles_query)) + .distinct() + ) + return queryset + + def destroy(self, request, *args, **kwargs): + """Forbid deleting the last owner access""" + instance = self.get_object() + resource = getattr(instance, self.resource_field_name) + + # Check if the access being deleted is the last owner access for the resource + if ( + instance.role == "owner" + and resource.accesses.filter(role="owner").count() == 1 + ): + return drf_response.Response( + {"detail": "Cannot delete the last owner access for the resource."}, + status=403, + ) + + return super().destroy(request, *args, **kwargs) + + def perform_update(self, serializer): + """Check that we don't change the role if it leads to losing the last owner.""" + instance = serializer.instance + + # Check if the role is being updated and the new role is not "owner" + if ( + "role" in self.request.data + and self.request.data["role"] != models.RoleChoices.OWNER + ): + resource = getattr(instance, self.resource_field_name) + # Check if the access being updated is the last owner access for the resource + if ( + instance.role == models.RoleChoices.OWNER + and resource.accesses.filter(role=models.RoleChoices.OWNER).count() == 1 + ): + message = "Cannot change the role to a non-owner role for the last owner access." + raise exceptions.PermissionDenied({"detail": message}) + + serializer.save() + + +class DocumentViewSet( + ResourceViewsetMixin, + mixins.CreateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + """Document ViewSet""" + + permission_classes = [ + permissions.IsAuthenticatedOrSafe, + permissions.AccessPermission, + ] + serializer_class = serializers.DocumentSerializer + access_model_class = models.DocumentAccess + resource_field_name = "document" + queryset = models.Document.objects.all() + + +class DocumentAccessViewSet( + ResourceAccessViewsetMixin, + mixins.CreateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + """ + API ViewSet for all interactions with document accesses. + + GET /api/v1.0/documents//accesses/: + Return list of all document accesses related to the logged-in user or one + document access if an id is provided. + + POST /api/v1.0/documents//accesses/ with expected data: + - user: str + - role: str [owner|admin|member] + Return newly created document access + + PUT /api/v1.0/documents//accesses// with expected data: + - role: str [owner|admin|member] + Return updated document access + + PATCH /api/v1.0/documents//accesses// with expected data: + - role: str [owner|admin|member] + Return partially updated document access + + DELETE /api/v1.0/documents//accesses// + Delete targeted document access + """ + + lookup_field = "pk" + pagination_class = Pagination + permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission] + queryset = models.DocumentAccess.objects.select_related("user").all() + resource_field_name = "document" + serializer_class = serializers.DocumentAccessSerializer + + class TemplateViewSet( + ResourceViewsetMixin, mixins.CreateModelMixin, mixins.DestroyModelMixin, mixins.ListModelMixin, @@ -150,41 +344,10 @@ class TemplateViewSet( permissions.AccessPermission, ] serializer_class = serializers.TemplateSerializer + access_model_class = models.TemplateAccess + resource_field_name = "template" queryset = models.Template.objects.all() - def get_queryset(self): - """Custom queryset to get user related templates.""" - if not self.request.user.is_authenticated: - return models.Template.objects.filter(is_public=True) - - user = self.request.user - teams = user.get_teams() - - user_roles_query = ( - models.TemplateAccess.objects.filter( - Q(user=user) | Q(team__in=teams), template=OuterRef("pk") - ) - .values("template") - .annotate(roles_array=ArrayAgg("role")) - .values("roles_array") - ) - return ( - models.Template.objects.filter( - Q(accesses__user=user) | Q(accesses__team__in=teams) | Q(is_public=True) - ) - .annotate(user_roles=Subquery(user_roles_query)) - .distinct() - ) - - def perform_create(self, serializer): - """Set the current user as owner of the newly created template.""" - template = serializer.save() - models.TemplateAccess.objects.create( - template=template, - user=self.request.user, - role=models.RoleChoices.OWNER, - ) - @decorators.action( detail=True, methods=["post"], @@ -214,6 +377,7 @@ class TemplateViewSet( class TemplateAccessViewSet( + ResourceAccessViewsetMixin, mixins.CreateModelMixin, mixins.DestroyModelMixin, mixins.ListModelMixin, @@ -248,91 +412,6 @@ class TemplateAccessViewSet( lookup_field = "pk" pagination_class = Pagination permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission] - queryset = models.TemplateAccess.objects.all().select_related("user") + queryset = models.TemplateAccess.objects.select_related("user").all() + resource_field_name = "template" serializer_class = serializers.TemplateAccessSerializer - - def get_permissions(self): - """User only needs to be authenticated to list template accesses""" - if self.action == "list": - permission_classes = [permissions.IsAuthenticated] - else: - return super().get_permissions() - - return [permission() for permission in permission_classes] - - def get_serializer_context(self): - """Extra context provided to the serializer class.""" - context = super().get_serializer_context() - context["template_id"] = self.kwargs["template_id"] - return context - - def get_queryset(self): - """Return the queryset according to the action.""" - queryset = super().get_queryset() - queryset = queryset.filter(template=self.kwargs["template_id"]) - - if self.action == "list": - user = self.request.user - teams = user.get_teams() - - user_roles_query = ( - models.TemplateAccess.objects.filter( - Q(user=user) | Q(team__in=teams), - template=self.kwargs["template_id"], - ) - .values("template") - .annotate(roles_array=ArrayAgg("role")) - .values("roles_array") - ) - - # Limit to template access instances related to a template THAT also has - # a template access - # instance for the logged-in user (we don't want to list only the template - # access instances pointing to the logged-in user) - queryset = ( - queryset.filter( - Q(template__accesses__user=user) - | Q(template__accesses__team__in=teams), - template=self.kwargs["template_id"], - ) - .annotate(user_roles=Subquery(user_roles_query)) - .distinct() - ) - return queryset - - def destroy(self, request, *args, **kwargs): - """Forbid deleting the last owner access""" - instance = self.get_object() - template = instance.template - - # Check if the access being deleted is the last owner access for the template - if ( - instance.role == "owner" - and template.accesses.filter(role="owner").count() == 1 - ): - return drf_response.Response( - {"detail": "Cannot delete the last owner access for the template."}, - status=403, - ) - - return super().destroy(request, *args, **kwargs) - - def perform_update(self, serializer): - """Check that we don't change the role if it leads to losing the last owner.""" - instance = serializer.instance - - # Check if the role is being updated and the new role is not "owner" - if ( - "role" in self.request.data - and self.request.data["role"] != models.RoleChoices.OWNER - ): - template = instance.template - # Check if the access being updated is the last owner access for the template - if ( - instance.role == models.RoleChoices.OWNER - and template.accesses.filter(role=models.RoleChoices.OWNER).count() == 1 - ): - message = "Cannot change the role to a non-owner role for the last owner access." - raise exceptions.PermissionDenied({"detail": message}) - - serializer.save() diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index 00d29978..790bce6b 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -25,12 +25,57 @@ class UserFactory(factory.django.DjangoModelFactory): password = make_password("password") +class DocumentFactory(factory.django.DjangoModelFactory): + """A factory to create documents""" + + class Meta: + model = models.Document + django_get_or_create = ("title",) + skip_postgeneration_save = True + + title = factory.Sequence(lambda n: f"document{n}") + is_public = factory.Faker("boolean") + + @factory.post_generation + def users(self, create, extracted, **kwargs): + """Add users to document from a given list of users with or without roles.""" + if create and extracted: + for item in extracted: + if isinstance(item, models.User): + UserDocumentAccessFactory(document=self, user=item) + else: + UserDocumentAccessFactory(document=self, user=item[0], role=item[1]) + + +class UserDocumentAccessFactory(factory.django.DjangoModelFactory): + """Create fake document user accesses for testing.""" + + class Meta: + model = models.DocumentAccess + + document = factory.SubFactory(DocumentFactory) + user = factory.SubFactory(UserFactory) + role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices]) + + +class TeamDocumentAccessFactory(factory.django.DjangoModelFactory): + """Create fake document team accesses for testing.""" + + class Meta: + model = models.DocumentAccess + + document = factory.SubFactory(DocumentFactory) + team = factory.Sequence(lambda n: f"team{n}") + role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices]) + + class TemplateFactory(factory.django.DjangoModelFactory): """A factory to create templates""" class Meta: model = models.Template django_get_or_create = ("title",) + skip_postgeneration_save = True title = factory.Sequence(lambda n: f"template{n}") is_public = factory.Faker("boolean") diff --git a/src/backend/core/migrations/0001_initial.py b/src/backend/core/migrations/0001_initial.py index 00444f7d..c7a572dd 100644 --- a/src/backend/core/migrations/0001_initial.py +++ b/src/backend/core/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.2 on 2024-02-24 17:39 +# Generated by Django 5.0.2 on 2024-03-31 18:49 import django.contrib.auth.models import django.core.validators @@ -18,6 +18,22 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='Document', + 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')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('is_public', models.BooleanField(default=False, help_text='Whether this document is public for anyone to use.', verbose_name='public')), + ], + options={ + 'verbose_name': 'Document', + 'verbose_name_plural': 'Documents', + 'db_table': 'impress_document', + 'ordering': ('title',), + }, + ), migrations.CreateModel( name='Template', fields=[ @@ -66,6 +82,24 @@ class Migration(migrations.Migration): ('objects', django.contrib.auth.models.UserManager()), ], ), + migrations.CreateModel( + name='DocumentAccess', + 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')), + ('team', models.CharField(blank=True, max_length=100)), + ('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)), + ('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.document')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Document/user relation', + 'verbose_name_plural': 'Document/user relations', + 'db_table': 'impress_document_access', + 'ordering': ('-created_at',), + }, + ), migrations.CreateModel( name='TemplateAccess', fields=[ @@ -75,14 +109,27 @@ class Migration(migrations.Migration): ('team', models.CharField(blank=True, max_length=100)), ('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)), ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.template')), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], options={ 'verbose_name': 'Template/user relation', 'verbose_name_plural': 'Template/user relations', 'db_table': 'impress_template_access', + 'ordering': ('-created_at',), }, ), + migrations.AddConstraint( + model_name='documentaccess', + constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'document'), name='unique_document_user', violation_error_message='This user is already in this document.'), + ), + migrations.AddConstraint( + model_name='documentaccess', + constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'document'), name='unique_document_team', violation_error_message='This team is already in this document.'), + ), + migrations.AddConstraint( + model_name='documentaccess', + constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_document_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'), + ), migrations.AddConstraint( model_name='templateaccess', constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'template'), name='unique_template_user', violation_error_message='This user is already in this template.'), @@ -93,6 +140,6 @@ class Migration(migrations.Migration): ), migrations.AddConstraint( model_name='templateaccess', - constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_either_user_or_team', violation_error_message='Either user or team must be set, not both.'), + constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_template_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'), ), ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index c09daacb..6ccf73aa 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -22,6 +22,23 @@ from weasyprint import CSS, HTML from weasyprint.text.fonts import FontConfiguration +def get_resource_roles(resource, user): + """Compute the roles a user has on a resource.""" + roles = [] + if user.is_authenticated: + try: + roles = resource.user_roles or [] + except AttributeError: + teams = user.get_teams() + try: + roles = resource.accesses.filter( + models.Q(user=user) | models.Q(team__in=teams), + ).values_list("role", flat=True) + except (models.ObjectDoesNotExist, IndexError): + roles = [] + return roles + + class RoleChoices(models.TextChoices): """Defines the possible roles a user can have in a template.""" @@ -156,6 +173,154 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): return [] +class BaseAccess(BaseModel): + """Base model for accesses to handle resources.""" + + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + null=True, + blank=True, + ) + team = models.CharField(max_length=100, blank=True) + role = models.CharField( + max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER + ) + + class Meta: + abstract = True + + def _get_abilities(self, resource, user): + """ + Compute and return abilities for a given user taking into account + the current state of the object. + """ + roles = [] + if user.is_authenticated: + teams = user.get_teams() + try: + roles = self.user_roles or [] + except AttributeError: + try: + roles = resource.accesses.filter( + models.Q(user=user) | models.Q(team__in=teams), + ).values_list("role", flat=True) + except (self._meta.model.DoesNotExist, IndexError): + roles = [] + + is_owner_or_admin = bool( + set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}) + ) + if self.role == RoleChoices.OWNER: + can_delete = ( + RoleChoices.OWNER in roles + and resource.accesses.filter(role=RoleChoices.OWNER).count() > 1 + ) + set_role_to = [RoleChoices.ADMIN, RoleChoices.MEMBER] if can_delete else [] + else: + can_delete = is_owner_or_admin + set_role_to = [] + if RoleChoices.OWNER in roles: + set_role_to.append(RoleChoices.OWNER) + if is_owner_or_admin: + set_role_to.extend([RoleChoices.ADMIN, RoleChoices.MEMBER]) + + # Remove the current role as we don't want to propose it as an option + try: + set_role_to.remove(self.role) + except ValueError: + pass + + return { + "destroy": can_delete, + "update": bool(set_role_to), + "retrieve": bool(roles), + "set_role_to": set_role_to, + } + + +class Document(BaseModel): + """Pad document carrying the content.""" + + title = models.CharField(_("title"), max_length=255) + is_public = models.BooleanField( + _("public"), + default=False, + help_text=_("Whether this document is public for anyone to use."), + ) + + class Meta: + db_table = "impress_document" + ordering = ("title",) + verbose_name = _("Document") + verbose_name_plural = _("Documents") + + def __str__(self): + return self.title + + def get_abilities(self, user): + """ + Compute and return abilities for a given user on the document. + """ + roles = get_resource_roles(self, user) + is_owner_or_admin = bool( + set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}) + ) + can_get = self.is_public or bool(roles) + + return { + "destroy": RoleChoices.OWNER in roles, + "manage_accesses": is_owner_or_admin, + "update": is_owner_or_admin, + "retrieve": can_get, + } + + +class DocumentAccess(BaseAccess): + """Relation model to give access to a document for a user or a team with a role.""" + + document = models.ForeignKey( + Document, + on_delete=models.CASCADE, + related_name="accesses", + ) + + class Meta: + db_table = "impress_document_access" + ordering = ("-created_at",) + verbose_name = _("Document/user relation") + verbose_name_plural = _("Document/user relations") + constraints = [ + models.UniqueConstraint( + fields=["user", "document"], + condition=models.Q(user__isnull=False), # Exclude null users + name="unique_document_user", + violation_error_message=_("This user is already in this document."), + ), + models.UniqueConstraint( + fields=["team", "document"], + condition=models.Q(team__gt=""), # Exclude empty string teams + name="unique_document_team", + violation_error_message=_("This team is already in this document."), + ), + models.CheckConstraint( + check=models.Q(user__isnull=False, team="") + | models.Q(user__isnull=True, team__gt=""), + name="check_document_access_either_user_or_team", + violation_error_message=_("Either user or team must be set, not both."), + ), + ] + + def __str__(self): + return f"{self.user!s} is {self.role:s} in document {self.document!s}" + + def get_abilities(self, user): + """ + Compute and return abilities for a given user on the document access. + """ + return self._get_abilities(self.document, user) + + class Template(BaseModel): """HTML and CSS code used for formatting the print around the MarkDown body.""" @@ -178,6 +343,24 @@ class Template(BaseModel): def __str__(self): return self.title + def get_abilities(self, user): + """ + Compute and return abilities for a given user on the template. + """ + roles = get_resource_roles(self, user) + is_owner_or_admin = bool( + set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}) + ) + can_get = self.is_public or bool(roles) + + return { + "destroy": RoleChoices.OWNER in roles, + "generate_document": can_get, + "manage_accesses": is_owner_or_admin, + "update": is_owner_or_admin, + "retrieve": can_get, + } + def generate_document(self, body): """ Generate and return a PDF document for this template around the @@ -201,38 +384,8 @@ class Template(BaseModel): ) return document_html.write_pdf(stylesheets=[css], zoom=1) - def get_abilities(self, user): - """ - Compute and return abilities for a given user on the template. - """ - # Compute user role - roles = [] - if user.is_authenticated: - try: - roles = self.user_roles or [] - except AttributeError: - teams = user.get_teams() - try: - roles = self.accesses.filter( - models.Q(user=user) | models.Q(team__in=teams) - ).values_list("role", flat=True) - except (TemplateAccess.DoesNotExist, IndexError): - roles = [] - is_owner_or_admin = bool( - set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}) - ) - can_get = self.is_public or bool(roles) - return { - "destroy": RoleChoices.OWNER in roles, - "generate_document": can_get, - "manage_accesses": is_owner_or_admin, - "update": is_owner_or_admin, - "retrieve": can_get, - } - - -class TemplateAccess(BaseModel): +class TemplateAccess(BaseAccess): """Relation model to give access to a template for a user or a team with a role.""" template = models.ForeignKey( @@ -240,20 +393,10 @@ class TemplateAccess(BaseModel): on_delete=models.CASCADE, related_name="accesses", ) - user = models.ForeignKey( - User, - on_delete=models.CASCADE, - related_name="accesses", - null=True, - blank=True, - ) - team = models.CharField(max_length=100, blank=True) - role = models.CharField( - max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER - ) class Meta: db_table = "impress_template_access" + ordering = ("-created_at",) verbose_name = _("Template/user relation") verbose_name_plural = _("Template/user relations") constraints = [ @@ -272,7 +415,7 @@ class TemplateAccess(BaseModel): models.CheckConstraint( check=models.Q(user__isnull=False, team="") | models.Q(user__isnull=True, team__gt=""), - name="check_either_user_or_team", + name="check_template_access_either_user_or_team", violation_error_message=_("Either user or team must be set, not both."), ), ] @@ -282,51 +425,6 @@ class TemplateAccess(BaseModel): def get_abilities(self, user): """ - Compute and return abilities for a given user taking into account - the current state of the object. + Compute and return abilities for a given user on the template access. """ - is_template_owner_or_admin = False - - roles = [] - if user.is_authenticated: - teams = user.get_teams() - try: - roles = self.user_roles or [] - except AttributeError: - try: - roles = self._meta.model.objects.filter( - models.Q(user=user) | models.Q(team__in=teams), - template=self.template_id, - ).values_list("role", flat=True) - except (self._meta.model.DoesNotExist, IndexError): - roles = [] - - is_template_owner_or_admin = bool( - set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}) - ) - if self.role == RoleChoices.OWNER: - can_delete = ( - RoleChoices.OWNER in roles - and self.template.accesses.filter(role=RoleChoices.OWNER).count() > 1 - ) - set_role_to = [RoleChoices.ADMIN, RoleChoices.MEMBER] if can_delete else [] - else: - can_delete = is_template_owner_or_admin - set_role_to = [] - if RoleChoices.OWNER in roles: - set_role_to.append(RoleChoices.OWNER) - if is_template_owner_or_admin: - set_role_to.extend([RoleChoices.ADMIN, RoleChoices.MEMBER]) - - # Remove the current role as we don't want to propose it as an option - try: - set_role_to.remove(self.role) - except ValueError: - pass - - return { - "destroy": can_delete, - "update": bool(set_role_to), - "retrieve": bool(roles), - "set_role_to": set_role_to, - } + return self._get_abilities(self.template, user) diff --git a/src/backend/core/tests/documents/test_api_documents_create.py b/src/backend/core/tests/documents/test_api_documents_create.py new file mode 100644 index 00000000..58f7b667 --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_create.py @@ -0,0 +1,47 @@ +""" +Tests for Documents API endpoint in impress's core app: create +""" +import pytest +from rest_framework.test import APIClient + +from core import factories +from core.models import Document + +pytestmark = pytest.mark.django_db + + +def test_api_documents_create_anonymous(): + """Anonymous users should not be allowed to create documents.""" + response = APIClient().post( + "/api/v1.0/documents/", + { + "title": "my document", + }, + ) + + assert response.status_code == 401 + assert not Document.objects.exists() + + +def test_api_documents_create_authenticated(): + """ + Authenticated users should be able to create documents and should automatically be declared + as the owner of the newly created document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.post( + "/api/v1.0/documents/", + { + "title": "my document", + }, + format="json", + ) + + assert response.status_code == 201 + document = Document.objects.get() + assert document.title == "my document" + assert document.accesses.filter(role="owner", user=user).exists() diff --git a/src/backend/core/tests/documents/test_api_documents_delete.py b/src/backend/core/tests/documents/test_api_documents_delete.py new file mode 100644 index 00000000..d0a5ab1e --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_delete.py @@ -0,0 +1,106 @@ +""" +Tests for Documents API endpoint in impress's core app: delete +""" +import random + +import pytest +from rest_framework.test import APIClient + +from core import factories, models +from core.tests.conftest import TEAM, USER, VIA + +pytestmark = pytest.mark.django_db + + +def test_api_documents_delete_anonymous(): + """Anonymous users should not be allowed to destroy a document.""" + document = factories.DocumentFactory() + + response = APIClient().delete( + f"/api/v1.0/documents/{document.id!s}/", + ) + + assert response.status_code == 401 + assert models.Document.objects.count() == 1 + + +def test_api_documents_delete_authenticated_unrelated(): + """ + Authenticated users should not be allowed to delete a document to which they are not + related. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + is_public = random.choice([True, False]) + document = factories.DocumentFactory(is_public=is_public) + + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/", + ) + + assert response.status_code == 403 if is_public else 404 + assert models.Document.objects.count() == 1 + + +@pytest.mark.parametrize("role", ["member", "administrator"]) +@pytest.mark.parametrize("via", VIA) +def test_api_documents_delete_authenticated_member_or_administrator( + via, role, mock_user_get_teams +): + """ + Authenticated users should not be allowed to delete a document for which they are + only a member or administrator. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role=role) + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role=role + ) + + response = client.delete( + f"/api/v1.0/documents/{document.id}/", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + assert models.Document.objects.count() == 1 + + +@pytest.mark.parametrize("via", VIA) +def test_api_documents_delete_authenticated_owner(via, mock_user_get_teams): + """ + Authenticated users should be able to delete a document they own. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role="owner") + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="owner" + ) + + response = client.delete( + f"/api/v1.0/documents/{document.id}/", + ) + + assert response.status_code == 204 + assert models.Document.objects.exists() is False diff --git a/src/backend/core/tests/documents/test_api_documents_list.py b/src/backend/core/tests/documents/test_api_documents_list.py new file mode 100644 index 00000000..561214ff --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_list.py @@ -0,0 +1,166 @@ +""" +Tests for Documents API endpoint in impress's core app: list +""" +from unittest import mock + +import pytest +from rest_framework.pagination import PageNumberPagination +from rest_framework.status import HTTP_200_OK +from rest_framework.test import APIClient + +from core import factories + +pytestmark = pytest.mark.django_db + + +def test_api_documents_list_anonymous(): + """Anonymous users should only be able to list public documents.""" + factories.DocumentFactory.create_batch(2, is_public=False) + documents = factories.DocumentFactory.create_batch(2, is_public=True) + expected_ids = {str(document.id) for document in documents} + + response = APIClient().get("/api/v1.0/documents/") + + assert response.status_code == HTTP_200_OK + results = response.json()["results"] + assert len(results) == 2 + results_id = {result["id"] for result in results} + assert expected_ids == results_id + + +def test_api_documents_list_authenticated_direct(): + """ + Authenticated users should be able to list documents they are a direct + owner/administrator/member of. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + related_documents = [ + access.document + for access in factories.UserDocumentAccessFactory.create_batch(5, user=user) + ] + public_documents = factories.DocumentFactory.create_batch(2, is_public=True) + factories.DocumentFactory.create_batch(2, is_public=False) + + expected_ids = { + str(document.id) for document in related_documents + public_documents + } + + response = client.get( + "/api/v1.0/documents/", + ) + + assert response.status_code == HTTP_200_OK + results = response.json()["results"] + assert len(results) == 7 + results_id = {result["id"] for result in results} + assert expected_ids == results_id + + +def test_api_documents_list_authenticated_via_team(mock_user_get_teams): + """ + Authenticated users should be able to list documents they are a + owner/administrator/member of via a team. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + mock_user_get_teams.return_value = ["team1", "team2", "unknown"] + + documents_team1 = [ + access.document + for access in factories.TeamDocumentAccessFactory.create_batch(2, team="team1") + ] + documents_team2 = [ + access.document + for access in factories.TeamDocumentAccessFactory.create_batch(3, team="team2") + ] + public_documents = factories.DocumentFactory.create_batch(2, is_public=True) + factories.DocumentFactory.create_batch(2, is_public=False) + + expected_ids = { + str(document.id) + for document in documents_team1 + documents_team2 + public_documents + } + + response = client.get("/api/v1.0/documents/") + + assert response.status_code == HTTP_200_OK + results = response.json()["results"] + assert len(results) == 7 + results_id = {result["id"] for result in results} + assert expected_ids == results_id + + +@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2) +def test_api_documents_list_pagination( + _mock_page_size, +): + """Pagination should work as expected.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document_ids = [ + str(access.document_id) + for access in factories.UserDocumentAccessFactory.create_batch(3, user=user) + ] + + # Get page 1 + response = client.get( + "/api/v1.0/documents/", + ) + + assert response.status_code == HTTP_200_OK + content = response.json() + + assert content["count"] == 3 + assert content["next"] == "http://testserver/api/v1.0/documents/?page=2" + assert content["previous"] is None + + assert len(content["results"]) == 2 + for item in content["results"]: + document_ids.remove(item["id"]) + + # Get page 2 + response = client.get( + "/api/v1.0/documents/?page=2", + ) + + assert response.status_code == HTTP_200_OK + content = response.json() + + assert content["count"] == 3 + assert content["next"] is None + assert content["previous"] == "http://testserver/api/v1.0/documents/" + + assert len(content["results"]) == 1 + document_ids.remove(content["results"][0]["id"]) + assert document_ids == [] + + +def test_api_documents_list_authenticated_distinct(): + """A document with several related users should only be listed once.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + other_user = factories.UserFactory() + + document = factories.DocumentFactory(users=[user, other_user], is_public=True) + + response = client.get( + "/api/v1.0/documents/", + ) + + assert response.status_code == HTTP_200_OK + content = response.json() + assert len(content["results"]) == 1 + assert content["results"][0]["id"] == str(document.id) diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py new file mode 100644 index 00000000..078945db --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -0,0 +1,441 @@ +""" +Tests for Documents API endpoint in impress's core app: retrieve +""" +import pytest +from rest_framework.test import APIClient + +from core import factories + +pytestmark = pytest.mark.django_db + + +def test_api_documents_retrieve_anonymous_public(): + """Anonymous users should be allowed to retrieve public documents.""" + document = factories.DocumentFactory(is_public=True) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/") + + assert response.status_code == 200 + assert response.json() == { + "id": str(document.id), + "abilities": { + "destroy": False, + "manage_accesses": False, + "retrieve": True, + "update": False, + }, + "accesses": [], + "title": document.title, + } + + +def test_api_documents_retrieve_anonymous_not_public(): + """Anonymous users should not be able to retrieve a document that is not public.""" + document = factories.DocumentFactory(is_public=False) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/") + + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + +def test_api_documents_retrieve_authenticated_unrelated_public(): + """ + Authenticated users should be able to retrieve a public document to which they are + not related. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(is_public=True) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/", + ) + assert response.status_code == 200 + assert response.json() == { + "id": str(document.id), + "abilities": { + "destroy": False, + "manage_accesses": False, + "retrieve": True, + "update": False, + }, + "accesses": [], + "title": document.title, + } + + +def test_api_documents_retrieve_authenticated_unrelated_not_public(): + """ + Authenticated users should not be allowed to retrieve a document that is not public and + to which they are not related. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(is_public=False) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/", + ) + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + +def test_api_documents_retrieve_authenticated_related_direct(): + """ + Authenticated users should be allowed to retrieve a document to which they + are directly related whatever the role. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + access1 = factories.UserDocumentAccessFactory(document=document, user=user) + access2 = factories.UserDocumentAccessFactory(document=document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/", + ) + assert response.status_code == 200 + content = response.json() + assert sorted(content.pop("accesses"), key=lambda x: x["user"]) == sorted( + [ + { + "id": str(access1.id), + "user": str(user.id), + "team": "", + "role": access1.role, + "abilities": access1.get_abilities(user), + }, + { + "id": str(access2.id), + "user": str(access2.user.id), + "team": "", + "role": access2.role, + "abilities": access2.get_abilities(user), + }, + ], + key=lambda x: x["user"], + ) + assert response.json() == { + "id": str(document.id), + "title": document.title, + "abilities": document.get_abilities(user), + } + + +def test_api_documents_retrieve_authenticated_related_team_none(mock_user_get_teams): + """ + Authenticated users should not be able to retrieve a document related to teams in + which the user is not. + """ + mock_user_get_teams.return_value = [] + + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(is_public=False) + + factories.TeamDocumentAccessFactory( + document=document, team="members", role="member" + ) + factories.TeamDocumentAccessFactory( + document=document, team="administrators", role="administrator" + ) + factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner") + factories.TeamDocumentAccessFactory(document=document) + factories.TeamDocumentAccessFactory() + + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + +@pytest.mark.parametrize( + "teams", + [ + ["members"], + ["unknown", "members"], + ], +) +def test_api_documents_retrieve_authenticated_related_team_members( + teams, mock_user_get_teams +): + """ + Authenticated users should be allowed to retrieve a document to which they + are related via a team whatever the role and see all its accesses. + """ + mock_user_get_teams.return_value = teams + + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(is_public=False) + + access_member = factories.TeamDocumentAccessFactory( + document=document, team="members", role="member" + ) + access_administrator = factories.TeamDocumentAccessFactory( + document=document, team="administrators", role="administrator" + ) + access_owner = factories.TeamDocumentAccessFactory( + document=document, team="owners", role="owner" + ) + other_access = factories.TeamDocumentAccessFactory(document=document) + factories.TeamDocumentAccessFactory() + + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + assert response.status_code == 200 + content = response.json() + expected_abilities = { + "destroy": False, + "retrieve": True, + "set_role_to": [], + "update": False, + } + assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted( + [ + { + "id": str(access_member.id), + "user": None, + "team": "members", + "role": access_member.role, + "abilities": expected_abilities, + }, + { + "id": str(access_administrator.id), + "user": None, + "team": "administrators", + "role": access_administrator.role, + "abilities": expected_abilities, + }, + { + "id": str(access_owner.id), + "user": None, + "team": "owners", + "role": access_owner.role, + "abilities": expected_abilities, + }, + { + "id": str(other_access.id), + "user": None, + "team": other_access.team, + "role": other_access.role, + "abilities": expected_abilities, + }, + ], + key=lambda x: x["id"], + ) + assert response.json() == { + "id": str(document.id), + "title": document.title, + "abilities": document.get_abilities(user), + } + + +@pytest.mark.parametrize( + "teams", + [ + ["administrators"], + ["members", "administrators"], + ["unknown", "administrators"], + ], +) +def test_api_documents_retrieve_authenticated_related_team_administrators( + teams, mock_user_get_teams +): + """ + Authenticated users should be allowed to retrieve a document to which they + are related via a team whatever the role and see all its accesses. + """ + mock_user_get_teams.return_value = teams + + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(is_public=False) + + access_member = factories.TeamDocumentAccessFactory( + document=document, team="members", role="member" + ) + access_administrator = factories.TeamDocumentAccessFactory( + document=document, team="administrators", role="administrator" + ) + access_owner = factories.TeamDocumentAccessFactory( + document=document, team="owners", role="owner" + ) + other_access = factories.TeamDocumentAccessFactory(document=document) + factories.TeamDocumentAccessFactory() + + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + + # pylint: disable=R0801 + assert response.status_code == 200 + content = response.json() + assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted( + [ + { + "id": str(access_member.id), + "user": None, + "team": "members", + "role": "member", + "abilities": { + "destroy": True, + "retrieve": True, + "set_role_to": ["administrator"], + "update": True, + }, + }, + { + "id": str(access_administrator.id), + "user": None, + "team": "administrators", + "role": "administrator", + "abilities": { + "destroy": True, + "retrieve": True, + "set_role_to": ["member"], + "update": True, + }, + }, + { + "id": str(access_owner.id), + "user": None, + "team": "owners", + "role": "owner", + "abilities": { + "destroy": False, + "retrieve": True, + "set_role_to": [], + "update": False, + }, + }, + { + "id": str(other_access.id), + "user": None, + "team": other_access.team, + "role": other_access.role, + "abilities": other_access.get_abilities(user), + }, + ], + key=lambda x: x["id"], + ) + assert response.json() == { + "id": str(document.id), + "title": document.title, + "abilities": document.get_abilities(user), + } + + +@pytest.mark.parametrize( + "teams", + [ + ["owners"], + ["owners", "administrators"], + ["members", "administrators", "owners"], + ["unknown", "owners"], + ], +) +def test_api_documents_retrieve_authenticated_related_team_owners( + teams, mock_user_get_teams +): + """ + Authenticated users should be allowed to retrieve a document to which they + are related via a team whatever the role and see all its accesses. + """ + mock_user_get_teams.return_value = teams + + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(is_public=False) + + access_member = factories.TeamDocumentAccessFactory( + document=document, team="members", role="member" + ) + access_administrator = factories.TeamDocumentAccessFactory( + document=document, team="administrators", role="administrator" + ) + access_owner = factories.TeamDocumentAccessFactory( + document=document, team="owners", role="owner" + ) + other_access = factories.TeamDocumentAccessFactory(document=document) + factories.TeamDocumentAccessFactory() + + response = client.get(f"/api/v1.0/documents/{document.id!s}/") + + # pylint: disable=R0801 + assert response.status_code == 200 + content = response.json() + assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted( + [ + { + "id": str(access_member.id), + "user": None, + "team": "members", + "role": "member", + "abilities": { + "destroy": True, + "retrieve": True, + "set_role_to": ["owner", "administrator"], + "update": True, + }, + }, + { + "id": str(access_administrator.id), + "user": None, + "team": "administrators", + "role": "administrator", + "abilities": { + "destroy": True, + "retrieve": True, + "set_role_to": ["owner", "member"], + "update": True, + }, + }, + { + "id": str(access_owner.id), + "user": None, + "team": "owners", + "role": "owner", + "abilities": { + # editable only if there is another owner role than the user's team... + "destroy": other_access.role == "owner", + "retrieve": True, + "set_role_to": ["administrator", "member"] + if other_access.role == "owner" + else [], + "update": other_access.role == "owner", + }, + }, + { + "id": str(other_access.id), + "user": None, + "team": other_access.team, + "role": other_access.role, + "abilities": other_access.get_abilities(user), + }, + ], + key=lambda x: x["id"], + ) + assert response.json() == { + "id": str(document.id), + "title": document.title, + "abilities": document.get_abilities(user), + } diff --git a/src/backend/core/tests/documents/test_api_documents_update.py b/src/backend/core/tests/documents/test_api_documents_update.py new file mode 100644 index 00000000..8d1c98fa --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_update.py @@ -0,0 +1,230 @@ +""" +Tests for Documents API endpoint in impress's core app: update +""" +import random + +import pytest +from rest_framework.test import APIClient + +from core import factories +from core.api import serializers +from core.tests.conftest import TEAM, USER, VIA + +pytestmark = pytest.mark.django_db + + +def test_api_documents_update_anonymous(): + """Anonymous users should not be allowed to update a document.""" + document = factories.DocumentFactory() + old_document_values = serializers.DocumentSerializer(instance=document).data + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + response = APIClient().put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + document.refresh_from_db() + document_values = serializers.DocumentSerializer(instance=document).data + assert document_values == old_document_values + + +def test_api_documents_update_authenticated_unrelated(): + """ + Authenticated users should not be allowed to update a document to which they are not related. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(is_public=False) + old_document_values = serializers.DocumentSerializer(instance=document).data + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + document.refresh_from_db() + document_values = serializers.DocumentSerializer(instance=document).data + assert document_values == old_document_values + + +@pytest.mark.parametrize("via", VIA) +def test_api_documents_update_authenticated_members(via, mock_user_get_teams): + """ + Users who are members of a document but not administrators should + not be allowed to update it. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role="member") + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="member" + ) + + old_document_values = serializers.DocumentSerializer(instance=document).data + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + document.refresh_from_db() + document_values = serializers.DocumentSerializer(instance=document).data + assert document_values == old_document_values + + +@pytest.mark.parametrize("role", ["administrator", "owner"]) +@pytest.mark.parametrize("via", VIA) +def test_api_documents_update_authenticated_administrator_or_owner( + via, role, mock_user_get_teams +): + """Administrator or owner of a document should be allowed to update it.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role=role) + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role=role + ) + + old_document_values = serializers.DocumentSerializer(instance=document).data + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 200 + + document.refresh_from_db() + document_values = serializers.DocumentSerializer(instance=document).data + for key, value in document_values.items(): + if key in ["id", "accesses"]: + assert value == old_document_values[key] + else: + assert value == new_document_values[key] + + +@pytest.mark.parametrize("via", VIA) +def test_api_documents_update_authenticated_owners(via, mock_user_get_teams): + """Administrators of a document should be allowed to update it.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role="owner") + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="owner" + ) + + old_document_values = serializers.DocumentSerializer(instance=document).data + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", new_document_values, format="json" + ) + + assert response.status_code == 200 + document.refresh_from_db() + document_values = serializers.DocumentSerializer(instance=document).data + for key, value in document_values.items(): + if key in ["id", "accesses"]: + assert value == old_document_values[key] + else: + assert value == new_document_values[key] + + +@pytest.mark.parametrize("via", VIA) +def test_api_documents_update_administrator_or_owner_of_another( + via, mock_user_get_teams +): + """ + Being administrator or owner of a document should not grant authorization to update + another document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory( + document=document, user=user, role=random.choice(["administrator", "owner"]) + ) + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, + team="lasuite", + role=random.choice(["administrator", "owner"]), + ) + + is_public = random.choice([True, False]) + document = factories.DocumentFactory(title="Old title", is_public=is_public) + old_document_values = serializers.DocumentSerializer(instance=document).data + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + + assert response.status_code == 403 if is_public else 404 + + document.refresh_from_db() + document_values = serializers.DocumentSerializer(instance=document).data + assert document_values == old_document_values diff --git a/src/backend/core/tests/templates/test_api_templates_generate_document.py b/src/backend/core/tests/templates/test_api_templates_generate_document.py index 9b7aa3a9..8d312650 100644 --- a/src/backend/core/tests/templates/test_api_templates_generate_document.py +++ b/src/backend/core/tests/templates/test_api_templates_generate_document.py @@ -107,7 +107,7 @@ def test_api_templates_generate_document_related(via, mock_user_get_teams): data = {"body": "# Test markdown body"} response = client.post( - f"/api/v1.0/templates/{access.template.id!s}/generate-document/", + f"/api/v1.0/templates/{access.template_id!s}/generate-document/", data, format="json", ) diff --git a/src/backend/core/tests/templates/test_api_templates_list.py b/src/backend/core/tests/templates/test_api_templates_list.py index 1ffdfcef..c63e7b73 100644 --- a/src/backend/core/tests/templates/test_api_templates_list.py +++ b/src/backend/core/tests/templates/test_api_templates_list.py @@ -108,7 +108,7 @@ def test_api_templates_list_pagination( client.force_login(user) template_ids = [ - str(access.template.id) + str(access.template_id) for access in factories.UserTemplateAccessFactory.create_batch(3, user=user) ] diff --git a/src/backend/core/tests/test_api_document_accesses.py b/src/backend/core/tests/test_api_document_accesses.py new file mode 100644 index 00000000..e5299049 --- /dev/null +++ b/src/backend/core/tests/test_api_document_accesses.py @@ -0,0 +1,967 @@ +""" +Test document accesses API endpoints for users in impress's core app. +""" +import random +from uuid import uuid4 + +import pytest +from rest_framework.test import APIClient + +from core import factories, models +from core.api import serializers +from core.tests.conftest import TEAM, USER, VIA + +pytestmark = pytest.mark.django_db + + +def test_api_document_accesses_list_anonymous(): + """Anonymous users should not be allowed to list document accesses.""" + document = factories.DocumentFactory() + factories.UserDocumentAccessFactory.create_batch(2, document=document) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/accesses/") + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_document_accesses_list_authenticated_unrelated(): + """ + Authenticated users should not be allowed to list document accesses for a document + to which they are not related. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + factories.UserDocumentAccessFactory.create_batch(3, document=document) + + # Accesses for other documents to which the user is related should not be listed either + other_access = factories.UserDocumentAccessFactory(user=user) + factories.UserDocumentAccessFactory(document=other_access.document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/accesses/", + ) + assert response.status_code == 200 + assert response.json() == { + "count": 0, + "next": None, + "previous": None, + "results": [], + } + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_list_authenticated_related(via, mock_user_get_teams): + """ + Authenticated users should be able to list document accesses for a document + to which they are directly related, whatever their role in the document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + user_access = models.DocumentAccess.objects.create( + document=document, + user=user, + role=random.choice(models.RoleChoices.choices)[0], + ) + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + user_access = models.DocumentAccess.objects.create( + document=document, + team="lasuite", + role=random.choice(models.RoleChoices.choices)[0], + ) + + access1 = factories.TeamDocumentAccessFactory(document=document) + access2 = factories.UserDocumentAccessFactory(document=document) + + # Accesses for other documents to which the user is related should not be listed either + other_access = factories.UserDocumentAccessFactory(user=user) + factories.UserDocumentAccessFactory(document=other_access.document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/accesses/", + ) + + assert response.status_code == 200 + content = response.json() + assert len(content["results"]) == 3 + assert sorted(content["results"], key=lambda x: x["id"]) == sorted( + [ + { + "id": str(user_access.id), + "user": str(user.id) if via == "user" else None, + "team": "lasuite" if via == "team" else "", + "role": user_access.role, + "abilities": user_access.get_abilities(user), + }, + { + "id": str(access1.id), + "user": None, + "team": access1.team, + "role": access1.role, + "abilities": access1.get_abilities(user), + }, + { + "id": str(access2.id), + "user": str(access2.user.id), + "team": "", + "role": access2.role, + "abilities": access2.get_abilities(user), + }, + ], + key=lambda x: x["id"], + ) + + +def test_api_document_accesses_retrieve_anonymous(): + """ + Anonymous users should not be allowed to retrieve a document access. + """ + access = factories.UserDocumentAccessFactory() + + response = APIClient().get( + f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_document_accesses_retrieve_authenticated_unrelated(): + """ + Authenticated users should not be allowed to retrieve a document access for + a document to which they are not related. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + access = factories.UserDocumentAccessFactory(document=document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + # Accesses related to another document should be excluded even if the user is related to it + for access in [ + factories.UserDocumentAccessFactory(), + factories.UserDocumentAccessFactory(user=user), + ]: + response = client.get( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_retrieve_authenticated_related(via, mock_user_get_teams): + """ + A user who is related to a document should be allowed to retrieve the + associated document user accesses. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user) + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory(document=document, team="lasuite") + + access = factories.UserDocumentAccessFactory(document=document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 200 + assert response.json() == { + "id": str(access.id), + "user": str(access.user.id), + "team": "", + "role": access.role, + "abilities": access.get_abilities(user), + } + + +def test_api_document_accesses_create_anonymous(): + """Anonymous users should not be allowed to create document accesses.""" + user = factories.UserFactory() + document = factories.DocumentFactory() + + response = APIClient().post( + f"/api/v1.0/documents/{document.id!s}/accesses/", + { + "user": str(user.id), + "document": str(document.id), + "role": random.choice(models.RoleChoices.choices)[0], + }, + format="json", + ) + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + assert models.DocumentAccess.objects.exists() is False + + +def test_api_document_accesses_create_authenticated_unrelated(): + """ + Authenticated users should not be allowed to create document accesses for a document to + which they are not related. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + other_user = factories.UserFactory() + document = factories.DocumentFactory() + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/accesses/", + { + "user": str(other_user.id), + }, + format="json", + ) + + assert response.status_code == 403 + assert not models.DocumentAccess.objects.filter(user=other_user).exists() + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_create_authenticated_member(via, mock_user_get_teams): + """Members of a document should not be allowed to create document accesses.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role="member") + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="member" + ) + + other_user = factories.UserFactory() + + for role in [role[0] for role in models.RoleChoices.choices]: + response = client.post( + f"/api/v1.0/documents/{document.id!s}/accesses/", + { + "user": str(other_user.id), + "role": role, + }, + format="json", + ) + + assert response.status_code == 403 + + assert not models.DocumentAccess.objects.filter(user=other_user).exists() + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_create_authenticated_administrator( + via, mock_user_get_teams +): + """ + Administrators of a document should be able to create document accesses + except for the "owner" role. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory( + document=document, user=user, role="administrator" + ) + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="administrator" + ) + + other_user = factories.UserFactory() + + # It should not be allowed to create an owner access + response = client.post( + f"/api/v1.0/documents/{document.id!s}/accesses/", + { + "user": str(other_user.id), + "role": "owner", + }, + format="json", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "Only owners of a resource can assign other users as owners." + } + + # It should be allowed to create a lower access + role = random.choice( + [role[0] for role in models.RoleChoices.choices if role[0] != "owner"] + ) + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/accesses/", + { + "user": str(other_user.id), + "role": role, + }, + format="json", + ) + + assert response.status_code == 201 + assert models.DocumentAccess.objects.filter(user=other_user).count() == 1 + new_document_access = models.DocumentAccess.objects.filter(user=other_user).get() + assert response.json() == { + "abilities": new_document_access.get_abilities(user), + "id": str(new_document_access.id), + "team": "", + "role": role, + "user": str(other_user.id), + } + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_create_authenticated_owner(via, mock_user_get_teams): + """ + Owners of a document should be able to create document accesses whatever the role. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role="owner") + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="owner" + ) + + other_user = factories.UserFactory() + + role = random.choice([role[0] for role in models.RoleChoices.choices]) + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/accesses/", + { + "user": str(other_user.id), + "role": role, + }, + format="json", + ) + + assert response.status_code == 201 + assert models.DocumentAccess.objects.filter(user=other_user).count() == 1 + new_document_access = models.DocumentAccess.objects.filter(user=other_user).get() + assert response.json() == { + "id": str(new_document_access.id), + "user": str(other_user.id), + "team": "", + "role": role, + "abilities": new_document_access.get_abilities(user), + } + + +def test_api_document_accesses_update_anonymous(): + """Anonymous users should not be allowed to update a document access.""" + access = factories.UserDocumentAccessFactory() + old_values = serializers.DocumentAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.choices)[0], + } + + api_client = APIClient() + for field, value in new_values.items(): + response = api_client.put( + f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + ) + assert response.status_code == 401 + + access.refresh_from_db() + updated_values = serializers.DocumentAccessSerializer(instance=access).data + assert updated_values == old_values + + +def test_api_document_accesses_update_authenticated_unrelated(): + """ + Authenticated users should not be allowed to update a document access for a document to which + they are not related. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + access = factories.UserDocumentAccessFactory() + old_values = serializers.DocumentAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.choices)[0], + } + + for field, value in new_values.items(): + response = client.put( + f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + ) + assert response.status_code == 403 + + access.refresh_from_db() + updated_values = serializers.DocumentAccessSerializer(instance=access).data + assert updated_values == old_values + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_update_authenticated_member(via, mock_user_get_teams): + """Members of a document should not be allowed to update its accesses.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role="member") + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="member" + ) + + access = factories.UserDocumentAccessFactory(document=document) + old_values = serializers.DocumentAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.choices)[0], + } + + for field, value in new_values.items(): + response = client.put( + f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + ) + assert response.status_code == 403 + + access.refresh_from_db() + updated_values = serializers.DocumentAccessSerializer(instance=access).data + assert updated_values == old_values + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_update_administrator_except_owner( + via, mock_user_get_teams +): + """ + A user who is a direct administrator in a document should be allowed to update a user + access for this document, as long as they don't try to set the role to owner. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory( + document=document, user=user, role="administrator" + ) + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="administrator" + ) + + access = factories.UserDocumentAccessFactory( + document=document, + role=random.choice(["administrator", "member"]), + ) + old_values = serializers.DocumentAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user_id": factories.UserFactory().id, + "role": random.choice(["administrator", "member"]), + } + + for field, value in new_values.items(): + new_data = {**old_values, field: value} + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + ) + + if ( + new_data["role"] == old_values["role"] + ): # we are not really updating the role + assert response.status_code == 403 + else: + assert response.status_code == 200 + + access.refresh_from_db() + updated_values = serializers.DocumentAccessSerializer(instance=access).data + if field == "role": + assert updated_values == {**old_values, "role": new_values["role"]} + else: + assert updated_values == old_values + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_update_administrator_from_owner( + via, mock_user_get_teams +): + """ + A user who is an administrator in a document, should not be allowed to update + the user access of an "owner" for this document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory( + document=document, user=user, role="administrator" + ) + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="administrator" + ) + + other_user = factories.UserFactory() + access = factories.UserDocumentAccessFactory( + document=document, user=other_user, role="owner" + ) + old_values = serializers.DocumentAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user_id": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.choices)[0], + } + + for field, value in new_values.items(): + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data={**old_values, field: value}, + format="json", + ) + + assert response.status_code == 403 + access.refresh_from_db() + updated_values = serializers.DocumentAccessSerializer(instance=access).data + assert updated_values == old_values + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_update_administrator_to_owner(via, mock_user_get_teams): + """ + A user who is an administrator in a document, should not be allowed to update + the user access of another user to grant document ownership. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory( + document=document, user=user, role="administrator" + ) + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="administrator" + ) + + other_user = factories.UserFactory() + access = factories.UserDocumentAccessFactory( + document=document, + user=other_user, + role=random.choice(["administrator", "member"]), + ) + old_values = serializers.DocumentAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user_id": factories.UserFactory().id, + "role": "owner", + } + + for field, value in new_values.items(): + new_data = {**old_values, field: value} + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + ) + # We are not allowed or not really updating the role + if field == "role" or new_data["role"] == old_values["role"]: + assert response.status_code == 403 + else: + assert response.status_code == 200 + + access.refresh_from_db() + updated_values = serializers.DocumentAccessSerializer(instance=access).data + assert updated_values == old_values + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_update_owner(via, mock_user_get_teams): + """ + A user who is an owner in a document should be allowed to update + a user access for this document whatever the role. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role="owner") + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="owner" + ) + + factories.UserFactory() + access = factories.UserDocumentAccessFactory( + document=document, + ) + old_values = serializers.DocumentAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user_id": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.choices)[0], + } + + for field, value in new_values.items(): + new_data = {**old_values, field: value} + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + ) + + if ( + new_data["role"] == old_values["role"] + ): # we are not really updating the role + assert response.status_code == 403 + else: + assert response.status_code == 200 + + access.refresh_from_db() + updated_values = serializers.DocumentAccessSerializer(instance=access).data + + if field == "role": + assert updated_values == {**old_values, "role": new_values["role"]} + else: + assert updated_values == old_values + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_update_owner_self(via, mock_user_get_teams): + """ + A user who is owner of a document should be allowed to update + their own user access provided there are other owners in the document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + access = factories.UserDocumentAccessFactory( + document=document, user=user, role="owner" + ) + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + access = factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="owner" + ) + + old_values = serializers.DocumentAccessSerializer(instance=access).data + new_role = random.choice(["administrator", "member"]) + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data={**old_values, "role": new_role}, + format="json", + ) + + assert response.status_code == 403 + access.refresh_from_db() + assert access.role == "owner" + + # Add another owner and it should now work + factories.UserDocumentAccessFactory(document=document, role="owner") + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data={**old_values, "role": new_role}, + format="json", + ) + + assert response.status_code == 200 + access.refresh_from_db() + assert access.role == new_role + + +# Delete + + +def test_api_document_accesses_delete_anonymous(): + """Anonymous users should not be allowed to destroy a document access.""" + access = factories.UserDocumentAccessFactory() + + response = APIClient().delete( + f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 401 + assert models.DocumentAccess.objects.count() == 1 + + +def test_api_document_accesses_delete_authenticated(): + """ + Authenticated users should not be allowed to delete a document access for a + document to which they are not related. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + access = factories.UserDocumentAccessFactory() + + response = client.delete( + f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 403 + assert models.DocumentAccess.objects.count() == 1 + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_delete_member(via, mock_user_get_teams): + """ + Authenticated users should not be allowed to delete a document access for a + document in which they are a simple member. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role="member") + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="member" + ) + + access = factories.UserDocumentAccessFactory(document=document) + + assert models.DocumentAccess.objects.count() == 2 + assert models.DocumentAccess.objects.filter(user=access.user).exists() + + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 403 + assert models.DocumentAccess.objects.count() == 2 + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_delete_administrators_except_owners( + via, mock_user_get_teams +): + """ + Users who are administrators in a document should be allowed to delete an access + from the document provided it is not ownership. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory( + document=document, user=user, role="administrator" + ) + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="administrator" + ) + + access = factories.UserDocumentAccessFactory( + document=document, role=random.choice(["member", "administrator"]) + ) + + assert models.DocumentAccess.objects.count() == 2 + assert models.DocumentAccess.objects.filter(user=access.user).exists() + + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 204 + assert models.DocumentAccess.objects.count() == 1 + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_get_teams): + """ + Users who are administrators in a document should not be allowed to delete an ownership + access from the document. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory( + document=document, user=user, role="administrator" + ) + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="administrator" + ) + + access = factories.UserDocumentAccessFactory(document=document, role="owner") + + assert models.DocumentAccess.objects.count() == 2 + assert models.DocumentAccess.objects.filter(user=access.user).exists() + + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 403 + assert models.DocumentAccess.objects.count() == 2 + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_delete_owners(via, mock_user_get_teams): + """ + Users should be able to delete the document access of another user + for a document of which they are owner. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + factories.UserDocumentAccessFactory(document=document, user=user, role="owner") + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="owner" + ) + + access = factories.UserDocumentAccessFactory(document=document) + + assert models.DocumentAccess.objects.count() == 2 + assert models.DocumentAccess.objects.filter(user=access.user).exists() + + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 204 + assert models.DocumentAccess.objects.count() == 1 + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_delete_owners_last_owner(via, mock_user_get_teams): + """ + It should not be possible to delete the last owner access from a document + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + if via == USER: + access = factories.UserDocumentAccessFactory( + document=document, user=user, role="owner" + ) + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + access = factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="owner" + ) + + assert models.DocumentAccess.objects.count() == 1 + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 403 + assert models.DocumentAccess.objects.count() == 1 diff --git a/src/backend/core/tests/test_api_template_accesses.py b/src/backend/core/tests/test_api_template_accesses.py index b6fbbc00..fb468173 100644 --- a/src/backend/core/tests/test_api_template_accesses.py +++ b/src/backend/core/tests/test_api_template_accesses.py @@ -130,7 +130,7 @@ def test_api_template_accesses_retrieve_anonymous(): access = factories.UserTemplateAccessFactory() response = APIClient().get( - f"/api/v1.0/templates/{access.template.id!s}/accesses/{access.id!s}/", + f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/", ) assert response.status_code == 401 @@ -326,7 +326,7 @@ def test_api_template_accesses_create_authenticated_administrator( assert response.status_code == 403 assert response.json() == { - "detail": "Only owners of a template can assign other users as owners." + "detail": "Only owners of a resource can assign other users as owners." } # It should be allowed to create a lower access @@ -413,7 +413,7 @@ def test_api_template_accesses_update_anonymous(): api_client = APIClient() for field, value in new_values.items(): response = api_client.put( - f"/api/v1.0/templates/{access.template.id!s}/accesses/{access.id!s}/", + f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/", {**old_values, field: value}, format="json", ) @@ -445,7 +445,7 @@ def test_api_template_accesses_update_authenticated_unrelated(): for field, value in new_values.items(): response = client.put( - f"/api/v1.0/templates/{access.template.id!s}/accesses/{access.id!s}/", + f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/", {**old_values, field: value}, format="json", ) @@ -484,7 +484,7 @@ def test_api_template_accesses_update_authenticated_member(via, mock_user_get_te for field, value in new_values.items(): response = client.put( - f"/api/v1.0/templates/{access.template.id!s}/accesses/{access.id!s}/", + f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/", {**old_values, field: value}, format="json", ) @@ -770,7 +770,7 @@ def test_api_template_accesses_delete_anonymous(): access = factories.UserTemplateAccessFactory() response = APIClient().delete( - f"/api/v1.0/templates/{access.template.id!s}/accesses/{access.id!s}/", + f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/", ) assert response.status_code == 401 @@ -790,7 +790,7 @@ def test_api_template_accesses_delete_authenticated(): access = factories.UserTemplateAccessFactory() response = client.delete( - f"/api/v1.0/templates/{access.template.id!s}/accesses/{access.id!s}/", + f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/", ) assert response.status_code == 403 diff --git a/src/backend/core/tests/test_models_document_accesses.py b/src/backend/core/tests/test_models_document_accesses.py new file mode 100644 index 00000000..e5d5299e --- /dev/null +++ b/src/backend/core/tests/test_models_document_accesses.py @@ -0,0 +1,311 @@ +""" +Unit tests for the DocumentAccess model +""" +from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import ValidationError + +import pytest + +from core import factories + +pytestmark = pytest.mark.django_db + + +def test_models_document_accesses_str(): + """ + The str representation should include user email, document title and role. + """ + user = factories.UserFactory(email="david.bowman@example.com") + access = factories.UserDocumentAccessFactory( + role="member", + user=user, + document__title="admins", + ) + assert str(access) == "david.bowman@example.com is member in document admins" + + +def test_models_document_accesses_unique_user(): + """Document accesses should be unique for a given couple of user and document.""" + access = factories.UserDocumentAccessFactory() + + with pytest.raises( + ValidationError, + match="This user is already in this document.", + ): + factories.UserDocumentAccessFactory(user=access.user, document=access.document) + + +def test_models_document_accesses_several_empty_teams(): + """A document can have several document accesses with an empty team.""" + access = factories.UserDocumentAccessFactory() + factories.UserDocumentAccessFactory(document=access.document) + + +def test_models_document_accesses_unique_team(): + """Document accesses should be unique for a given couple of team and document.""" + access = factories.TeamDocumentAccessFactory() + + with pytest.raises( + ValidationError, + match="This team is already in this document.", + ): + factories.TeamDocumentAccessFactory(team=access.team, document=access.document) + + +def test_models_document_accesses_several_null_users(): + """A document can have several document accesses with a null user.""" + access = factories.TeamDocumentAccessFactory() + factories.TeamDocumentAccessFactory(document=access.document) + + +def test_models_document_accesses_user_and_team_set(): + """User and team can't both be set on a document access.""" + with pytest.raises( + ValidationError, + match="Either user or team must be set, not both.", + ): + factories.UserDocumentAccessFactory(team="my-team") + + +def test_models_document_accesses_user_and_team_empty(): + """User and team can't both be empty on a document access.""" + with pytest.raises( + ValidationError, + match="Either user or team must be set, not both.", + ): + factories.UserDocumentAccessFactory(user=None) + + +# get_abilities + + +def test_models_document_access_get_abilities_anonymous(): + """Check abilities returned for an anonymous user.""" + access = factories.UserDocumentAccessFactory() + abilities = access.get_abilities(AnonymousUser()) + assert abilities == { + "destroy": False, + "retrieve": False, + "update": False, + "set_role_to": [], + } + + +def test_models_document_access_get_abilities_authenticated(): + """Check abilities returned for an authenticated user.""" + access = factories.UserDocumentAccessFactory() + user = factories.UserFactory() + abilities = access.get_abilities(user) + assert abilities == { + "destroy": False, + "retrieve": False, + "update": False, + "set_role_to": [], + } + + +# - for owner + + +def test_models_document_access_get_abilities_for_owner_of_self_allowed(): + """ + Check abilities of self access for the owner of a document when + there is more than one owner left. + """ + access = factories.UserDocumentAccessFactory(role="owner") + factories.UserDocumentAccessFactory(document=access.document, role="owner") + abilities = access.get_abilities(access.user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "set_role_to": ["administrator", "member"], + } + + +def test_models_document_access_get_abilities_for_owner_of_self_last(): + """ + Check abilities of self access for the owner of a document when there is only one owner left. + """ + access = factories.UserDocumentAccessFactory(role="owner") + abilities = access.get_abilities(access.user) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "set_role_to": [], + } + + +def test_models_document_access_get_abilities_for_owner_of_owner(): + """Check abilities of owner access for the owner of a document.""" + access = factories.UserDocumentAccessFactory(role="owner") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="owner" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "set_role_to": ["administrator", "member"], + } + + +def test_models_document_access_get_abilities_for_owner_of_administrator(): + """Check abilities of administrator access for the owner of a document.""" + access = factories.UserDocumentAccessFactory(role="administrator") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="owner" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "set_role_to": ["owner", "member"], + } + + +def test_models_document_access_get_abilities_for_owner_of_member(): + """Check abilities of member access for the owner of a document.""" + access = factories.UserDocumentAccessFactory(role="member") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="owner" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "set_role_to": ["owner", "administrator"], + } + + +# - for administrator + + +def test_models_document_access_get_abilities_for_administrator_of_owner(): + """Check abilities of owner access for the administrator of a document.""" + access = factories.UserDocumentAccessFactory(role="owner") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="administrator" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "set_role_to": [], + } + + +def test_models_document_access_get_abilities_for_administrator_of_administrator(): + """Check abilities of administrator access for the administrator of a document.""" + access = factories.UserDocumentAccessFactory(role="administrator") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="administrator" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "set_role_to": ["member"], + } + + +def test_models_document_access_get_abilities_for_administrator_of_member(): + """Check abilities of member access for the administrator of a document.""" + access = factories.UserDocumentAccessFactory(role="member") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="administrator" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "set_role_to": ["administrator"], + } + + +# - for member + + +def test_models_document_access_get_abilities_for_member_of_owner(): + """Check abilities of owner access for the member of a document.""" + access = factories.UserDocumentAccessFactory(role="owner") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="member" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "set_role_to": [], + } + + +def test_models_document_access_get_abilities_for_member_of_administrator(): + """Check abilities of administrator access for the member of a document.""" + access = factories.UserDocumentAccessFactory(role="administrator") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="member" + ).user + abilities = access.get_abilities(user) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "set_role_to": [], + } + + +def test_models_document_access_get_abilities_for_member_of_member_user( + django_assert_num_queries +): + """Check abilities of member access for the member of a document.""" + access = factories.UserDocumentAccessFactory(role="member") + factories.UserDocumentAccessFactory(document=access.document) # another one + user = factories.UserDocumentAccessFactory( + document=access.document, role="member" + ).user + + with django_assert_num_queries(1): + abilities = access.get_abilities(user) + + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "set_role_to": [], + } + + +def test_models_document_access_get_abilities_preset_role(django_assert_num_queries): + """No query is done if the role is preset, e.g., with a query annotation.""" + access = factories.UserDocumentAccessFactory(role="member") + user = factories.UserDocumentAccessFactory( + document=access.document, role="member" + ).user + access.user_roles = ["member"] + + with django_assert_num_queries(0): + abilities = access.get_abilities(user) + + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "set_role_to": [], + } diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py new file mode 100644 index 00000000..3544d4a0 --- /dev/null +++ b/src/backend/core/tests/test_models_documents.py @@ -0,0 +1,153 @@ +""" +Unit tests for the Document model +""" +from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import ValidationError + +import pytest + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def test_models_documents_str(): + """The str representation should be the title of the document.""" + document = factories.DocumentFactory(title="admins") + assert str(document) == "admins" + + +def test_models_documents_id_unique(): + """The "id" field should be unique.""" + document = factories.DocumentFactory() + with pytest.raises(ValidationError, match="Document with this Id already exists."): + factories.DocumentFactory(id=document.id) + + +def test_models_documents_title_null(): + """The "title" field should not be null.""" + with pytest.raises(ValidationError, match="This field cannot be null."): + models.Document.objects.create(title=None) + + +def test_models_documents_title_empty(): + """The "title" field should not be empty.""" + with pytest.raises(ValidationError, match="This field cannot be blank."): + models.Document.objects.create(title="") + + +def test_models_documents_title_max_length(): + """The "title" field should be 100 characters maximum.""" + factories.DocumentFactory(title="a" * 255) + with pytest.raises( + ValidationError, + match=r"Ensure this value has at most 255 characters \(it has 256\)\.", + ): + factories.DocumentFactory(title="a" * 256) + + +# get_abilities + + +def test_models_documents_get_abilities_anonymous_public(): + """Check abilities returned for an anonymous user if the document is public.""" + document = factories.DocumentFactory(is_public=True) + abilities = document.get_abilities(AnonymousUser()) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "manage_accesses": False, + } + + +def test_models_documents_get_abilities_anonymous_not_public(): + """Check abilities returned for an anonymous user if the document is private.""" + document = factories.DocumentFactory(is_public=False) + abilities = document.get_abilities(AnonymousUser()) + assert abilities == { + "destroy": False, + "retrieve": False, + "update": False, + "manage_accesses": False, + } + + +def test_models_documents_get_abilities_authenticated_public(): + """Check abilities returned for an authenticated user if the user is public.""" + document = factories.DocumentFactory(is_public=True) + abilities = document.get_abilities(factories.UserFactory()) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "manage_accesses": False, + } + + +def test_models_documents_get_abilities_authenticated_not_public(): + """Check abilities returned for an authenticated user if the document is private.""" + document = factories.DocumentFactory(is_public=False) + abilities = document.get_abilities(factories.UserFactory()) + assert abilities == { + "destroy": False, + "retrieve": False, + "update": False, + "manage_accesses": False, + } + + +def test_models_documents_get_abilities_owner(): + """Check abilities returned for the owner of a document.""" + user = factories.UserFactory() + access = factories.UserDocumentAccessFactory(role="owner", user=user) + abilities = access.document.get_abilities(access.user) + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "manage_accesses": True, + } + + +def test_models_documents_get_abilities_administrator(): + """Check abilities returned for the administrator of a document.""" + access = factories.UserDocumentAccessFactory(role="administrator") + abilities = access.document.get_abilities(access.user) + assert abilities == { + "destroy": False, + "retrieve": True, + "update": True, + "manage_accesses": True, + } + + +def test_models_documents_get_abilities_member_user(django_assert_num_queries): + """Check abilities returned for the member of a document.""" + access = factories.UserDocumentAccessFactory(role="member") + + with django_assert_num_queries(1): + abilities = access.document.get_abilities(access.user) + + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "manage_accesses": False, + } + + +def test_models_documents_get_abilities_preset_role(django_assert_num_queries): + """No query is done if the role is preset e.g. with query annotation.""" + access = factories.UserDocumentAccessFactory(role="member") + access.document.user_roles = ["member"] + + with django_assert_num_queries(0): + abilities = access.document.get_abilities(access.user) + + assert abilities == { + "destroy": False, + "retrieve": True, + "update": False, + "manage_accesses": False, + } diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 7530b81a..ab6c9f80 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -10,8 +10,17 @@ from core.api import viewsets # - Main endpoints router = DefaultRouter() router.register("templates", viewsets.TemplateViewSet, basename="templates") +router.register("documents", viewsets.DocumentViewSet, basename="documents") router.register("users", viewsets.UserViewSet, basename="users") +# - Routes nested under a document +document_related_router = DefaultRouter() +document_related_router.register( + "accesses", + viewsets.DocumentAccessViewSet, + basename="document_accesses", +) + # - Routes nested under a template template_related_router = DefaultRouter() template_related_router.register( @@ -29,7 +38,11 @@ urlpatterns = [ *router.urls, *oidc_urls, re_path( - r"^templates/(?P[0-9a-z-]*)/", + r"^documents/(?P[0-9a-z-]*)/", + include(document_related_router.urls), + ), + re_path( + r"^templates/(?P[0-9a-z-]*)/", include(template_related_router.urls), ), ]