Skip to content
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and this project adheres to
- 🔧(front) configure x-frame-options to DENY in nginx conf #1084
- (doc) add documentation to install with compose #855
- ✨(backend) allow to disable checking unsafe mimetype on attachment upload
- ✨Ask for access #1081

### Changed

Expand All @@ -33,7 +34,7 @@ and this project adheres to
- 🔧(git) set LF line endings for all text files #1032
- 📝(docs) minor fixes to docs/env.md

## Removed
### Removed

- 🔥(frontend) remove Beta from logo #1095

Expand Down
44 changes: 44 additions & 0 deletions src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,50 @@ def validate_role(self, role):
return role


class RoleSerializer(serializers.Serializer):
"""Serializer validating role choices."""

role = serializers.ChoiceField(
choices=models.RoleChoices.choices, required=False, allow_null=True
)


class DocumentAskForAccessCreateSerializer(serializers.Serializer):
"""Serializer for creating a document ask for access."""

role = serializers.ChoiceField(
choices=models.RoleChoices.choices,
required=False,
default=models.RoleChoices.READER,
)


class DocumentAskForAccessSerializer(serializers.ModelSerializer):
"""Serializer for document ask for access model"""

abilities = serializers.SerializerMethodField(read_only=True)
user = UserSerializer(read_only=True)

class Meta:
model = models.DocumentAskForAccess
fields = [
"id",
"document",
"user",
"role",
"created_at",
"abilities",
]
read_only_fields = ["id", "document", "user", "role", "created_at", "abilities"]

def get_abilities(self, invitation) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return invitation.get_abilities(request.user)
return {}


class VersionFilterSerializer(serializers.Serializer):
"""Validate version filters applied to the list endpoint."""

Expand Down
78 changes: 78 additions & 0 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from core import authentication, enums, models
from core.services.ai_services import AIService
from core.services.collaboration_services import CollaborationService
from core.tasks.mail import send_ask_for_access_mail
from core.utils import extract_attachments, filter_descendants

from . import permissions, serializers, utils
Expand Down Expand Up @@ -1774,6 +1775,83 @@ def perform_create(self, serializer):
)


class DocumentAskForAccessViewSet(
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for asking for access to a document."""

lookup_field = "id"
pagination_class = Pagination
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
queryset = models.DocumentAskForAccess.objects.all()
serializer_class = serializers.DocumentAskForAccessSerializer
_document = None

def get_document_or_404(self):
"""Get the document related to the viewset or raise a 404 error."""
if self._document is None:
try:
self._document = models.Document.objects.get(
pk=self.kwargs["resource_id"]
)
except models.Document.DoesNotExist as e:
raise drf.exceptions.NotFound("Document not found.") from e
return self._document

def get_queryset(self):
"""Return the queryset according to the action."""
document = self.get_document_or_404()

queryset = super().get_queryset()
queryset = queryset.filter(document=document)

roles = set(document.get_roles(self.request.user))
is_owner_or_admin = bool(roles.intersection(set(models.PRIVILEGED_ROLES)))
if not is_owner_or_admin:
queryset = queryset.filter(user=self.request.user)

return queryset

def create(self, request, *args, **kwargs):
"""Create a document ask for access resource."""
document = self.get_document_or_404()

serializer = serializers.DocumentAskForAccessCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)

queryset = self.get_queryset()

if queryset.filter(user=request.user).exists():
return drf.response.Response(
{"detail": "You already ask to access to this document."},
status=drf.status.HTTP_400_BAD_REQUEST,
)

ask_for_access = models.DocumentAskForAccess.objects.create(
document=document,
user=request.user,
role=serializer.validated_data["role"],
)

send_ask_for_access_mail.delay(ask_for_access.id)

return drf.response.Response(status=drf.status.HTTP_201_CREATED)

@drf.decorators.action(detail=True, methods=["post"])
def accept(self, request, *args, **kwargs):
"""Accept a document ask for access resource."""
document_ask_for_access = self.get_object()

serializer = serializers.RoleSerializer(data=request.data)
serializer.is_valid(raise_exception=True)

document_ask_for_access.accept(role=serializer.validated_data.get("role"))
return drf.response.Response(status=drf.status.HTTP_204_NO_CONTENT)


class ConfigView(drf.views.APIView):
"""API ViewSet for sharing some public settings."""

Expand Down
11 changes: 11 additions & 0 deletions src/backend/core/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,17 @@ class Meta:
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])


class DocumentAskForAccessFactory(factory.django.DjangoModelFactory):
"""Create fake document ask for access for testing."""

class Meta:
model = models.DocumentAskForAccess

document = factory.SubFactory(DocumentFactory)
user = factory.SubFactory(UserFactory)
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])


class TemplateFactory(factory.django.DjangoModelFactory):
"""A factory to create templates"""

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Generated by Django 5.2.3 on 2025-06-18 10:02

import uuid

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("core", "0021_activate_unaccent_extension"),
]

operations = [
migrations.CreateModel(
name="DocumentAskForAccess",
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",
),
),
(
"role",
models.CharField(
choices=[
("reader", "Reader"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ask_for_accesses",
to="core.document",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ask_for_accesses",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Document ask for access",
"verbose_name_plural": "Document ask for accesses",
"db_table": "impress_document_ask_for_access",
"constraints": [
models.UniqueConstraint(
fields=("user", "document"),
name="unique_document_ask_for_access_user",
violation_error_message="This user has already asked for access to this document.",
)
],
},
),
]
110 changes: 108 additions & 2 deletions src/backend/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -876,8 +876,8 @@ def send_email(self, subject, emails, context=None, language=None):
)

with override(language):
msg_html = render_to_string("mail/html/invitation.html", context)
msg_plain = render_to_string("mail/text/invitation.txt", context)
msg_html = render_to_string("mail/html/template.html", context)
msg_plain = render_to_string("mail/text/template.txt", context)
subject = str(subject) # Force translation

try:
Expand Down Expand Up @@ -1149,6 +1149,112 @@ def get_abilities(self, user):
}


class DocumentAskForAccess(BaseModel):
"""Relation model to ask for access to a document."""

document = models.ForeignKey(
Document, on_delete=models.CASCADE, related_name="ask_for_accesses"
)
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="ask_for_accesses"
)

role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER
)

class Meta:
db_table = "impress_document_ask_for_access"
verbose_name = _("Document ask for access")
verbose_name_plural = _("Document ask for accesses")
constraints = [
models.UniqueConstraint(
fields=["user", "document"],
name="unique_document_ask_for_access_user",
violation_error_message=_(
"This user has already asked for access to this document."
),
),
]

def __str__(self):
return f"{self.user!s} asked for access to document {self.document!s}"

def get_abilities(self, user):
"""Compute and return abilities for a given user."""
roles = []

if user.is_authenticated:
teams = user.teams
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = self.document.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (self._meta.model.DoesNotExist, IndexError):
roles = []

is_admin_or_owner = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)

return {
"destroy": is_admin_or_owner,
"update": is_admin_or_owner,
"partial_update": is_admin_or_owner,
"retrieve": is_admin_or_owner,
"accept": is_admin_or_owner,
}

def accept(self, role=None):
"""Accept a document ask for access resource."""
if role is None:
role = self.role

DocumentAccess.objects.update_or_create(
document=self.document,
user=self.user,
defaults={"role": role},
create_defaults={"role": role},
)
self.delete()

def send_ask_for_access_email(self, email, language=None):
"""
Method allowing a user to send an email notification when asking for access to a document.
"""

language = language or get_language()
sender = self.user
sender_name = sender.full_name or sender.email
sender_name_email = (
f"{sender.full_name:s} ({sender.email})"
if sender.full_name
else sender.email
)

with override(language):
context = {
"title": _("{name} would like access to a document!").format(
name=sender_name
),
"message": _(
"{name} would like access to the following document:"
).format(name=sender_name_email),
}
subject = (
context["title"]
if not self.document.title
else _("{name} is asking for access to the document: {title}").format(
name=sender_name, title=self.document.title
)
)

self.document.send_email(subject, [email], context, language)


class Template(BaseModel):
"""HTML and CSS code used for formatting the print around the MarkDown body."""

Expand Down
Empty file.
Loading
Loading