Skip to content

Ask for access #1081

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions env.d/development/common.dist
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,11 @@ AI_API_KEY=password
AI_MODEL=llama

# Collaboration
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
COLLABORATION_API_URL=http://y-provider-development:4444/collaboration/api/
COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
COLLABORATION_SERVER_SECRET=my-secret
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/

Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
Y_PROVIDER_API_KEY=yprovider-api-key
2 changes: 2 additions & 0 deletions env.d/development/common.e2e.dist
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# For the CI job test-e2e
BURST_THROTTLE_RATES="200/minute"
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
DJANGO_SERVER_TO_SERVER_API_TOKENS=test-e2e
SUSTAINED_THROTTLE_RATES="200/hour"
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
44 changes: 44 additions & 0 deletions src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,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_id = serializers.PrimaryKeyRelatedField(
queryset=models.User.objects.all(),
write_only=True,
source="user",
required=False,
allow_null=True,
)
user = UserSerializer(read_only=True)

class Meta:
model = models.DocumentAskForAccess
fields = ["id", "document", "user", "user_id","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
89 changes: 89 additions & 0 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -1772,6 +1772,95 @@ 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)))
# self.is_current_user_owner_or_admin = is_owner_or_admin
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# self.is_current_user_owner_or_admin = is_owner_or_admin

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 email to document owners/admins
owner_admin_accesses = models.DocumentAccess.objects.filter(
document=document, role__in=models.PRIVILEGED_ROLES
).select_related("user")

for access in owner_admin_accesses:
if access.user and access.user.email:
ask_for_access.send_ask_for_access_email(
access.user.email,
request.user,
access.user.language or settings.LANGUAGE_CODE,
)

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 @@ -183,6 +183,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.",
)
],
},
),
]
109 changes: 107 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,111 @@ 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, sender, language=None):
"""
Method allowing a user to send an email notification when asking for access to a document.
"""

language = language or get_language()
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
Loading
Loading