Files
lasuite-drive/src/backend/core/api/viewsets.py
T
Manuel Raynaud 5e16aa9dcc 🔥(backend) remove usage of atomic transaction for item creation
We used atomic transaction in the ItemViewset when a new item is created
(perform_create and children actions). This code can be removed seen we
decided to use UUID for the item path. The race condition possible when
multiple items are created does not exists anymore, we don't need to
know previously created item to guess the next path.
2026-01-28 08:05:13 +01:00

1744 lines
63 KiB
Python

"""API endpoints"""
# pylint: disable=too-many-lines
import json
import logging
import re
from urllib.parse import unquote, urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.search import TrigramSimilarity
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db import models as db
from django.db import transaction
from django.db.models.expressions import RawSQL
from django.db.models.functions import Coalesce
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.text import slugify
import posthog
import rest_framework as drf
from corsheaders.middleware import (
ACCESS_CONTROL_ALLOW_METHODS,
ACCESS_CONTROL_ALLOW_ORIGIN,
)
from lasuite.malware_detection import malware_detection
from lasuite.oidc_login.decorators import refresh_oidc_access_token
from rest_framework import filters, status, viewsets
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
from rest_framework.throttling import UserRateThrottle
from rest_framework_api_key.permissions import HasAPIKey
from core import enums, models
from core.entitlements import get_entitlements_backend
from core.services.sdk_relay import SDKRelayManager
from core.services.search_indexers import (
get_file_indexer,
get_visited_items_ids_of,
)
from core.tasks.item import process_item_deletion, rename_file
from wopi.services import access as access_service
from wopi.utils import compute_wopi_launch_url, get_wopi_client_config
from . import permissions, serializers, utils
from .filters import ItemFilter, ListItemFilter, SearchItemFilter
logger = logging.getLogger(__name__)
ITEM_FOLDER = "item"
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = '[^.\\/:*?&"<>|\r\n]+'
MEDIA_STORAGE_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}(?P<preview>preview/)?"
f"(?P<key>{ITEM_FOLDER:s}/(?P<pk>{UUID_REGEX:s})/.*{FILE_EXT_REGEX:s})$"
)
# pylint: disable=too-many-ancestors
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
A generic Viewset aims to be used in a nested route context.
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
It allows to define all url kwargs and lookup fields to perform the lookup.
"""
lookup_fields: list[str] = ["pk"]
lookup_url_kwargs: list[str] = []
def __getattribute__(self, item):
"""
This method is overridden to allow to get the last lookup field or lookup url kwarg
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
to keep compatibility with all methods used by the parent class `GenericViewSet`.
"""
if item in ["lookup_field", "lookup_url_kwarg"]:
return getattr(self, item + "s", [None])[-1]
return super().__getattribute__(item)
def get_queryset(self):
"""
Get the list of items for this view.
`lookup_fields` attribute is enumerated here to perform the nested lookup.
"""
queryset = super().get_queryset()
# The last lookup field is removed to perform the nested lookup as it corresponds
# to the object pk, it is used within get_object method.
lookup_url_kwargs = (
self.lookup_url_kwargs[:-1]
if self.lookup_url_kwargs
else self.lookup_fields[:-1]
)
filter_kwargs = {}
for index, lookup_url_kwarg in enumerate(lookup_url_kwargs):
if lookup_url_kwarg not in self.kwargs:
raise KeyError(
f"Expected view {self.__class__.__name__} to be called with a URL "
f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or '
"set the `.lookup_fields` attribute on the view correctly."
)
filter_kwargs.update(
{self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]}
)
return queryset.filter(**filter_kwargs)
class SerializerPerActionMixin:
"""
A mixin to allow to define serializer classes for each action.
This mixin is useful to avoid to define a serializer class for each action in the
`get_serializer_class` method.
Example:
```
class MyViewSet(SerializerPerActionMixin, viewsets.GenericViewSet):
serializer_class = MySerializer
list_serializer_class = MyListSerializer
retrieve_serializer_class = MyRetrieveSerializer
```
"""
def get_serializer_class(self):
"""
Return the serializer class to use depending on the action.
"""
if serializer_class := getattr(self, f"{self.action}_serializer_class", None):
return serializer_class
return super().get_serializer_class()
class Pagination(drf.pagination.PageNumberPagination):
"""Pagination to display no more than 100 objects per page sorted by creation date."""
ordering = "-created_on"
max_page_size = settings.MAX_PAGE_SIZE
page_size_query_param = "page_size"
class UserListThrottleBurst(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_burst"
class UserListThrottleSustained(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_sustained"
class UserViewSet(
SerializerPerActionMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.mixins.ListModelMixin,
):
"""User ViewSet"""
permission_classes = [permissions.IsSelf]
queryset = models.User.objects.all().filter(is_active=True)
serializer_class = serializers.UserSerializer
get_me_serializer_class = serializers.UserMeSerializer
pagination_class = None
throttle_classes = []
def get_throttles(self):
self.throttle_classes = []
if self.action == "list":
self.throttle_classes = [UserListThrottleBurst, UserListThrottleSustained]
return super().get_throttles()
def get_queryset(self):
"""
Limit listed users by querying the email field with a trigram similarity
search if a query is provided.
Limit listed users by excluding users already in the item if a item_id
is provided.
"""
queryset = self.queryset
if self.action != "list":
return queryset
# Exclude all users already in the given item
if item_id := self.request.query_params.get("item_id", ""):
queryset = queryset.exclude(itemaccess__item_id=item_id)
if not (query := self.request.query_params.get("q", "")) or len(query) < 5:
return queryset.none()
# For emails, match emails by Levenstein distance to prevent typing errors
if "@" in query:
return (
queryset.annotate(
distance=RawSQL("levenshtein(email::text, %s::text)", (query,))
)
.filter(distance__lte=3)
.order_by("distance", "email")[: settings.API_USERS_LIST_LIMIT]
)
# Use trigram similarity for non-email-like queries
# For performance reasons we filter first by similarity, which relies on an
# index, then only calculate precise similarity scores for sorting purposes
return (
queryset.filter(email__trigram_word_similar=query)
.annotate(similarity=TrigramSimilarity("email", query))
.filter(similarity__gt=0.2)
.order_by("-similarity", "email")[: settings.API_USERS_LIST_LIMIT]
)
@drf.decorators.action(
detail=False,
methods=["get"],
url_name="me",
url_path="me",
)
def get_me(self, request):
"""
Return information on currently logged user
"""
context = {"request": request}
return drf.response.Response(
self.get_serializer(request.user, context=context).data
)
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.teams
user_roles_query = (
queryset.filter(
db.Q(user=user) | db.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(
db.Q(**{f"{self.resource_field_name}__accesses__user": user})
| db.Q(
**{f"{self.resource_field_name}__accesses__team__in": teams}
),
**{self.resource_field_name: self.kwargs["resource_id"]},
)
.annotate(user_roles=db.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=drf.status.HTTP_403_FORBIDDEN,
)
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 drf.exceptions.PermissionDenied({"detail": message})
serializer.save()
class ItemMetadata(drf.metadata.SimpleMetadata):
"""Custom metadata class to add information"""
def determine_metadata(self, request, view):
"""Add language choices only for the list endpoint."""
simple_metadata = super().determine_metadata(request, view)
if request.path.endswith("/items/"):
simple_metadata["actions"]["POST"]["language"] = {
"choices": [
{"value": code, "display_name": name}
for code, name in enums.ALL_LANGUAGES.items()
]
}
return simple_metadata
# pylint: disable=too-many-public-methods
class ItemViewSet(
SerializerPerActionMixin,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""
ItemViewSet API.
This view set provides CRUD operations and additional actions for managing items.
Supports filtering, ordering, and annotations for enhanced querying capabilities.
### API Endpoints:
1. **List**: Retrieve a paginated list of items.
Example: GET /items/?page=2
2. **Retrieve**: Get a specific item by its ID.
Example: GET /items/{id}/
3. **Create**: Create a new item.
Example: POST /items/
4. **Update**: Update a item by its ID.
Example: PUT /items/{id}/
5. **Delete**: Soft delete a item by its ID.
Example: DELETE /items/{id}/
### Additional Actions:
1. **Trashbin**: List soft deleted items for a item owner
Example: GET /items/{id}/trashbin/
2. **Children**: List or create child items.
Example: GET, POST /items/{id}/children/
3. **Favorite**: Get list of favorite items for a user. Mark or unmark
a item as favorite.
Examples:
- GET /items/favorite/
- POST, DELETE /items/{id}/favorite/
4. **Link Configuration**: Update item link configuration.
Example: PUT /items/{id}/link-configuration/
5. **Media Auth**: Authorize access to item media.
Example: GET /items/media-auth/
### Ordering: created_at, updated_at, is_favorite, title
Example:
- Ascending: GET /api/v1.0/items/?ordering=created_at
- Desceding: GET /api/v1.0/items/?ordering=-title
### Filtering:
- `is_creator_me=true`: Returns items created by the current user.
- `is_creator_me=false`: Returns items created by other users.
- `is_favorite=true`: Returns items marked as favorite by the current user
- `is_favorite=false`: Returns items not marked as favorite by the current user
- `title=hello`: Returns items which title contains the "hello" string
Example:
- GET /api/v1.0/items/?is_creator_me=true&is_favorite=true
- GET /api/v1.0/items/?is_creator_me=false&title=hello
### Annotations:
1. **is_favorite**: Indicates whether the item is marked as favorite by the current user.
2. *`*user_roles**: Roles the current user has on the item or its ancestors.
### Notes:
- Only the highest ancestor in a item hierarchy is shown in list views.
- Implements soft delete logic to retain item tree structures.
"""
metadata_class = ItemMetadata
ordering = ["-updated_at"]
ordering_fields = ["created_at", "updated_at", "title", "type"]
pagination_class = Pagination
permission_classes = [
permissions.ItemAccessPermission,
]
queryset = models.Item.objects.filter(hard_deleted_at__isnull=True)
serializer_class = serializers.ItemSerializer
list_serializer_class = serializers.ListItemSerializer
trashbin_serializer_class = serializers.ListItemSerializer
children_serializer_class = serializers.ListItemSerializer
create_serializer_class = serializers.CreateItemSerializer
tree_serializer_class = serializers.ListItemSerializer
search_serializer_class = serializers.SearchItemSerializer
breadcrumb_serializer_class = serializers.BreadcrumbItemSerializer
def annotate_is_favorite(self, queryset):
"""
Annotate item queryset with the favorite status for the current user.
"""
user = self.request.user
if user.is_authenticated:
favorite_exists_subquery = models.ItemFavorite.objects.filter(
item_id=db.OuterRef("pk"), user=user
)
return queryset.annotate(is_favorite=db.Exists(favorite_exists_subquery))
return queryset.annotate(is_favorite=db.Value(False))
def annotate_user_roles(self, queryset):
"""
Annotate item queryset with the roles of the current user
on the item or its ancestors.
"""
user = self.request.user
output_field = ArrayField(base_field=db.CharField())
if user.is_authenticated:
user_roles_subquery = models.ItemAccess.objects.filter(
db.Q(user=user) | db.Q(team__in=user.teams),
item__path__ancestors=db.OuterRef("path"),
).values_list("role", flat=True)
return queryset.annotate(
user_roles=db.Func(
user_roles_subquery, function="ARRAY", output_field=output_field
)
)
return queryset.annotate(
user_roles=db.Value([], output_field=output_field),
)
def _filter_suspicious_items(self, queryset, user):
"""
Filter out items with SUSPICIOUS upload_state for non-creators.
Args:
queryset: The queryset to filter
user: The current user
Returns:
Filtered queryset excluding suspicious items from non-creators
"""
# For authenticated users, exclude suspicious items they didn't create
# For unauthenticated users, exclude all suspicious items
if user.is_authenticated:
return queryset.exclude(
db.Q(upload_state=models.ItemUploadStateChoices.SUSPICIOUS)
& ~db.Q(creator=user)
)
return queryset.exclude(upload_state=models.ItemUploadStateChoices.SUSPICIOUS)
def get_queryset(self):
"""Get queryset performing all annotation and filtering on the item tree structure."""
user = self.request.user
queryset = super().get_queryset().select_related("creator")
# Remove items with upload_state SUSPICIOUS for non-creators
queryset = self._filter_suspicious_items(queryset, user)
# Only list views need filtering and annotation
if self.detail:
return queryset
if not user.is_authenticated:
return queryset.none()
queryset = queryset.filter(ancestors_deleted_at__isnull=True)
# Filter items to which the current user has access...
access_items_ids = models.ItemAccess.objects.filter(
db.Q(user=user) | db.Q(team__in=user.teams)
).values_list("item_id", flat=True)
# ...or that were previously accessed and are not restricted
traced_items_ids = models.LinkTrace.objects.filter(user=user).values_list(
"item_id", flat=True
)
return queryset.filter(
db.Q(id__in=access_items_ids)
| (
db.Q(id__in=traced_items_ids)
& ~db.Q(link_reach=models.LinkReachChoices.RESTRICTED)
)
)
def filter_queryset(self, queryset):
"""Override to apply annotations to generic views."""
queryset = super().filter_queryset(queryset)
queryset = self.annotate_is_favorite(queryset)
queryset = self.annotate_user_roles(queryset)
return queryset
def get_response_for_queryset(self, queryset, context=None):
"""Return paginated response for the queryset if requested."""
context = context or self.get_serializer_context()
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True, context=context)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True, context=context)
return drf.response.Response(serializer.data)
def retrieve(self, request, *args, **kwargs):
"""
Add a trace that the item was accessed by a user. This is used to list items
on a user's list view even though the user has no specific role in the item (link
access when the link reach configuration of the item allows it).
"""
user = self.request.user
instance = self.get_object()
serializer = self.get_serializer(instance)
# The `create` query generates 5 db queries which are much less efficient than an
# `exists` query. The user will visit the item many times after the first visit
# so that's what we should optimize for.
if (
user.is_authenticated
and not instance.link_traces.filter(user=user).exists()
):
models.LinkTrace.objects.create(item=instance, user=request.user)
return drf.response.Response(serializer.data)
def perform_create(self, serializer):
"""Set the current user as creator and owner of the newly created object."""
obj = models.Item.objects.create_child(
creator=self.request.user,
**serializer.validated_data,
)
serializer.instance = obj
models.ItemAccess.objects.create(
item=obj,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
def perform_destroy(self, instance):
"""Override to implement a soft delete instead of dumping the record in database."""
instance.soft_delete()
def perform_update(self, serializer):
"""Override to check if a file is renamed in order to rename file on storage."""
instance = serializer.instance
if instance.type == models.ItemTypeChoices.FILE:
title = serializer.validated_data.get("title")
if title and instance.title != title:
rename_file.delay(instance.id, title)
serializer.save()
@drf.decorators.action(detail=True, methods=["delete"], url_path="hard-delete")
def hard_delete(self, request, *args, **kwargs):
"""
Hard delete an item.
"""
instance = self.get_object()
instance.hard_delete()
process_item_deletion.delay(instance.id)
return drf.response.Response(status=status.HTTP_204_NO_CONTENT)
def list(self, request, *args, **kwargs):
"""List items with pagination and filtering."""
# Not calling filter_queryset. We do our own cooking.
queryset = self.get_queryset()
# exclude main workspace
queryset = queryset.filter(main_workspace=False)
# filter only on folder types
queryset = queryset.filter(type=models.ItemTypeChoices.FOLDER)
filterset = ListItemFilter(
self.request.GET, queryset=queryset, request=self.request
)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
filter_data = filterset.form.cleaned_data
# Filter as early as possible on fields that are available on the model
for field in ["is_creator_me", "title", "workspaces"]:
queryset = filterset.filters[field].filter(queryset, filter_data[field])
queryset = self.annotate_user_roles(queryset)
# Among the results, we may have items that are ancestors/descendants
# of each other. In this case we want to keep only the highest ancestors.
root_paths = utils.filter_root_paths(
queryset.order_by("path").values_list("path", flat=True),
skip_sorting=True,
)
queryset = queryset.filter(path__in=root_paths)
# Annotate the queryset with an attribute marking instances as highest ancestor
# in order to save some time while computing abilities in the instance
queryset = queryset.annotate(
is_highest_ancestor_for_user=db.Value(True, output_field=db.BooleanField())
)
# Annotate favorite status and filter if applicable as late as possible
queryset = self.annotate_is_favorite(queryset)
queryset = filterset.filters["is_favorite"].filter(
queryset, filter_data["is_favorite"]
)
# Apply ordering only now that everyting is filtered and annotated
queryset = filters.OrderingFilter().filter_queryset(
self.request, queryset, self
)
return self.get_response_for_queryset(queryset)
@drf.decorators.action(detail=True, methods=["post"], url_path="upload-ended")
def upload_ended(self, request, *args, **kwargs):
"""
Start the analysis of an item after a successful upload.
"""
item = self.get_object()
if item.type != models.ItemTypeChoices.FILE:
raise drf.exceptions.ValidationError(
{"item": "This action is only available for items of type FILE."},
code="item_upload_type_unavailable",
)
if item.upload_state != models.ItemUploadStateChoices.PENDING:
raise drf.exceptions.ValidationError(
{"item": "This action is only available for items in PENDING state."},
code="item_upload_state_not_pending",
)
entitlements_backend = get_entitlements_backend()
can_upload = entitlements_backend.can_upload(self.request.user)
if not can_upload["result"]:
self._complete_item_deletion(item)
raise drf.exceptions.PermissionDenied(
detail=can_upload.get(
"message", "You do not have permission to upload files."
)
)
s3_client = default_storage.connection.meta.client
head_response = s3_client.head_object(
Bucket=default_storage.bucket_name, Key=item.file_key
)
file_size = head_response["ContentLength"]
if file_size > 2048:
range_response = s3_client.get_object(
Bucket=default_storage.bucket_name,
Key=item.file_key,
Range="bytes=0-2047",
)
file_head = range_response["Body"].read()
else:
file_head = s3_client.get_object(
Bucket=default_storage.bucket_name, Key=item.file_key
)["Body"].read()
# Use improved MIME type detection combining magic bytes and file extension
mimetype = utils.detect_mimetype(file_head, filename=item.filename)
if (
settings.RESTRICT_UPLOAD_FILE_TYPE
and mimetype not in settings.FILE_MIMETYPE_ALLOWED
):
self._complete_item_deletion(item)
logger.info(
"upload_ended: mimetype not allowed %s for filename %s",
mimetype,
item.filename,
)
raise drf.exceptions.ValidationError(
detail="The file type is not allowed.",
code="file_type_not_allowed",
)
item.upload_state = models.ItemUploadStateChoices.ANALYZING
item.mimetype = mimetype
item.size = file_size
item.save(update_fields=["upload_state", "mimetype", "size"])
malware_detection.analyse_file(item.file_key, item_id=item.id)
serializer = self.get_serializer(item)
if settings.POSTHOG_KEY:
posthog.capture(
"item_uploaded",
distinct_id=request.user.email,
properties={
"id": item.id,
"title": item.title,
"size": item.size,
"mimetype": item.mimetype,
},
)
return drf_response.Response(serializer.data, status=status.HTTP_200_OK)
def _complete_item_deletion(self, item):
"""Completely delete an item."""
item.soft_delete()
item.hard_delete()
process_item_deletion.delay(item.id)
@drf.decorators.action(
detail=False,
methods=["get"],
permission_classes=[permissions.IsAuthenticated],
)
def favorite_list(self, request, *args, **kwargs):
"""Get list of favorite items for the current user."""
user = request.user
favorite_items_ids = models.ItemFavorite.objects.filter(user=user).values_list(
"item_id", flat=True
)
queryset = self.get_queryset()
queryset = queryset.filter(id__in=favorite_items_ids)
return self.get_response_for_queryset(queryset)
@drf.decorators.action(
detail=False,
methods=["get"],
)
def trashbin(self, request, *args, **kwargs):
"""
Retrieve soft-deleted items for which the current user has the owner role.
The selected items are those deleted within the cutoff period defined in the
settings (see TRASHBIN_CUTOFF_DAYS), before they are considered permanently deleted.
Optimized version that uses EXISTS instead of expensive subqueries to check
owner access on items or their ancestors.
"""
user = request.user
# Build the EXISTS subquery to check if user has owner access
# to the item or any of its ancestors
owner_access_exists = models.ItemAccess.objects.filter(
db.Q(user=user) | db.Q(team__in=user.teams),
role=models.RoleChoices.OWNER,
item__path__ancestors=db.OuterRef("path"),
)
# Filter trashbin items to only those where user has owner access
# Before we were filtering on the user_roles annotation, but it was too slow
# Here the optimization is to filter on the owner_access_exists subquery
# which is much faster.
queryset = (
self.queryset.select_related("creator")
.filter(
deleted_at__gte=models.get_trashbin_cutoff(),
)
.filter(db.Exists(owner_access_exists))
)
# Apply filtering similar to children method
filterset = ItemFilter(request.GET, queryset=queryset)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
queryset = filterset.qs
# Only annotate with user roles for the filtered set if needed by serializer
queryset = self.annotate_user_roles(queryset)
return self.get_response_for_queryset(queryset)
@drf.decorators.action(detail=True, methods=["post"])
@transaction.atomic
def move(self, request, *args, **kwargs):
"""
Move an item to another location within the item tree.
The user must be an administrator or owner of both the item being moved
and the target parent item.
"""
user = request.user
item = self.get_object() # including permission checks
# Validate the input payload
serializer = serializers.MoveItemSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
validated_data = serializer.validated_data
target_item_id = validated_data["target_item_id"]
try:
target_item = models.Item.objects.get(
id=target_item_id, ancestors_deleted_at__isnull=True
)
except models.Item.DoesNotExist as excpt:
raise drf.exceptions.ValidationError(
{"target_item_id": "Target parent item does not exist."},
code="item_move_target_does_not_exist",
) from excpt
message = None
if not target_item.get_abilities(user).get("children_create"):
message = (
"You do not have permission to move items "
"as a child to this target item."
)
if message:
raise drf.exceptions.ValidationError(
{"target_item_id": message}, code="item_move_missing_permission"
)
item.move(target_item)
return drf.response.Response(
{"message": "item moved successfully."}, status=status.HTTP_200_OK
)
@drf.decorators.action(
detail=True,
methods=["post"],
)
def restore(self, request, *args, **kwargs):
"""
Restore a soft-deleted item if it was deleted less than x days ago.
"""
item = self.get_object()
item.restore()
return drf_response.Response(
{"detail": "item has been successfully restored."},
status=status.HTTP_200_OK,
)
@drf.decorators.action(
detail=True,
methods=["get", "post"],
ordering=["created_at"],
url_path="children",
)
def children(self, request, *args, **kwargs):
"""Handle listing and creating children of a item"""
item = self.get_object()
if request.method == "POST":
# Create a child item
serializer = serializers.CreateItemSerializer(
data=request.data, context=self.get_serializer_context()
)
serializer.is_valid(raise_exception=True)
entitlements_backend = get_entitlements_backend()
can_upload = entitlements_backend.can_upload(self.request.user)
if (
serializer.validated_data.get("type") == models.ItemTypeChoices.FILE
and not can_upload["result"]
):
raise drf.exceptions.PermissionDenied(
detail=can_upload.get(
"message", "You do not have permission to upload files."
)
)
child_item = models.Item.objects.create_child(
creator=request.user,
parent=item,
**serializer.validated_data,
)
# Set the created instance to the serializer
serializer.instance = child_item
headers = self.get_success_headers(serializer.data)
return drf.response.Response(
serializer.data, status=status.HTTP_201_CREATED, headers=headers
)
# GET: List children
queryset = (
item.children().select_related("creator").filter(deleted_at__isnull=True)
)
queryset = self._filter_suspicious_items(queryset, request.user)
queryset = self.filter_queryset(queryset)
filterset = ItemFilter(request.GET, queryset=queryset)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
queryset = filterset.qs
# Apply ordering only now that everything is filtered and annotated
queryset = filters.OrderingFilter().filter_queryset(
self.request, queryset, self
)
# Pre-compute number of accesses
item_nb_accesses = item.nb_accesses
queryset = queryset.annotate(
_nb_accesses=db.Value(item_nb_accesses)
+ Coalesce(db.Count("accesses", distinct=True), 0),
)
# Pass ancestors' links paths mapping to the serializer as a context variable
# in order to allow saving time while computing abilities on the instance
paths_links_mapping = item.compute_ancestors_links_paths_mapping()
return self.get_response_for_queryset(
queryset,
context={
"request": request,
"paths_links_mapping": paths_links_mapping,
},
)
@drf.decorators.action(detail=True, methods=["get"])
def tree(self, request, pk=None):
"""
List ancestors tree above the item
What we need to display is the tree structure opened for the current document.
"""
try:
item = self.queryset.only("path").get(pk=pk)
except models.Item.DoesNotExist as exc:
raise drf.exceptions.NotFound from exc
highest_ancestor = (
self.queryset.filter(
path__ancestors=item.path, ancestors_deleted_at__isnull=True
)
.readable_per_se(request.user)
.only("path")
.order_by("path")
.first()
)
if not highest_ancestor:
raise (
drf.exceptions.PermissionDenied()
if request.user.is_authenticated
else drf.exceptions.NotAuthenticated()
)
ancestors = (
self.queryset.filter(
path__ancestors=item.path,
path__descendants=highest_ancestor.path,
ancestors_deleted_at__isnull=True,
)
.order_by("path")
.values_list("path", "link_reach", "link_role", named=True)
)
if len(ancestors) == 0:
raise (
drf.exceptions.PermissionDenied()
if request.user.is_authenticated
else drf.exceptions.NotAuthenticated()
)
paths_links_mapping = {}
ancestors_links = []
clause = db.Q()
for i, ancestor in enumerate(ancestors):
# exclude first iteration
if i == 0:
# this is the highest ancestor, select it directly
clause |= db.Q(path=ancestor.path)
else:
# Select all siblings of the current ancestor
clause |= db.Q(
path__descendants=".".join(ancestor.path[:-1]),
path__depth=len(ancestor.path),
)
# Compute cache for ancestors links to avoid many queries while computing
# abilties for his items in the tree!
ancestors_links.append(
{"link_reach": ancestor.link_reach, "link_role": ancestor.link_role}
)
paths_links_mapping[str(ancestor.path)] = ancestors_links.copy()
tree = (
self.queryset.select_related("creator")
.filter(clause, type=models.ItemTypeChoices.FOLDER, deleted_at__isnull=True)
.order_by("created_at")
)
tree = self.annotate_user_roles(tree)
tree = self.annotate_is_favorite(tree)
tree = self._filter_suspicious_items(tree, request.user)
serializer = self.get_serializer(
tree,
many=True,
context={
"request": request,
"paths_links_mapping": paths_links_mapping,
},
)
return drf.response.Response(
utils.flat_to_nested(serializer.data), status=drf.status.HTTP_200_OK
)
@drf.decorators.action(detail=True, methods=["get"])
def breadcrumb(self, request, *args, **kwargs):
"""
List the breadcrumb for an item
"""
item = self.get_object()
highest_ancestor = (
self.queryset.filter(
path__ancestors=item.path, ancestors_deleted_at__isnull=True
)
.readable_per_se(request.user)
.only("path")
.order_by("path")
.first()
)
if not highest_ancestor:
raise (
drf.exceptions.PermissionDenied()
if request.user.is_authenticated
else drf.exceptions.NotAuthenticated()
)
breadcrumb = self.queryset.filter(
path__ancestors=item.path,
path__descendants=highest_ancestor.path,
ancestors_deleted_at__isnull=True,
).order_by("path")
serializer = self.get_serializer(breadcrumb, many=True)
return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK)
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
@method_decorator(refresh_oidc_access_token)
def _indexed_search(self, request, queryset, indexer, text):
"""
Returns a DRF response containding the results the fulltext search of Find
sorted by score.
"""
user = request.user
token = request.session.get("oidc_access_token")
# Retrieve the documents ids from Find. No pagination here the queryset is
# already filtered
result_ids = [
r["_id"]
for r in indexer.search(
text=text, token=token, visited=get_visited_items_ids_of(queryset, user)
)
]
queryset = queryset.filter(pk__in=result_ids)
queryset = self.annotate_user_roles(queryset)
queryset = self.annotate_is_favorite(queryset)
files_by_uuid = {str(d.pk): d for d in queryset}
ordered_files = [files_by_uuid[id] for id in result_ids if id in files_by_uuid]
page = self.paginate_queryset(ordered_files)
if page is not None:
items = self._compute_parents(page)
serializer = self.get_serializer(items, many=True)
result = self.get_paginated_response(serializer.data)
return result
items = self._compute_parents(ordered_files)
serializer = self.get_serializer(items, many=True)
return drf.response.Response(serializer.data)
@drf.decorators.action(
detail=False,
methods=["get"],
url_path="search",
pagination_class=drf.pagination.PageNumberPagination,
)
def search(self, request, *args, **kwargs):
"""
Returns a DRF response containing the filtered, annotated and ordered items.
Applies filtering based on request parameter 'q' from `SearchItemFilter`.
Depending of the configuration it can be:
- A fulltext search through the opensearch indexation app "find" if the backend is
enabled (see SEARCH_INDEXER_CLASS) and the feature flag INDEXED_SEARCH_ENABLED is True
- A filtering by the model fields 'title' & 'type'.
"""
queryset = self.queryset
indexer = get_file_indexer()
filterset = SearchItemFilter(
request.GET, queryset=queryset, request=self.request
)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
workspace = filterset.form.cleaned_data.get("workspace")
# First look for all top level items user has access to
user = request.user
item_access_queryset = models.ItemAccess.objects.select_related("item").filter(
db.Q(user=user) | db.Q(team__in=user.teams),
item__type=models.ItemTypeChoices.FOLDER,
item__deleted_at__isnull=True,
item__path__depth=1,
)
if workspace:
item_access_queryset = item_access_queryset.filter(item__id=workspace)
top_level_items = item_access_queryset.values_list("item__path", flat=True)
# Then look for all items that are children of the top level items
if not top_level_items:
return self.get_response_for_queryset(queryset.none())
path_list = db.Q()
for top_level_item in top_level_items:
path_list |= db.Q(path__descendants=top_level_item)
queryset = queryset.filter(path_list)
# use indexed search ONLY when the feature flag is enabled
if indexer and settings.FEATURES_INDEXED_SEARCH is True:
# When the indexer is configured pop "title" from queryset search and use
# fulltext results instead.
return self._indexed_search(
request,
queryset,
indexer,
text=filterset.form.cleaned_data.pop("title"),
)
# Without the indexer, the "title" filtering is kept
queryset = filterset.filter_queryset(queryset)
queryset = self.annotate_user_roles(queryset)
queryset = self.annotate_is_favorite(queryset)
page = self.paginate_queryset(queryset)
if page is not None:
items = self._compute_parents(page)
serializer = self.get_serializer(items, many=True)
result = self.get_paginated_response(serializer.data)
return result
items = self._compute_parents(queryset)
serializer = self.get_serializer(items, many=True)
return drf.response.Response(serializer.data)
def _compute_parents(self, items):
"""
Compute parents for the items by analyzing their paths and fetching missing parents.
"""
# Build parents dictionary and collect missing parent IDs
parents = {str(item.id): item for item in items}
missing_parent_ids = set()
for item in items:
for item_id in item.path:
if item_id not in parents and item_id not in missing_parent_ids:
missing_parent_ids.add(item_id)
# Fetch missing ancestors from database
if missing_parent_ids:
for parent in models.Item.objects.filter(
id__in=missing_parent_ids
).iterator():
parents[str(parent.id)] = parent
# Set parents for each item
for item in items:
item.parents = [
parents[item_id] for item_id in item.path if item_id != str(item.id)
]
return items
@drf.decorators.action(detail=True, methods=["put"], url_path="link-configuration")
def link_configuration(self, request, *args, **kwargs):
"""Update link configuration with specific rights (cf get_abilities)."""
# Check permissions first
item = self.get_object()
# Deserialize and validate the data
serializer = serializers.LinkItemSerializer(
item, data=request.data, partial=True
)
serializer.is_valid(raise_exception=True)
serializer.save()
return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK)
@drf.decorators.action(detail=True, methods=["post", "delete"], url_path="favorite")
def favorite(self, request, *args, **kwargs):
"""
Mark or unmark the item as a favorite for the logged-in user based on the HTTP method.
"""
# Check permissions first
item = self.get_object()
user = request.user
if request.method == "POST":
# Try to mark as favorite
try:
models.ItemFavorite.objects.create(item=item, user=user)
except ValidationError:
return drf.response.Response(
{"detail": "item already marked as favorite"},
status=drf.status.HTTP_200_OK,
)
return drf.response.Response(
{"detail": "item marked as favorite"},
status=drf.status.HTTP_201_CREATED,
)
# Handle DELETE method to unmark as favorite
deleted, _ = models.ItemFavorite.objects.filter(item=item, user=user).delete()
if deleted:
return drf.response.Response(
{"detail": "item unmarked as favorite"},
status=drf.status.HTTP_204_NO_CONTENT,
)
return drf.response.Response(
{"detail": "item was already not marked as favorite"},
status=drf.status.HTTP_200_OK,
)
def _authorize_subrequest(self, request, pattern):
"""
Shared method to authorize access based on the original URL of an Nginx subrequest
and user permissions. Returns a dictionary of URL parameters if authorized.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
Based on the original url and the logged in user, we must decide if we authorize Nginx
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
Parameters:
- pattern: The regex pattern to extract identifiers from the URL.
Returns:
- A dictionary of URL parameters if the request is authorized.
Raises:
- PermissionDenied if authorization fails.
"""
# Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
if not original_url:
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
raise drf.exceptions.PermissionDenied()
parsed_url = urlparse(unquote(original_url))
match = pattern.search(parsed_url.path)
# If the path does not match the pattern, try to extract the parameters from the query
if not match:
match = pattern.search(parsed_url.query)
if not match:
logger.debug(
"Subrequest URL '%s' did not match pattern '%s'",
parsed_url.path,
pattern,
)
raise drf.exceptions.PermissionDenied()
try:
url_params = match.groupdict()
except (ValueError, AttributeError) as exc:
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
raise drf.exceptions.PermissionDenied() from exc
pk = url_params.get("pk")
if not pk:
logger.debug("item ID (pk) not found in URL parameters: %s", url_params)
raise drf.exceptions.PermissionDenied()
# Fetch the item and check if the user has access
queryset = models.Item.objects.all()
queryset = self._filter_suspicious_items(queryset, request.user)
try:
item = queryset.get(pk=pk)
except models.Item.DoesNotExist as exc:
logger.debug("item with ID '%s' does not exist", pk)
raise drf.exceptions.PermissionDenied() from exc
user_abilities = item.get_abilities(request.user)
if not user_abilities.get(self.action, False):
logger.debug(
"User '%s' lacks permission for item '%s'", request.user.id, pk
)
raise drf.exceptions.PermissionDenied()
logger.debug(
"Subrequest authorization successful. Extracted parameters: %s", url_params
)
return url_params, user_abilities, request.user.id, item
@drf.decorators.action(detail=False, methods=["get"], url_path="media-auth")
def media_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to an item's
attachment file.
When we let the request go through, we compute authorization headers that will be added to
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
annotation. The request will then be proxied to the object storage backend who will
respond with the file after checking the signature included in headers.
"""
url_params, _, _, item = self._authorize_subrequest(
request, MEDIA_STORAGE_URL_PATTERN
)
if item.type != models.ItemTypeChoices.FILE:
logger.debug("Item '%s' is not a file", item.id)
raise drf.exceptions.PermissionDenied()
if item.upload_state == models.ItemUploadStateChoices.PENDING:
logger.debug("Item '%s' is not ready", item.id)
raise drf.exceptions.PermissionDenied()
if url_params.get("preview") and not utils.is_previewable_item(item):
logger.debug("Item '%s' is not previewable", item.id)
raise drf.exceptions.PermissionDenied()
# Generate S3 authorization headers using the extracted URL parameters
request = utils.generate_s3_authorization_headers(f"{url_params.get('key'):s}")
return drf.response.Response("authorized", headers=request.headers, status=200)
@drf.decorators.action(detail=True, methods=["get"], url_path="wopi")
def wopi(self, request, *args, **kwargs):
"""
This view is used to generate an access token and access token ttl in order to start
a WOPI session for the item and the current user.
"""
item = self.get_object()
if not (wopi_client := get_wopi_client_config(item, request.user)):
raise drf.exceptions.ValidationError(
{"detail": "This item does not suport WOPI integration."}
)
service = access_service.AccessUserItemService()
access_token, access_token_ttl = service.insert_new_access(item, request.user)
get_file_info = reverse("files-detail", kwargs={"pk": item.id})
language = (
request.user.language
if request.user.is_authenticated
else settings.LANGUAGE_CODE
)
launch_url = compute_wopi_launch_url(wopi_client, get_file_info, language)
return drf.response.Response(
{
"access_token": access_token,
"access_token_ttl": access_token_ttl,
"launch_url": launch_url,
},
status=drf.status.HTTP_200_OK,
)
class ItemAccessViewSet(
ResourceAccessViewsetMixin,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with item accesses.
GET /api/v1.0/items/<resource_id>/accesses/:<item_access_id>
Return list of all item accesses related to the logged-in user or one
item access if an id is provided.
POST /api/v1.0/items/<resource_id>/accesses/ with expected data:
- user: str
- role: str [administrator|editor|reader]
Return newly created item access
PUT /api/v1.0/items/<resource_id>/accesses/<item_access_id>/ with expected data:
- role: str [owner|admin|editor|reader]
Return updated item access
PATCH /api/v1.0/items/<resource_id>/accesses/<item_access_id>/ with expected data:
- role: str [owner|admin|editor|reader]
Return partially updated item access
DELETE /api/v1.0/items/<resource_id>/accesses/<item_access_id>/
Delete targeted item access
"""
lookup_field = "pk"
pagination_class = Pagination
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
queryset = models.ItemAccess.objects.select_related("user").all()
resource_field_name = "item"
serializer_class = serializers.ItemAccessSerializer
def perform_create(self, serializer):
"""Add a new access to the item and send an email to the new added user."""
access = serializer.save()
access.item.send_invitation_email(
access.user.email,
access.role,
self.request.user,
self.request.user.language or settings.LANGUAGE_CODE,
)
class InvitationViewset(
drf.mixins.CreateModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to item.
GET /api/v1.0/items/<item_id>/invitations/:<invitation_id>/
Return list of invitations related to that item or one
item access if an id is provided.
POST /api/v1.0/items/<item_id>/invitations/ with expected data:
- email: str
- role: str [administrator|editor|reader]
Return newly created invitation (issuer and item are automatically set)
PATCH /api/v1.0/items/<item_id>/invitations/:<invitation_id>/ with expected data:
- role: str [owner|admin|editor|reader]
Return partially updated item invitation
DELETE /api/v1.0/items/<item_id>/invitations/<invitation_id>/
Delete targeted invitation
"""
lookup_field = "id"
pagination_class = Pagination
permission_classes = [
permissions.CanCreateInvitationPermission,
permissions.AccessPermission,
]
queryset = (
models.Invitation.objects.all().select_related("item").order_by("-created_at")
)
serializer_class = serializers.InvitationSerializer
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(item=self.kwargs["resource_id"])
if self.action == "list":
user = self.request.user
teams = user.teams
# Determine which role the logged-in user has in the item
user_roles_query = (
models.ItemAccess.objects.filter(
db.Q(user=user) | db.Q(team__in=teams),
item=self.kwargs["resource_id"],
)
.values("item")
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
queryset = (
# The logged-in user should be administrator or owner to see its accesses
queryset.filter(
db.Q(
item__accesses__user=user,
item__accesses__role__in=models.PRIVILEGED_ROLES,
)
| db.Q(
item__accesses__team__in=teams,
item__accesses__role__in=models.PRIVILEGED_ROLES,
),
)
# Abilities are computed based on logged-in user's role and
# the user role on each item access
.annotate(user_roles=db.Subquery(user_roles_query))
.distinct()
)
return queryset
def perform_create(self, serializer):
"""Save invitation to a item then send an email to the invited user."""
invitation = serializer.save()
invitation.item.send_invitation_email(
invitation.email,
invitation.role,
self.request.user,
self.request.user.language or settings.LANGUAGE_CODE,
)
class ConfigView(drf.views.APIView):
"""API ViewSet for sharing some public settings."""
permission_classes = [AllowAny]
def get(self, request):
"""
GET /api/v1.0/config/
Return a dictionary of public settings.
"""
array_settings = [
"CRISP_WEBSITE_ID",
"ENVIRONMENT",
"FRONTEND_THEME",
"FRONTEND_MORE_LINK",
"FRONTEND_FEEDBACK_BUTTON_SHOW",
"FRONTEND_FEEDBACK_BUTTON_IDLE",
"FRONTEND_FEEDBACK_ITEMS",
"FRONTEND_FEEDBACK_MESSAGES_WIDGET_ENABLED",
"FRONTEND_FEEDBACK_MESSAGES_WIDGET_API_URL",
"FRONTEND_FEEDBACK_MESSAGES_WIDGET_CHANNEL",
"FRONTEND_FEEDBACK_MESSAGES_WIDGET_PATH",
"FRONTEND_HIDE_GAUFRE",
"MEDIA_BASE_URL",
"POSTHOG_KEY",
"POSTHOG_HOST",
"LANGUAGES",
"LANGUAGE_CODE",
"SENTRY_DSN",
]
dict_settings = {}
for setting in array_settings:
if hasattr(settings, setting):
dict_settings[setting] = getattr(settings, setting)
dict_settings["theme_customization"] = self._load_theme_customization()
return drf.response.Response(dict_settings)
def _load_theme_customization(self):
if not settings.THEME_CUSTOMIZATION_FILE_PATH:
return {}
cache_key = (
f"theme_customization_{slugify(settings.THEME_CUSTOMIZATION_FILE_PATH)}"
)
theme_customization = cache.get(cache_key, {})
if theme_customization:
return theme_customization
try:
with open(
settings.THEME_CUSTOMIZATION_FILE_PATH, "r", encoding="utf-8"
) as f:
theme_customization = json.load(f)
except FileNotFoundError:
logger.error(
"Configuration file not found: %s",
settings.THEME_CUSTOMIZATION_FILE_PATH,
)
except json.JSONDecodeError:
logger.error(
"Configuration file is not a valid JSON: %s",
settings.THEME_CUSTOMIZATION_FILE_PATH,
)
else:
cache.set(
cache_key,
theme_customization,
settings.THEME_CUSTOMIZATION_CACHE_TIMEOUT,
)
return theme_customization
class SDKRelayEventViewset(drf.viewsets.ViewSet):
"""API View for SDK relay interactions."""
permission_classes = [AllowAny]
throttle_scope = "sdk_event_relay"
def get_permissions(self):
"""
Return the list of permissions that this view requires.
"""
if self.action == "create":
return [permissions.IsAuthenticated()]
return super().get_permissions()
def handle_cors(self, request, response):
"""Handle CORS preflight requests."""
# Same approach as here:
# https://github.com/adamchainz/django-cors-headers/blob/b04460f37cbf458984bb377d8e6afb56776c3465/src/corsheaders/middleware.py#L96
origin = request.headers.get("origin")
if origin and origin in settings.SDK_CORS_ALLOWED_ORIGINS:
response[ACCESS_CONTROL_ALLOW_ORIGIN] = origin
response[ACCESS_CONTROL_ALLOW_METHODS] = "GET, OPTIONS"
def retrieve(self, request, pk=None):
"""
GET /api/v1.0/sdk-relay/events/<token>/
"""
sdk_relay = SDKRelayManager()
event = sdk_relay.get_event(pk)
response = drf.response.Response(event)
self.handle_cors(request, response)
return response
def create(self, request):
"""
POST /api/v1.0/sdk-relay/events/
"""
serializer = serializers.SDKRelayEventSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
sdk_relay = SDKRelayManager()
sdk_relay.register_event(
serializer.validated_data.get("token"),
serializer.validated_data.get("event"),
)
return drf.response.Response(status=status.HTTP_201_CREATED)
def options(self, request, *args, **kwargs):
"""
OPTIONS /api/v1.0/sdk-relay/events/<token>/
Handle CORS preflight requests.
"""
response = drf.response.Response(status=status.HTTP_200_OK)
self.handle_cors(request, response)
return response
class UsageMetricViewset(drf.mixins.ListModelMixin, viewsets.GenericViewSet):
"""
Viewset for usage metrics.
"""
permission_classes = [HasAPIKey]
queryset = models.User.objects.all().filter(is_active=True)
serializer_class = serializers.UsageMetricSerializer
pagination_class = Pagination
def get_queryset(self):
"""
Return the queryset applying the filters from the query params.
"""
queryset = self.queryset
if self.request.query_params.get("account_id"):
queryset = queryset.filter(id=self.request.query_params.get("account_id"))
return queryset
class EntitlementsViewset(viewsets.ViewSet):
"""API View for handling entitlements."""
permission_classes = [permissions.IsAuthenticated]
def list(self, request):
"""
GET /api/v1.0/entitlements/
"""
entitlements_backend = get_entitlements_backend()
entitlements = {}
for method_name in dir(entitlements_backend):
if method_name.startswith("can_"):
method = getattr(entitlements_backend, method_name)
if callable(method):
entitlements[method_name] = method(request.user)
return drf.response.Response(entitlements)