From dc278a606427eca917d058a3ef653389b2e74437 Mon Sep 17 00:00:00 2001 From: Florent Chehab Date: Thu, 26 Feb 2026 18:08:51 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20add=20file=20upload=20feat?= =?UTF-8?q?ure=20&=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For the coming features we will need to store files on the meet side. (for instance user backgrounds). This commits adds a new Model to manage files, and the associated serializers & viewsets. All are tested. This work was heavily inspired by the work done by our friends at https://github.com/suitenumerique/drive It build on the same architecture design (upload directly to S3 but download goes through our proxy), but model is much much simplier (no folders, no file sharing, etc.). --- .github/workflows/meet.yml | 1 + CHANGELOG.md | 4 + Makefile | 3 +- compose.yml | 2 +- docker/files/etc/nginx/conf.d/default.conf | 37 ++ env.d/development/common.dist | 2 + src/backend/core/api/filters.py | 67 ++++ src/backend/core/api/permissions.py | 22 ++ src/backend/core/api/serializers.py | 156 ++++++++ src/backend/core/api/viewsets.py | 342 +++++++++++++++++- src/backend/core/factories.py | 42 +++ src/backend/core/migrations/0017_file.py | 42 +++ src/backend/core/models.py | 188 +++++++++- src/backend/core/tasks/_task.py | 49 +++ src/backend/core/tasks/file.py | 36 ++ src/backend/core/tests/files/__init__.py | 0 .../core/tests/files/test_api_files_create.py | 323 +++++++++++++++++ .../core/tests/files/test_api_files_delete.py | 45 +++ .../core/tests/files/test_api_files_list.py | 160 ++++++++ .../files/test_api_files_list_filters.py | 216 +++++++++++ .../files/test_api_files_list_ordering.py | 71 ++++ .../tests/files/test_api_files_media_auth.py | 141 ++++++++ .../core/tests/files/test_api_files_update.py | 67 ++++ .../files/test_api_files_upload_ended.py | 272 ++++++++++++++ src/backend/core/urls.py | 1 + src/backend/core/utils.py | 48 ++- .../locale/de_DE/LC_MESSAGES/django.mo | Bin 10376 -> 11024 bytes .../locale/de_DE/LC_MESSAGES/django.po | 237 +++++++----- .../locale/en_US/LC_MESSAGES/django.mo | Bin 9427 -> 10017 bytes .../locale/en_US/LC_MESSAGES/django.po | 236 +++++++----- .../locale/fr_FR/LC_MESSAGES/django.mo | Bin 10544 -> 11277 bytes .../locale/fr_FR/LC_MESSAGES/django.po | 234 +++++++----- .../locale/nl_NL/LC_MESSAGES/django.mo | Bin 9866 -> 10514 bytes .../locale/nl_NL/LC_MESSAGES/django.po | 236 +++++++----- src/backend/meet/settings.py | 44 +++ src/backend/pyproject.toml | 1 + .../env.d/dev-dinum/values.meet.yaml.gotmpl | 1 + .../dev-keycloak/values.meet.yaml.gotmpl | 1 + src/helm/env.d/dev/values.meet.yaml.gotmpl | 1 + 39 files changed, 2937 insertions(+), 391 deletions(-) create mode 100644 src/backend/core/api/filters.py create mode 100644 src/backend/core/migrations/0017_file.py create mode 100644 src/backend/core/tasks/_task.py create mode 100644 src/backend/core/tasks/file.py create mode 100644 src/backend/core/tests/files/__init__.py create mode 100644 src/backend/core/tests/files/test_api_files_create.py create mode 100644 src/backend/core/tests/files/test_api_files_delete.py create mode 100644 src/backend/core/tests/files/test_api_files_list.py create mode 100644 src/backend/core/tests/files/test_api_files_list_filters.py create mode 100644 src/backend/core/tests/files/test_api_files_list_ordering.py create mode 100644 src/backend/core/tests/files/test_api_files_media_auth.py create mode 100644 src/backend/core/tests/files/test_api_files_update.py create mode 100644 src/backend/core/tests/files/test_api_files_upload_ended.py diff --git a/.github/workflows/meet.yml b/.github/workflows/meet.yml index b0eac232..1e83c82d 100644 --- a/.github/workflows/meet.yml +++ b/.github/workflows/meet.yml @@ -230,6 +230,7 @@ jobs: OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect OIDC_OP_URL: https://oidc.example.com + MEDIA_BASE_URL: http://localhost:8083 steps: - name: Checkout repository diff --git a/CHANGELOG.md b/CHANGELOG.md index a0f1e9e4..c80a9ebd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,10 @@ and this project adheres to - ♿(frontend) prevent focus ring clipping on invite dialog #1078 - ♿(frontend) dynamic tab title when connected to meeting #1060 +### Added + +- ✨(backend) add file upload feature #1030 + ## [1.9.0] - 2026-03-02 ### Added diff --git a/Makefile b/Makefile index cdc5261f..fcee7c6f 100644 --- a/Makefile +++ b/Makefile @@ -114,7 +114,8 @@ logs: ## display app-dev logs (follow mode) .PHONY: logs run-backend: ## start only the backend application and all needed services - @$(COMPOSE) up --force-recreate -d celery-dev + @$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans + @$(COMPOSE) up --force-recreate -d nginx @echo "Wait for postgresql to be up..." @$(WAIT_DB) .PHONY: run-backend diff --git a/compose.yml b/compose.yml index 993c2539..5b06ab46 100644 --- a/compose.yml +++ b/compose.yml @@ -84,7 +84,6 @@ services: - postgresql - mailcatcher - redis - - nginx - livekit - createbuckets - createwebhook @@ -148,6 +147,7 @@ services: - ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro depends_on: - keycloak + - app-dev networks: - resource-server - default diff --git a/docker/files/etc/nginx/conf.d/default.conf b/docker/files/etc/nginx/conf.d/default.conf index f4886b87..627bf256 100644 --- a/docker/files/etc/nginx/conf.d/default.conf +++ b/docker/files/etc/nginx/conf.d/default.conf @@ -4,10 +4,47 @@ server { server_name localhost; charset utf-8; + # Proxy auth for media + location /media/ { + # Auth request configuration + auth_request /media-auth; + auth_request_set $authHeader $upstream_http_authorization; + auth_request_set $authDate $upstream_http_x_amz_date; + auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256; + + # Pass specific headers from the auth response + proxy_set_header Authorization $authHeader; + proxy_set_header X-Amz-Date $authDate; + proxy_set_header X-Amz-Content-SHA256 $authContentSha256; + + # Get resource from Minio + proxy_pass http://minio:9000/meet-media-storage/; + proxy_set_header Host minio:9000; + # To use with ds_proxy + # proxy_pass http://ds-proxy:4444/upstream/meet-media-storage/; + # proxy_set_header Host ds-proxy:4444; + add_header Content-Disposition "attachment"; + } + + location /media-auth { + proxy_pass http://app-dev:8000/api/v1.0/files/media-auth/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Original-URL $request_uri; + + # Prevent the body from being passed + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-Method $request_method; + } + location / { proxy_pass http://keycloak:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Port $server_port; } } diff --git a/env.d/development/common.dist b/env.d/development/common.dist index d42b1052..1eca95d9 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -23,9 +23,11 @@ MEET_BASE_URL="http://localhost:8072" # Media STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage +AWS_S3_DOMAIN_REPLACE=http://localhost:9000 AWS_S3_ENDPOINT_URL=http://minio:9000 AWS_S3_ACCESS_KEY_ID=meet AWS_S3_SECRET_ACCESS_KEY=password +MEDIA_BASE_URL=http://localhost:8083 # OIDC OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs diff --git a/src/backend/core/api/filters.py b/src/backend/core/api/filters.py new file mode 100644 index 00000000..9216bd1e --- /dev/null +++ b/src/backend/core/api/filters.py @@ -0,0 +1,67 @@ +"""API filters for meet' core application.""" + +from django.utils.translation import gettext_lazy as _ + +import django_filters +from django_filters import BooleanFilter + +from core import models + + +class FileFilter(django_filters.FilterSet): + """ + Custom filter for filtering files. + """ + + class Meta: + model = models.File + fields = ["type"] + + +class ListFileFilter(FileFilter): + """Filter class dedicated to the file viewset list method.""" + + is_creator_me = django_filters.BooleanFilter( + method="filter_is_creator_me", label=_("Creator is me") + ) + + is_deleted = BooleanFilter(field_name="deleted_at", method="filter_is_deleted") + + class Meta: + model = models.File + fields = ["is_creator_me", "type", "upload_state", "is_deleted"] + + def filter_is_deleted(self, queryset, name, value): + """ + Filter files based on whether they are deleted or not. + + Example: + - /api/v1.0/files/?is_deleted=false + → Filters files that were not deleted + """ + if value is None: + return queryset + + lookup = "__".join([name, "isnull"]) + return queryset.filter(**{lookup: not value}) + + # pylint: disable=unused-argument + def filter_is_creator_me(self, queryset, name, value): + """ + Filter files based on the `creator` being the current user. + + Example: + - /api/v1.0/files/?is_creator_me=true + → Filters files created by the logged-in user + - /api/v1.0/files/?is_creator_me=false + → Filters files created by other users + """ + user = self.request.user + + if not user.is_authenticated: + return queryset + + if value: + return queryset.filter(creator=user) + + return queryset.exclude(creator=user) diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index ce1f1934..63f695c2 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -1,5 +1,7 @@ """Permission handlers for the Meet core app.""" +from django.http import Http404 + from rest_framework import permissions from ..models import RoleChoices @@ -106,3 +108,23 @@ class HasLiveKitRoomAccess(permissions.BasePermission): if not request.auth or not hasattr(request.auth, "video"): return False return request.auth.video.room == str(obj.id) + + +class FilePermission(IsAuthenticated): + """ + Permissions applying to the file API endpoint. + Handling soft deletions specificities + """ + + def has_object_permission(self, request, view, obj): + """ + Return a 404 on deleted files or if the user is not the owner + """ + + if obj.deleted_at is not None or obj.hard_deleted_at is not None: + raise Http404 + + if obj.creator != request.user: + raise Http404 + + return obj.get_abilities(request.user).get(view.action, False) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 84092368..aaedd743 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -1,10 +1,15 @@ """Client serializers for the Meet core app.""" # pylint: disable=abstract-method,no-name-in-module +import logging +from os.path import splitext from typing import Literal +from urllib.parse import quote from django.conf import settings from django.core.exceptions import SuspiciousOperation + +# pylint: disable=abstract-method,no-name-in-module from django.utils.translation import gettext_lazy as _ from django_pydantic_field.rest_framework import SchemaField @@ -15,6 +20,8 @@ from timezone_field.rest_framework import TimeZoneSerializerField from core import models, utils +logger = logging.getLogger(__name__) + class UserSerializer(serializers.ModelSerializer): """Serialize users.""" @@ -27,6 +34,15 @@ class UserSerializer(serializers.ModelSerializer): read_only_fields = ["id", "email", "full_name", "short_name"] +class UserLightSerializer(serializers.ModelSerializer): + """Serialize users with limited fields.""" + + class Meta: + model = models.User + fields = ["id", "full_name", "short_name"] + read_only_fields = ["id", "full_name", "short_name"] + + class ResourceAccessSerializerMixin: """ A serializer mixin to share controlling that the logged-in user submitting a room access object @@ -377,3 +393,143 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer): ) return attrs + + +class ListFileSerializer(serializers.ModelSerializer): + """Serialize File model for the API.""" + + url = serializers.SerializerMethodField(read_only=True) + creator = UserLightSerializer(read_only=True) + abilities = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = models.File + fields = [ + "id", + "created_at", + "updated_at", + "title", + "type", + "creator", + "deleted_at", + "hard_deleted_at", + "filename", + "upload_state", + "mimetype", + "size", + "description", + "url", + "abilities", + ] + read_only_fields = [ + "id", + "created_at", + "updated_at", + "creator", + "deleted_at", + "hard_deleted_at", + "filename", + "upload_state", + "mimetype", + "size", + "url", + "abilities", + ] + + def get_url(self, obj): + """Return the URL of the file.""" + if obj.is_pending_upload: + return None + + return f"{settings.MEDIA_BASE_URL}{settings.MEDIA_URL}{quote(obj.file_key)}" + + def get_abilities(self, file) -> dict: + """Return abilities of the logged-in user on the instance.""" + request = self.context.get("request") + if not request: + return {} + + return file.get_abilities(request.user) + + +class FileSerializer(ListFileSerializer): + """Default serializer File model for the API.""" + + def create(self, validated_data): + raise NotImplementedError("Create method can not be used.") + + +class CreateFileSerializer(ListFileSerializer): + """Serializer used to create a new file""" + + title = serializers.CharField(max_length=255, required=False) + policy = serializers.SerializerMethodField() + + class Meta: + model = models.File + fields = [*ListFileSerializer.Meta.fields, "policy"] + read_only_fields = [ + *( + field + for field in ListFileSerializer.Meta.read_only_fields + if field != "filename" + ), + "policy", + ] + + def get_fields(self): + """Force the id field to be writable.""" + fields = super().get_fields() + fields["id"].read_only = False + + return fields + + def validate_id(self, value): + """Ensure the provided ID does not already exist when creating a new file.""" + request = self.context.get("request") + + # Only check this on POST (creation) + if request and models.File.objects.filter(id=value).exists(): + raise serializers.ValidationError( + "A file with this ID already exists. You cannot override it.", + code="file_create_existing_id", + ) + + return value + + def validate(self, attrs): + """Validate extension and fill title.""" + # we run the default validation first to make sure the base data in attrs is ok + attrs = super().validate(attrs) + + filename_root, ext = splitext(attrs["filename"]) + + if settings.FILE_UPLOAD_APPLY_RESTRICTIONS: + config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[attrs["type"]] + if ext.lower() not in config_for_file_type["allowed_extensions"]: + logger.info( + "create_item: file extension not allowed %s for filename %s", + ext, + attrs["filename"], + ) + raise serializers.ValidationError( + {"filename": _("This file extension is not allowed.")}, + code="item_create_file_extension_not_allowed", + ) + + # The title will be the filename if not provided + if not attrs.get("title", None): + attrs["title"] = filename_root + + return attrs + + def get_policy(self, file): + """Return the policy to use if the item is a file.""" + + if file.upload_state == models.FileUploadStateChoices.READY: + return None + + return utils.generate_upload_policy(file) + + def update(self, instance, validated_data): + raise NotImplementedError("Update method can not be used.") diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index f9e76659..9c8b429e 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1,16 +1,26 @@ """API endpoints""" +# pylint: disable=too-many-lines +import re import uuid from logging import getLogger -from urllib.parse import urlparse +from urllib.parse import unquote, urlparse from django.conf import settings +from django.core.files.storage import default_storage from django.db.models import Q from django.http import Http404 from django.shortcuts import get_object_or_404 from django.utils.text import slugify -from rest_framework import decorators, mixins, pagination, viewsets +from django_filters import rest_framework as django_filters +from rest_framework import ( + decorators, + filters, + mixins, + pagination, + viewsets, +) from rest_framework import ( exceptions as drf_exceptions, ) @@ -22,6 +32,7 @@ from rest_framework import ( ) from core import enums, models, utils +from core.api.filters import ListFileFilter from core.recording.enums import FileExtension from core.recording.event.authentication import StorageEventAuthentication from core.recording.event.exceptions import ( @@ -56,6 +67,7 @@ from core.services.participants_management import ( ) from core.services.room_creation import RoomCreation from core.services.subtitle import SubtitleException, SubtitleService +from core.tasks.file import process_file_deletion from ..authentication.livekit import LiveKitTokenAuthentication from . import permissions, serializers, throttling @@ -66,6 +78,17 @@ from .feature_flag import FeatureFlag logger = getLogger(__name__) +FILE_FOLDER = settings.FILE_UPLOAD_PATH +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"[\d\w]+" +MEDIA_STORAGE_URL_PATTERN = re.compile( + f"{settings.MEDIA_URL:s}" + rf"(?P{FILE_FOLDER:s}/(?P{UUID_REGEX:s})/\.{FILE_EXT_REGEX:s})$" +) + + class NestedGenericViewSet(viewsets.GenericViewSet): """ A generic Viewset aims to be used in a nested route context. @@ -77,20 +100,20 @@ class NestedGenericViewSet(viewsets.GenericViewSet): lookup_fields: list[str] = ["pk"] lookup_url_kwargs: list[str] = [] - def __getattribute__(self, item): + def __getattribute__(self, file): """ 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] + if file in ["lookup_field", "lookup_url_kwarg"]: + return getattr(self, file + "s", [None])[-1] - return super().__getattribute__(item) + return super().__getattribute__(file) def get_queryset(self): """ - Get the list of items for this view. + Get the list of files for this view. `lookup_fields` attribute is enumerated here to perform the nested lookup. """ @@ -793,7 +816,7 @@ class RecordingViewSet( # 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") + logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest") raise drf_exceptions.PermissionDenied() logger.debug("Original url: '%s'", original_url) @@ -810,7 +833,7 @@ class RecordingViewSet( try: return match.groupdict() except (ValueError, AttributeError) as exc: - logger.debug("Failed to extract parameters from subrequest URL: %s", exc) + logger.warning("Failed to extract parameters from subrequest URL: %s", exc) raise drf_exceptions.PermissionDenied() from exc @decorators.action(detail=False, methods=["get"], url_path="media-auth") @@ -834,7 +857,7 @@ class RecordingViewSet( recording_id = url_params["recording_id"] extension = url_params["extension"] - if extension not in [item.value for item in FileExtension]: + if extension not in [file.value for file in FileExtension]: raise drf_exceptions.ValidationError({"detail": "Unsupported extension."}) try: @@ -858,3 +881,302 @@ class RecordingViewSet( request = utils.generate_s3_authorization_headers(recording.key) return drf_response.Response("authorized", headers=request.headers, status=200) + + +# pylint: disable=too-many-public-methods +class FileViewSet( + SerializerPerActionMixin, + mixins.CreateModelMixin, + mixins.DestroyModelMixin, + mixins.UpdateModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): + """ + FileViewSet API. + + This viewset provides CRUD operations and additional actions for managing files. + + ### API Endpoints: + 1. **List**: Retrieve a paginated list of files. + Example: GET /files/?page=2 + 2. **Retrieve**: Get a specific file by its ID. + Example: GET /files/{id}/ + 3. **Create**: Create a new file. + Example: POST /files/ + 4. **Update**: Update a file by its ID. + Example: PUT /files/{id}/ + 5. **Delete**: Soft delete a file by its ID. + Example: DELETE /files/{id}/ + + + ### Ordering: created_at, updated_at, title + + Example: + - Ascending: GET /api/v1.0/files/?ordering=created_at + + ### Filtering: + - `is_creator_me=true`: Returns files created by the current user. + - `is_creator_me=false`: Returns files created by other users. + - `is_deleted=false`: Returns files that are not (soft) deleted + + Example: + - GET /api/v1.0/files/?is_creator_me=true + - GET /api/v1.0/files/?is_creator_me=false&is_deleted=false + + ### Notes: + - Implements soft delete logic to retain file + """ + + ordering = ["-updated_at"] + ordering_fields = ["created_at", "updated_at", "title"] + pagination_class = Pagination + permission_classes = [ + permissions.FilePermission, + ] + queryset = models.File.objects.filter(hard_deleted_at__isnull=True) + default_serializer_class = serializers.FileSerializer + serializer_classes = { + "list": serializers.ListFileSerializer, + "create": serializers.CreateFileSerializer, + } + filter_backends = (django_filters.DjangoFilterBackend, filters.OrderingFilter) + filterset_class = ListFileFilter + + def get_queryset(self): + """Get queryset that defaults to the the current request user.""" + user = self.request.user + queryset = super().get_queryset().select_related("creator") + + if not user.is_authenticated: + return queryset.none() + + # For now, we force the filtering on the current user in all cases, might evolve later + queryset = queryset.filter(creator=user) + 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) + result = self.get_paginated_response(serializer.data) + return result + + serializer = self.get_serializer(queryset, many=True, context=context) + return drf_response.Response(serializer.data) + + def perform_create(self, serializer): + """Set the current user as creator of the newly created file.""" + serializer.save(creator=self.request.user) + + def perform_destroy(self, instance): + """Override to implement a soft delete instead of dumping the record in database.""" + instance.soft_delete() + + @decorators.action(detail=True, methods=["post"], url_path="upload-ended") + def upload_ended(self, request, *args, **kwargs): + """ + Check the actual uploaded file and mark it as ready. + """ + + file = self.get_object() + + if not file.is_pending_upload: + raise drf_exceptions.ValidationError( + {"file": "This action is only available for files in PENDING state."}, + code="file_upload_state_not_pending", + ) + + s3_client = default_storage.connection.meta.client + + head_response = s3_client.head_object( + Bucket=default_storage.bucket_name, Key=file.file_key + ) + file_size = head_response["ContentLength"] + + if settings.FILE_UPLOAD_APPLY_RESTRICTIONS: + config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type] + if file_size > config_for_file_type["max_size"]: + self._complete_file_deletion(file) + logger.info( + "upload_ended: file size (%s) for file %s higher than the allowed max size", + file_size, + file.file_key, + ) + raise drf_exceptions.ValidationError( + detail="The file size is higher than the allowed max size.", + code="file_size_exceeded", + ) + + # python-magic recommends using at least the first 2048 bytes + # to reduce incorrect identification. + # This is a tradeoff between pulling in the whole file and the most likely relevant bytes + # of the file for mime type identification. + if file_size > 2048: + range_response = s3_client.get_object( + Bucket=default_storage.bucket_name, + Key=file.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=file.file_key + )["Body"].read() + + # Use improved MIME type detection combining magic bytes and file extension + logger.info("upload_ended: detecting mimetype for file: %s", file.file_key) + mimetype = utils.detect_mimetype(file_head, filename=file.filename) + + if settings.FILE_UPLOAD_APPLY_RESTRICTIONS: + config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type] + allowed_file_mimetypes = config_for_file_type["allowed_mimetypes"] + if mimetype not in allowed_file_mimetypes: + self._complete_file_deletion(file) + logger.warning( + "upload_ended: mimetype not allowed %s for file %s", + mimetype, + file.file_key, + ) + raise drf_exceptions.ValidationError( + detail="The file type is not allowed.", + code="file_type_not_allowed", + ) + + file.upload_state = models.FileUploadStateChoices.READY + file.mimetype = mimetype + file.size = file_size + + file.save(update_fields=["upload_state", "mimetype", "size"]) + + if head_response["ContentType"] != mimetype: + logger.info( + "upload_ended: content type mismatch between object storage and file," + " updating from %s to %s", + head_response["ContentType"], + mimetype, + ) + s3_client.copy_object( + Bucket=default_storage.bucket_name, + Key=file.file_key, + CopySource={ + "Bucket": default_storage.bucket_name, + "Key": file.file_key, + }, + ContentType=mimetype, + Metadata=head_response["Metadata"], + MetadataDirective="REPLACE", + ) + + # Not yet implemented + # Change the file.upload_state when this will be done + # malware_detection.analyse_file(file.file_key, file_id=file.id) + + serializer = self.get_serializer(file) + + return drf_response.Response(serializer.data, status=drf_status.HTTP_200_OK) + + def _complete_file_deletion(self, file): + """Delete a file completely.""" + file.soft_delete() + file.hard_delete() + process_file_deletion.delay(file.id) + + def _authorize_subrequest(self, request, pattern): + """ + 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.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest") + raise drf_exceptions.PermissionDenied() + + parsed_url = urlparse(original_url) + match = pattern.search(unquote(parsed_url.path)) + + if not match: + logger.warning( + "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.warning("Failed to extract parameters from subrequest URL: %s", exc) + raise drf_exceptions.PermissionDenied() from exc + + pk = url_params.get("pk") + if not pk: + logger.warning("File ID (pk) not found in URL parameters: %s", url_params) + raise drf_exceptions.PermissionDenied() + + # Fetch the file and check if the user has access + queryset = models.File.objects.all() + # No suspicious analysis implemented yet + # queryset = self._filter_suspicious_files(queryset, request.user) + try: + file = queryset.get(pk=pk) + except models.File.DoesNotExist as exc: + logger.warning("File with ID '%s' does not exist", pk) + raise drf_exceptions.PermissionDenied() from exc + + user_abilities = file.get_abilities(request.user) + if not user_abilities.get(self.action, False): + logger.warning( + "User '%s' lacks permission for file '%s'", request.user.id, pk + ) + raise drf_exceptions.PermissionDenied() + + logger.debug( + "Subrequest authorization successful. Extracted parameters: %s", url_params + ) + return url_params, request.user.id, file + + @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 file'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, _, file = self._authorize_subrequest( + request, MEDIA_STORAGE_URL_PATTERN + ) + + if file.is_pending_upload: + logger.warning("File '%s' is not ready", file.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) diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index 43f4fdfc..de56f98b 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -2,8 +2,11 @@ Core application factories """ +from io import BytesIO + from django.conf import settings from django.contrib.auth.hashers import make_password +from django.core.files.storage import default_storage from django.utils.text import slugify import factory.fuzzy @@ -153,3 +156,42 @@ class ApplicationDomainFactory(factory.django.DjangoModelFactory): domain = factory.Faker("domain_name") application = factory.SubFactory(ApplicationFactory) + + +class FileFactory(factory.django.DjangoModelFactory): + """A factory to create files""" + + class Meta: + model = models.File + skip_postgeneration_save = True + + title = factory.Sequence(lambda n: f"file{n}") + creator = factory.SubFactory(UserFactory) + deleted_at = None + type = factory.fuzzy.FuzzyChoice([t[0] for t in models.FileTypeChoices.choices]) + filename = factory.lazy_attribute(lambda o: fake.file_name()) + upload_state = None + size = None + + @factory.post_generation + def update_upload_state(self, create, extracted, **kwargs): + """Change the upload state of a file.""" + if create and extracted: + self.upload_state = extracted + self.save() + + @factory.post_generation + def upload_bytes(self, create, extracted, **kwargs): + """Save content of the file into the storage""" + if create and extracted is not None: + content = ( + extracted + if isinstance(extracted, bytes) + else str(extracted).encode("utf-8") + ) + + self.filename = kwargs.get("filename", self.filename or "content.txt") + self.size = len(content) + self.save() + + default_storage.save(self.file_key, BytesIO(content)) diff --git a/src/backend/core/migrations/0017_file.py b/src/backend/core/migrations/0017_file.py new file mode 100644 index 00000000..b2908870 --- /dev/null +++ b/src/backend/core/migrations/0017_file.py @@ -0,0 +1,42 @@ +# Generated by Django 5.2.11 on 2026-03-03 15:22 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0016_recording_options'), + ] + + operations = [ + migrations.CreateModel( + name='File', + 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')), + ('type', models.CharField(choices=[('background_image', 'Background image')], max_length=25)), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('hard_deleted_at', models.DateTimeField(blank=True, null=True)), + ('filename', models.CharField(max_length=255)), + ('upload_state', models.CharField(choices=[('pending', 'Pending'), ('ready', 'Ready')], max_length=25)), + ('mimetype', models.CharField(blank=True, max_length=255, null=True)), + ('size', models.BigIntegerField(blank=True, null=True)), + ('description', models.TextField(blank=True, null=True)), + ('malware_detection_info', models.JSONField(blank=True, default=dict, help_text='Malware detection info when the analysis status is unsafe.', null=True)), + ('creator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='files_created', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'File', + 'verbose_name_plural': 'Files', + 'db_table': 'file', + 'ordering': ('created_at',), + 'indexes': [models.Index(fields=['creator', 'type', '-created_at'], name='file_creator_730cce_idx')], + }, + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 8cf50fa0..dcf9af15 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1,11 +1,14 @@ """ Declare and configure the models for the Meet core application +# pylint: disable=too-many-lines """ +# pylint: disable=too-many-lines import secrets import uuid from datetime import datetime, timedelta from logging import getLogger +from os.path import splitext from typing import List, Optional from django.conf import settings @@ -14,7 +17,7 @@ from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.postgres.fields import ArrayField from django.core import mail, validators from django.core.exceptions import PermissionDenied, ValidationError -from django.db import models +from django.db import models, transaction from django.utils import timezone from django.utils.text import capfirst, slugify from django.utils.translation import gettext_lazy as _ @@ -829,3 +832,186 @@ class ApplicationDomain(BaseModel): self.domain = self.domain.lower().strip() super().save(*args, **kwargs) + + +class FileUploadStateChoices(models.TextChoices): + """Possible states of a file.""" + + PENDING = "pending", _("Pending") + # Commented out for now, as we may need this when we implement the malware detection logic. + # ANALYZING = "analyzing", _("Analyzing") + # SUSPICIOUS = "suspicious", _("Suspicious") + # FILE_TOO_LARGE_TO_ANALYZE = ( + # "file_too_large_to_analyze", + # _("File too large to analyze"), + # ) + READY = "ready", _("Ready") + + +class FileTypeChoices(models.TextChoices): + """Defines the possible types of a file.""" + + BACKGROUND_IMAGE = "background_image", _("Background image") + + +class File(BaseModel): + """File uploaded by a user.""" + + type = models.CharField( + max_length=25, + choices=FileTypeChoices.choices, + null=False, + blank=False, + ) + title = models.CharField(_("title"), max_length=255) + creator = models.ForeignKey( + User, + on_delete=models.RESTRICT, + related_name="files_created", + blank=True, + null=True, + ) + deleted_at = models.DateTimeField(null=True, blank=True) + hard_deleted_at = models.DateTimeField(null=True, blank=True) + + filename = models.CharField(max_length=255, null=False, blank=False) + + upload_state = models.CharField( + max_length=25, + choices=FileUploadStateChoices.choices, + ) + mimetype = models.CharField(max_length=255, null=True, blank=True) + size = models.BigIntegerField(null=True, blank=True) + description = models.TextField(null=True, blank=True) + malware_detection_info = models.JSONField( + null=True, + blank=True, + default=dict, + help_text=_("Malware detection info when the analysis status is unsafe."), + ) + + class Meta: + db_table = "file" + verbose_name = _("File") + verbose_name_plural = _("Files") + ordering = ("created_at",) + indexes = [ + models.Index(fields=["creator", "type", "-created_at"]), + ] + + def __str__(self): + return str(self.title) + + def save(self, *args, **kwargs): + """Set the upload state to pending if it's the first save and it's a file.""" + + if self.created_at is None: + self.upload_state = FileUploadStateChoices.PENDING + + return super().save(*args, **kwargs) + + def delete(self, using=None, keep_parents=False): + if self.deleted_at is None: + raise RuntimeError("The file must be soft deleted before being deleted.") + + return super().delete(using, keep_parents) + + @property + def is_pending_upload(self): + """Return whether the file is in a pending upload state""" + return self.upload_state == FileUploadStateChoices.PENDING + + @property + def extension(self): + """Return the extension related to the filename.""" + if self.filename is None: + raise RuntimeError( + "The file must have a filename to compute its extension." + ) + + _, extension = splitext(self.filename) + + if extension: + return extension.lstrip(".") + + return None + + @property + def key_base(self): + """Key base of the location where the file is stored in object storage.""" + if not self.pk: + raise RuntimeError( + "The file instance must be saved before requesting a storage key." + ) + + return f"{settings.FILE_UPLOAD_PATH}/{self.pk!s}" + + @property + def file_key(self): + """Key used to store the file in object storage.""" + _, extension = splitext(self.filename) + # We store only the extension in the storage system to avoid + # leaking Personal Information in logs, etc. + return f"{self.key_base}/{extension!s}" + + def get_abilities(self, user): + """ + Compute and return abilities for a given user on the file. + """ + # Characteristics that are based only on specific access + is_creator = user == self.creator + retrieve = is_creator + is_deleted = self.deleted_at is not None + can_update = is_creator and not is_deleted and user.is_authenticated + can_hard_delete = is_creator and user.is_authenticated + can_destroy = can_hard_delete and not is_deleted + + return { + "destroy": can_destroy, + "hard_delete": can_hard_delete, + "retrieve": retrieve, + "media_auth": retrieve and not is_deleted, + "partial_update": can_update, + "update": can_update, + "upload_ended": can_update and user.is_authenticated, + } + + @transaction.atomic + def soft_delete(self): + """ + Soft delete the file. + We still keep the .delete() method untouched for programmatic purposes. + """ + if self.deleted_at: + raise RuntimeError("This file is already deleted.") + + self.deleted_at = timezone.now() + self.save(update_fields=["deleted_at"]) + + def hard_delete(self): + """ + Hard delete the file. + We still keep the .delete() method untouched for programmatic purposes. + """ + if self.hard_deleted_at: + raise ValidationError( + { + "hard_deleted_at": ValidationError( + _("This file is already hard deleted."), + code="file_hard_delete_already_effective", + ) + } + ) + + if self.deleted_at is None: + raise ValidationError( + { + "hard_deleted_at": ValidationError( + _("To hard delete a file, it must first be soft deleted."), + code="file_hard_delete_should_soft_delete_first", + ) + } + ) + + self.hard_deleted_at = timezone.now() + self.save(update_fields=["hard_deleted_at"]) diff --git a/src/backend/core/tasks/_task.py b/src/backend/core/tasks/_task.py new file mode 100644 index 00000000..01c362a0 --- /dev/null +++ b/src/backend/core/tasks/_task.py @@ -0,0 +1,49 @@ +# ruff: noqa: PLC0415 + +from django.conf import settings + + +def task(*d_args, **d_kwargs): + """ + Decorator compatible with Celery's @app.task, but works without Celery. + + If Celery is available, returns a real Celery task. + If not, returns the original function and provides `.delay()`/`.apply_async()` + as synchronous fallbacks (so existing call sites don't break). + + Notes: + Mostly LLM-generated. + """ + + def _fallback_wrap(func): + def delay(*args, **kwargs): + return func(*args, **kwargs) + + def apply_async(args=None, kwargs=None, **_options): + return func(*(args or ()), **(kwargs or {})) + + func.delay = delay + func.apply_async = apply_async + return func + + # Handle bare decorator usage: @task + if len(d_args) == 1 and callable(d_args[0]) and not d_kwargs: + func = d_args[0] + if settings.CELERY_ENABLED: + from meet.celery_app import app as _celery_app + + return _celery_app.task(func) + return _fallback_wrap(func) + + # Handle parameterized usage: @task(...), e.g. @task(bind=True) + def _decorate(func): + if settings.CELERY_ENABLED: + from meet.celery_app import app as _celery_app + + return _celery_app.task(*d_args, **d_kwargs)(func) + return _fallback_wrap(func) + + return _decorate + + +__all__ = ("task",) diff --git a/src/backend/core/tasks/file.py b/src/backend/core/tasks/file.py new file mode 100644 index 00000000..ed4b6ee8 --- /dev/null +++ b/src/backend/core/tasks/file.py @@ -0,0 +1,36 @@ +""" +Tasks related to files. +""" + +import logging + +from django.core.files.storage import default_storage + +from core.models import File +from core.tasks._task import task + +logger = logging.getLogger(__name__) + + +@task +def process_file_deletion(file_id): + """ + Process the deletion of a file. + Definitely delete it in the database. + Delete the files from the storage. + """ + logger.info("Processing item deletion for %s", file_id) + try: + file = File.objects.get(id=file_id) + except File.DoesNotExist: + logger.error("Item %s does not exist", file_id) + return + + if file.hard_deleted_at is None: + logger.error("To process an item deletion, it must be hard deleted first.") + return + + logger.info("Deleting file %s", file.file_key) + default_storage.delete(file.file_key) + + file.delete() diff --git a/src/backend/core/tests/files/__init__.py b/src/backend/core/tests/files/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/tests/files/test_api_files_create.py b/src/backend/core/tests/files/test_api_files_create.py new file mode 100644 index 00000000..f02c3476 --- /dev/null +++ b/src/backend/core/tests/files/test_api_files_create.py @@ -0,0 +1,323 @@ +""" +Tests for files API endpoint in meet's core app: create +""" + +from concurrent.futures import ThreadPoolExecutor +from urllib.parse import parse_qs, urlparse +from uuid import uuid4 + +from django.utils import timezone + +import pytest +from freezegun import freeze_time +from rest_framework import status +from rest_framework.test import APIClient + +from core import factories +from core.models import File, FileTypeChoices, FileUploadStateChoices + +pytestmark = pytest.mark.django_db + + +def test_api_files_create_anonymous(): + """Anonymous users should not be allowed to create items.""" + response = APIClient().post( + "/api/v1.0/files/", + { + "title": "My file", + "type": FileTypeChoices.BACKGROUND_IMAGE, + }, + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert not File.objects.exists() + + +def test_api_files_create_authenticated_success(): + """ + Authenticated users should be able to create files and should automatically be declared + as the owner of the newly created file. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.post( + "/api/v1.0/files/", + { + "title": "my file", + "filename": "my_file.png", + "type": FileTypeChoices.BACKGROUND_IMAGE, + }, + format="json", + ) + + assert response.status_code == 201, response.json() + file = File.objects.get() + assert file.title == "my file" + assert file.creator == user + assert file.type == FileTypeChoices.BACKGROUND_IMAGE + assert file.upload_state == FileUploadStateChoices.PENDING + + +def test_api_files_create_file_authenticated_no_filename(): + """ + Creating a file item without providing a filename should fail. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.post( + "/api/v1.0/files/", + { + "title": "my item", + "type": FileTypeChoices.BACKGROUND_IMAGE, + }, + format="json", + ) + assert response.status_code == 400 + assert response.json() == {"filename": ["This field is required."]} + + +def test_api_files_create_file_authenticated_success(): + """ + Authenticated users should be able to create a file file and must provide a filename. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + now = timezone.now() + with freeze_time(now): + response = client.post( + "/api/v1.0/files/", + { + "type": FileTypeChoices.BACKGROUND_IMAGE, + "title": "Eiffle tower", + "filename": "file.png", + }, + format="json", + ) + assert response.status_code == 201 + file = File.objects.get() + assert file.title == "Eiffle tower" + assert file.type == FileTypeChoices.BACKGROUND_IMAGE + assert file.filename == "file.png" + + response_data = response.json() + assert response_data["creator"] is not None, response_data + + assert response.json().get("policy") is not None + + policy = response.json()["policy"] + policy_parsed = urlparse(policy) + + assert policy_parsed.scheme == "http" + assert policy_parsed.netloc == "localhost:9000" + assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}/.png" + + query_params = parse_qs(policy_parsed.query) + + assert query_params.pop("X-Amz-Algorithm") == ["AWS4-HMAC-SHA256"] + assert query_params.pop("X-Amz-Credential") == [ + f"meet/{now.strftime('%Y%m%d')}/us-east-1/s3/aws4_request" + ] + assert query_params.pop("X-Amz-Date") == [now.strftime("%Y%m%dT%H%M%SZ")] + assert query_params.pop("X-Amz-Expires") == ["60"] + assert query_params.pop("X-Amz-SignedHeaders") == ["host;x-amz-acl"] + assert query_params.pop("X-Amz-Signature") is not None + + assert len(query_params) == 0 + + +def test_api_files_create_file_authenticated_extension_not_allowed(): + """ + Creating a file item with an extension not allowed should fail. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + response = client.post( + "/api/v1.0/files/", + { + "type": FileTypeChoices.BACKGROUND_IMAGE, + "title": "Paris tower", + "filename": "file.notallowed", + }, + format="json", + ) + assert response.status_code == 400 + assert response.json() == {"filename": ["This file extension is not allowed."]} + + +def test_api_files_create_file_authenticated_extension_case_insensitive(): + """ + Creating a file item with an extension, no matter the case used, should be allowed. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + response = client.post( + "/api/v1.0/files/", + { + "type": FileTypeChoices.BACKGROUND_IMAGE, + "filename": "file.JPG", + }, + format="json", + ) + assert response.status_code == 201, response.json() + file = File.objects.get() + assert file.title == "file" + + +def test_api_files_create_file_authenticated_not_checking_extension(settings): + """ + Creating a file with an extension not allowed should not fail when restrictions are disabled. + """ + settings.FILE_UPLOAD_APPLY_RESTRICTIONS = False + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + response = client.post( + "/api/v1.0/files/", + { + "type": FileTypeChoices.BACKGROUND_IMAGE, + "filename": "file.notallowed", + }, + format="json", + ) + assert response.status_code == 201, response.json() + file = File.objects.get() + assert file.title == "file" + + +def test_api_files_create_file_authenticated_no_extension_but_checking_it_should_fail( + settings, +): + """ + Creating a file without an extension but checking the extension should fail. + """ + settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + response = client.post( + "/api/v1.0/files/", + { + "type": FileTypeChoices.BACKGROUND_IMAGE, + "filename": "file", + }, + format="json", + ) + assert response.status_code == 400 + assert response.json() == {"filename": ["This file extension is not allowed."]} + + +def test_api_files_create_file_authenticated_hidden_file_but_checking_extension_should_fail( + settings, +): + """ + Creating a hidden file (starting with a dot) but checking the extension should fail. + """ + settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + response = client.post( + "/api/v1.0/files/", + { + "type": FileTypeChoices.BACKGROUND_IMAGE, + "filename": ".file", + }, + ) + + assert response.status_code == 400 + assert response.json() == {"filename": ["This file extension is not allowed."]} + + +def test_api_files_create_force_id_success(): + """It should be possible to force the item ID when creating a item.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + forced_id = uuid4() + + response = client.post( + "/api/v1.0/files/", + { + "id": str(forced_id), + "title": "my item", + "type": FileTypeChoices.BACKGROUND_IMAGE, + "filename": "my_file.png", + }, + format="json", + ) + + assert response.status_code == 201, response.json() + files = File.objects.all() + assert len(files) == 1 + assert files[0].id == forced_id + + +def test_api_files_create_force_id_existing(): + """ + It should not be possible to use the ID of an existing file when forcing ID on creation. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + file = factories.FileFactory() + + response = client.post( + "/api/v1.0/files/", + { + "id": str(file.id), + "title": "my file", + "type": FileTypeChoices.BACKGROUND_IMAGE, + "filename": "my_file.png", + }, + format="json", + ) + + assert response.status_code == 400 + assert response.json() == { + "id": ["A file with this ID already exists. You cannot override it."] + } + + +@pytest.mark.django_db(transaction=True) +def test_api_files_create_file_race_condition(): + """ + It should be possible to create several files at the same time + without causing any race conditions or data integrity issues. + """ + + def create_item(title): + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + return client.post( + "/api/v1.0/files/", + { + "title": title, + "type": FileTypeChoices.BACKGROUND_IMAGE, + "filename": "my_file.png", + }, + format="json", + ) + + with ThreadPoolExecutor(max_workers=2) as executor: + future1 = executor.submit(create_item, "my item 1") + future2 = executor.submit(create_item, "my item 2") + + response1 = future1.result() + response2 = future2.result() + + assert response1.status_code == 201 + assert response2.status_code == 201 diff --git a/src/backend/core/tests/files/test_api_files_delete.py b/src/backend/core/tests/files/test_api_files_delete.py new file mode 100644 index 00000000..7afafc01 --- /dev/null +++ b/src/backend/core/tests/files/test_api_files_delete.py @@ -0,0 +1,45 @@ +""" +Tests for files API endpoint in meet's core app: delete +""" + +import pytest +from rest_framework.test import APIClient + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def test_api_files_delete_anonymous(): + """Anonymous users should not be allowed to destroy a file.""" + file = factories.FileFactory() + existing_items = models.File.objects.all().count() + + response = APIClient().delete( + f"/api/v1.0/files/{file.id!s}/", + ) + + assert response.status_code == 401 + assert models.File.objects.count() == existing_items + + +def test_api_files_delete_authenticated_owner(): + """ + Authenticated users should be able to delete a item they own. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + file = factories.FileFactory(creator=user) + + response = client.delete( + f"/api/v1.0/files/{file.id}/", + ) + + assert response.status_code == 204 + + # Make sure it is only a soft delete + file.refresh_from_db() + assert file.deleted_at is not None diff --git a/src/backend/core/tests/files/test_api_files_list.py b/src/backend/core/tests/files/test_api_files_list.py new file mode 100644 index 00000000..d08b4870 --- /dev/null +++ b/src/backend/core/tests/files/test_api_files_list.py @@ -0,0 +1,160 @@ +""" +Tests for files API endpoint in meet's core app: list +""" + +from unittest import mock + +from django.utils import timezone + +import pytest +from faker import Faker +from rest_framework.pagination import PageNumberPagination +from rest_framework.test import APIClient + +from core import factories, models + +fake = Faker() +pytestmark = pytest.mark.django_db + + +def test_api_files_list_anonymous_not_allowed(): + """ + Anonymous users should not be allowed to list files whatever the + """ + response = APIClient().get("/api/v1.0/files/") + assert response.status_code == 401 + + +def test_api_files_list_authentificated_user_allowed(): + """ + Authentificated users should be allowed to list files + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + response = client.get("/api/v1.0/files/") + assert response.status_code == 200 + assert response.data == {"count": 0, "next": None, "previous": None, "results": []} + + +def test_api_files_list_format(): + """Validate the format of files as returned by the list view.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + file = factories.FileFactory( + type=models.FileTypeChoices.BACKGROUND_IMAGE, + title="item 1", + creator=user, + ) + + # A file from another user should not appear + factories.FileFactory( + type=models.FileTypeChoices.BACKGROUND_IMAGE, + title="item 2", + ) + + # hard deleted item should not appear + factories.FileFactory( + type=models.FileTypeChoices.BACKGROUND_IMAGE, + hard_deleted_at=timezone.now(), + title="hard deleted item", + creator=user, + ) + + response = client.get("/api/v1.0/files/") + + assert response.status_code == 200 + content = response.json() + results = content.pop("results") + assert content == { + "count": 1, + "next": None, + "previous": None, + } + assert len(results) == 1 + assert results == [ + { + "id": str(file.id), + "created_at": file.created_at.isoformat().replace("+00:00", "Z"), + "creator": { + "id": str(file.creator.id), + "full_name": file.creator.full_name, + "short_name": file.creator.short_name, + }, + "title": file.title, + "updated_at": file.updated_at.isoformat().replace("+00:00", "Z"), + "type": models.FileTypeChoices.BACKGROUND_IMAGE, + "upload_state": file.upload_state, + "url": None, + "mimetype": file.mimetype, + "filename": file.filename, + "size": None, + "description": None, + "deleted_at": None, + "hard_deleted_at": None, + "abilities": { + "destroy": True, + "hard_delete": True, + "media_auth": True, + "partial_update": True, + "retrieve": True, + "update": True, + "upload_ended": True, + }, + } + ] + + +@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2) +def test_api_files_list_pagination( + _mock_page_size, +): + """Pagination should work as expected.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + file_ids = [ + str(file.id) + for file in factories.FileFactory.create_batch( + 3, + creator=user, + type=models.FileTypeChoices.BACKGROUND_IMAGE, + ) + ] + # Get page 1 + response = client.get( + "/api/v1.0/files/", + ) + + assert response.status_code == 200 + content = response.json() + + assert content["count"] == 3 + assert content["next"] == "http://testserver/api/v1.0/files/?page=2" + assert content["previous"] is None + + assert len(content["results"]) == 2 + for item in content["results"]: + file_ids.remove(item["id"]) + + # Get page 2 + response = client.get( + "/api/v1.0/files/?page=2", + ) + + assert response.status_code == 200 + content = response.json() + + assert content["count"] == 3 + assert content["next"] is None + assert content["previous"] == "http://testserver/api/v1.0/files/" + + assert len(content["results"]) == 1 + for item in content["results"]: + file_ids.remove(item["id"]) + assert file_ids == [] diff --git a/src/backend/core/tests/files/test_api_files_list_filters.py b/src/backend/core/tests/files/test_api_files_list_filters.py new file mode 100644 index 00000000..36aa0ff5 --- /dev/null +++ b/src/backend/core/tests/files/test_api_files_list_filters.py @@ -0,0 +1,216 @@ +""" +Tests for files API endpoint in meet's core app: list +""" + +import pytest +from faker import Faker +from rest_framework.test import APIClient + +from core import factories, models + +fake = Faker() +pytestmark = pytest.mark.django_db + + +# Filters: unknown field + + +def test_api_files_list_filter_unknown_field(): + """ + Trying to filter by an unknown field should do nothing. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + factories.FileFactory(type=models.FileTypeChoices.BACKGROUND_IMAGE) + expected_ids = { + str(file.id) + for file in factories.FileFactory.create_batch( + 2, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE + ) + } + + response = client.get("/api/v1.0/files/?unknown=true") + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 2 + assert {result["id"] for result in results} == expected_ids + + +# Filters: is_creator_me + + +def test_api_files_list_filter_is_creator_me_true(): + """ + Authenticated users should be able to filter files they created. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + factories.FileFactory.create_batch( + 2, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE + ) + factories.FileFactory.create_batch(2, type=models.FileTypeChoices.BACKGROUND_IMAGE) + + response = client.get("/api/v1.0/files/?is_creator_me=true") + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 2 + + # Ensure all results are created by the current user + for result in results: + assert result["creator"] == { + "id": str(user.id), + "full_name": user.full_name, + "short_name": user.short_name, + } + + +def test_api_files_list_filter_is_creator_me_invalid(): + """Filtering with an invalid `is_creator_me` value should do nothing.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + factories.FileFactory.create_batch( + 3, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE + ) + + response = client.get("/api/v1.0/files/?is_creator_me=invalid") + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 3 + + +# Filters: type + + +def test_api_files_list_filter_type_and_upload_status(): + """ + Authenticated users should be able to filter files by their type and upload status. + + This test will make more sense when other types are added to the API + """ + + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + file = factories.FileFactory( + creator=user, + type=models.FileTypeChoices.BACKGROUND_IMAGE, + update_upload_state=models.FileUploadStateChoices.PENDING, + ) + assert file.upload_state == models.FileUploadStateChoices.PENDING + + expected_files = factories.FileFactory.create_batch( + 2, + creator=user, + type=models.FileTypeChoices.BACKGROUND_IMAGE, + update_upload_state=models.FileUploadStateChoices.READY, + ) + expected_files_ids = {str(file.id) for file in expected_files} + + # Filter by type: background_image & upload state + response = client.get("/api/v1.0/files/?type=background_image&upload_state=ready") + + assert response.status_code == 200 + assert response.json()["count"] == 2 + + results = response.json()["results"] + + # Ensure all results are background images + results_ids = {result["id"] for result in results} + assert results_ids == expected_files_ids + for result in results: + assert result["type"] == models.FileTypeChoices.BACKGROUND_IMAGE + assert result["upload_state"] == models.FileUploadStateChoices.READY + + # Second request without the upload_state filter, to check that all 3 show up + response = client.get("/api/v1.0/files/?type=background_image") + + assert response.status_code == 200 + assert response.json()["count"] == 3 + + results = response.json()["results"] + + # Ensure all results are background images + results_ids = {result["id"] for result in results} + assert results_ids == {str(file.id) for file in expected_files + [file]} + for result in results: + assert result["type"] == models.FileTypeChoices.BACKGROUND_IMAGE + + +def test_api_files_list_filter_is_deleted(): + """ + Authenticated users should be able to filter files by their deletion status. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + not_deleted_file = factories.FileFactory(creator=user) + + deleted_files = factories.FileFactory.create_batch(2, creator=user) + for file in deleted_files: + file.soft_delete() + + # No filters + response_no_filters = client.get("/api/v1.0/files/") + + assert response_no_filters.status_code == 200 + assert response_no_filters.json()["count"] == 3 + + results = response_no_filters.json()["results"] + + results_ids = {result["id"] for result in results} + assert results_ids == {str(file.id) for file in [*deleted_files, not_deleted_file]} + + # Filters deleted + response_filter_deleted = client.get("/api/v1.0/files/?is_deleted=true") + + assert response_filter_deleted.status_code == 200 + assert response_filter_deleted.json()["count"] == 2 + + results = response_filter_deleted.json()["results"] + + results_ids = {result["id"] for result in results} + assert results_ids == {str(file.id) for file in deleted_files} + + # Filters not deleted + response_filter_not_deleted = client.get("/api/v1.0/files/?is_deleted=false") + + assert response_filter_not_deleted.status_code == 200 + assert response_filter_not_deleted.json()["count"] == 1 + + results = response_filter_not_deleted.json()["results"] + + # Ensure all results are deleted + results_ids = {result["id"] for result in results} + assert results_ids == {str(file.id) for file in [not_deleted_file]} + + +def test_api_files_list_filter_unknown_type(): + """ + Filtering by an unknown type should return an empty list + """ + + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + factories.FileFactory.create_batch(3, creator=user) + + response = client.get("/api/v1.0/files/?type=unknown") + + assert response.status_code == 400 + assert response.json() == { + "type": ["Select a valid choice. unknown is not one of the available choices."] + } diff --git a/src/backend/core/tests/files/test_api_files_list_ordering.py b/src/backend/core/tests/files/test_api_files_list_ordering.py new file mode 100644 index 00000000..8c47b954 --- /dev/null +++ b/src/backend/core/tests/files/test_api_files_list_ordering.py @@ -0,0 +1,71 @@ +"""Test the ordering of items.""" + +import operator + +import pytest +from rest_framework.test import APIClient + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def test_api_files_list_ordering_default(): + """items should be ordered by descending "updated_at" by default""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + factories.FileFactory.create_batch( + 4, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE + ) + response = client.get("/api/v1.0/files/") + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 4 + + # Check that results are sorted by descending "updated_at" as expected + for i in range(3): + assert operator.ge(results[i]["updated_at"], results[i + 1]["updated_at"]) + + +def test_api_files_list_ordering_by_fields(): + """It should be possible to order by several fields""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + factories.FileFactory.create_batch( + 4, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE + ) + + for parameter in [ + "created_at", + "-created_at", + "updated_at", + "-updated_at", + ]: + is_descending = parameter.startswith("-") + field = parameter.lstrip("-") + querystring = f"?ordering={parameter}" + + response = client.get(f"/api/v1.0/files/{querystring:s}") + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 4 + + # Check that results are sorted by the field in querystring as expected + compare = operator.ge if is_descending else operator.le + for i in range(3): + operator1 = ( + results[i][field].lower() + if isinstance(results[i][field], str) + else results[i][field] + ) + operator2 = ( + results[i + 1][field].lower() + if isinstance(results[i + 1][field], str) + else results[i + 1][field] + ) + assert compare(operator1, operator2) diff --git a/src/backend/core/tests/files/test_api_files_media_auth.py b/src/backend/core/tests/files/test_api_files_media_auth.py new file mode 100644 index 00000000..c537a5f7 --- /dev/null +++ b/src/backend/core/tests/files/test_api_files_media_auth.py @@ -0,0 +1,141 @@ +""" +Test file uploads API endpoint for users in meet's core app. +""" + +from io import BytesIO +from urllib.parse import quote, urlparse + +from django.conf import settings +from django.core.files.storage import default_storage +from django.utils import timezone + +import pytest +import requests +from freezegun import freeze_time +from rest_framework.test import APIClient + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def test_api_files_media_auth_anonymous_not_authorized(): + """Anonymous users should not be allowed to retrieve a file""" + file = factories.FileFactory( + type=models.FileTypeChoices.BACKGROUND_IMAGE, + update_upload_state=models.FileUploadStateChoices.READY, + ) + + original_url = f"http://localhost/media/{file.file_key:s}" + response = APIClient().get( + "/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 401 + + +def test_api_files_media_get_own(): + """ + Authenticated user should be allowed to retrieve their own file. + """ + user = factories.UserFactory() + + file = factories.FileFactory( + type=models.FileTypeChoices.BACKGROUND_IMAGE, + update_upload_state=models.FileUploadStateChoices.READY, + creator=user, + ) + + client = APIClient() + client.force_login(user) + + default_storage.save( + file.file_key, + BytesIO(b"my prose"), + ) + + original_url = f"http://localhost/media/{file.file_key:s}" + now = timezone.now() + with freeze_time(now): + response = client.get( + "/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 200 + + authorization = response["Authorization"] + assert "AWS4-HMAC-SHA256 Credential=" in authorization + assert ( + "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" + in authorization + ) + assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ") + + s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) + file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{file.file_key:s}" + response = requests.get( + file_url, + headers={ + "authorization": authorization, + "x-amz-date": response["x-amz-date"], + "x-amz-content-sha256": response["x-amz-content-sha256"], + "Host": f"{s3_url.hostname:s}:{s3_url.port:d}", + }, + timeout=1, + ) + assert response.content.decode("utf-8") == "my prose" + + +def test_api_files_media_auth_file_pending(): + """ + Users who have a specific access to an file, whatever the role, should not be able to + retrieve related attachments if the file is not ready. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + file = factories.FileFactory( + type=models.FileTypeChoices.BACKGROUND_IMAGE, + upload_state=models.FileUploadStateChoices.PENDING, + creator=user, + ) + + key = file.file_key + + original_url = quote(f"http://localhost/media/{key:s}") + response = client.get( + "/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 403 + + +def test_api_files_media_auth_own_file_deleted(): + """ + This function tests the access restrictions on deleted files through the media + authorization API endpoint. It ensures that a user cannot retrieve a file that is deleted. + """ + user = factories.UserFactory() + + file = factories.FileFactory( + type=models.FileTypeChoices.BACKGROUND_IMAGE, + update_upload_state=models.FileUploadStateChoices.READY, + creator=user, + ) + + client = APIClient() + client.force_login(user) + + default_storage.save( + file.file_key, + BytesIO(b"my prose"), + ) + file.soft_delete() + + original_url = f"http://localhost/media/{file.file_key:s}" + response = client.get( + "/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 403 diff --git a/src/backend/core/tests/files/test_api_files_update.py b/src/backend/core/tests/files/test_api_files_update.py new file mode 100644 index 00000000..660f4ba2 --- /dev/null +++ b/src/backend/core/tests/files/test_api_files_update.py @@ -0,0 +1,67 @@ +""" +Tests for files API endpoint in meet's core app: update +""" + +import pytest +from rest_framework.test import APIClient + +from core import factories +from core.api import serializers + +pytestmark = pytest.mark.django_db + + +def test_api_files_update_anonymous_forbidden(): + """ + Anonymous users should not be allowed to update an file when link + configuration does not allow it. + """ + + file = factories.FileFactory() + + old_file_values = serializers.FileSerializer(instance=file).data + new_file_values = serializers.FileSerializer(instance=factories.FileFactory()).data + + response = APIClient().put( + f"/api/v1.0/files/{file.id!s}/", + new_file_values, + format="json", + ) + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + file.refresh_from_db() + item_values = serializers.FileSerializer(instance=file).data + assert item_values == old_file_values + + +def test_api_files_update_description_and_title(): + """ + Test the description and title of an file can be updated. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + file = factories.FileFactory( + description="Old description", + title="Old title", + creator=user, + ) + + response = client.patch( + f"/api/v1.0/files/{file.id!s}/", + {"description": "New description", "title": "New title"}, + format="json", + ) + assert response.status_code == 200 + result = response.json() + assert result["description"] == "New description" + assert result["title"] == "New title" + + file.refresh_from_db() + assert file.description == "New description" + assert file.title == "New title" diff --git a/src/backend/core/tests/files/test_api_files_upload_ended.py b/src/backend/core/tests/files/test_api_files_upload_ended.py new file mode 100644 index 00000000..7f238955 --- /dev/null +++ b/src/backend/core/tests/files/test_api_files_upload_ended.py @@ -0,0 +1,272 @@ +"""Test related to item upload ended API.""" + +import logging +from io import BytesIO + +from django.core.files.storage import default_storage + +import pytest +from rest_framework.test import APIClient + +from core import factories, models +from core.models import FileTypeChoices, FileUploadStateChoices + +pytestmark = pytest.mark.django_db + + +def test_api_file_upload_ended_anonymous(): + """Anonymous users should not be allowed to end an upload.""" + file = factories.FileFactory() + response = APIClient().post(f"/api/v1.0/files/{file.id!s}/upload-ended/") + + assert response.status_code == 401 + + +def test_api_file_upload_ended_non_creator_not_found(): + """Users without write permissions should not be allowed to end an upload.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + file = factories.FileFactory() + + response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/") + + assert response.status_code == 404 + + +def test_api_file_upload_ended_on_wrong_upload_state(): + """ + Users should not be allowed to end an upload on files that are not in the PENDING upload state. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + file = factories.FileFactory( + type=FileTypeChoices.BACKGROUND_IMAGE, + creator=user, + update_upload_state=FileUploadStateChoices.READY, + ) + + response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/") + + assert response.status_code == 400 + assert response.json() == { + "file": "This action is only available for files in PENDING state." + } + + +def test_api_file_upload_ended_success(settings): + """ + Users should be able to end an upload on files that are files and in the UPLOADING upload state. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True + settings.FILE_UPLOAD_RESTRICTIONS = { + "background_image": { + **settings.FILE_UPLOAD_RESTRICTIONS["background_image"], + "allowed_mimetypes": ["text/html", "text/plain"], + }, + } + + file = factories.FileFactory( + type=FileTypeChoices.BACKGROUND_IMAGE, + filename="my_file.txt", + mimetype="text/html", + creator=user, + ) + + default_storage.save( + file.file_key, + BytesIO(b"my prose"), + ) + + response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/") + + assert response.status_code == 200 + + file.refresh_from_db() + assert file.upload_state == FileUploadStateChoices.READY + assert file.mimetype == "text/plain" + assert file.size == 8 + + assert response.json()["mimetype"] == "text/plain" + + +def test_api_file_upload_ended_mimetype_not_allowed(settings, caplog): + """ + Test that the API returns a 400 when the mimetype is not allowed. + File should be deleted and the file should be deleted from the storage. + """ + settings.RESTRICT_UPLOAD_FILE_TYPE = True + settings.FILE_UPLOAD_RESTRICTIONS = { + "background_image": { + **settings.FILE_UPLOAD_RESTRICTIONS["background_image"], + "allowed_mimetypes": ["application/pdf"], + } + } + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + file = factories.FileFactory( + type=FileTypeChoices.BACKGROUND_IMAGE, filename="my_file.txt", creator=user + ) + + default_storage.save( + file.file_key, + BytesIO(b"my prose"), + ) + + with caplog.at_level(logging.WARNING): + response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/") + + assert response.status_code == 400 + assert ( + f"upload_ended: mimetype not allowed text/plain for file {file.file_key}" + in caplog.text + ) + + assert not models.File.objects.filter(id=file.id).exists() + assert not default_storage.exists(file.file_key) + + +def test_api_file_upload_ended_mimetype_not_allowed_not_checking_mimetype(settings): + """ + Test that the API returns a 200 when the mimetype is not allowed but not checking the mimetype. + """ + settings.FILE_UPLOAD_APPLY_RESTRICTIONS = False + settings.FILE_UPLOAD_RESTRICTIONS = { + "background_image": { + **settings.FILE_UPLOAD_RESTRICTIONS["background_image"], + "allowed_mimetypes": ["application/pdf"], + } + } + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + file = factories.FileFactory( + type=FileTypeChoices.BACKGROUND_IMAGE, filename="my_file.txt", creator=user + ) + + default_storage.save( + file.file_key, + BytesIO(b"my prose"), + ) + + response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/") + + assert response.status_code == 200 + + file.refresh_from_db() + assert file.upload_state == FileUploadStateChoices.READY + assert file.mimetype == "text/plain" + assert file.size == 8 + + assert response.json()["mimetype"] == "text/plain" + + +def test_api_upload_ended_mismatch_mimetype_with_object_storage(settings, caplog): + """ + Object on storage should have the same mimetype than the one saved in the + File object. + """ + settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True + settings.FILE_UPLOAD_RESTRICTIONS = { + "background_image": { + **settings.FILE_UPLOAD_RESTRICTIONS["background_image"], + "allowed_mimetypes": ["text/html", "application/pdf"], + } + } + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + file = factories.FileFactory( + type=FileTypeChoices.BACKGROUND_IMAGE, + filename="my_file.pdf", + title="my_file.pdf", + creator=user, + ) + + s3_client = default_storage.connection.meta.client + + s3_client.put_object( + Bucket=default_storage.bucket_name, + Key=file.file_key, + ContentType="text/html", + Body=BytesIO( + b'' + ), + Metadata={ + "foo": "bar", + }, + ) + + head_object = s3_client.head_object( + Bucket=default_storage.bucket_name, Key=file.file_key + ) + + assert head_object["ContentType"] == "text/html" + with caplog.at_level(logging.INFO, logger="core.api.viewsets"): + response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/") + assert ( + "upload_ended: content type mismatch between object storage and file," + " updating from text/html to application/pdf" in caplog.text + ) + assert response.status_code == 200 + + file.refresh_from_db() + + assert file.mimetype == "application/pdf" + + head_object = s3_client.head_object( + Bucket=default_storage.bucket_name, Key=file.file_key + ) + assert head_object["ContentType"] == "application/pdf" + assert head_object["Metadata"] == {"foo": "bar"} + + +def test_api_upload_ended_file_size_exceeded(settings, caplog): + """ + Test when the file size exceed the allowed max upload file size + should return a 400 and delete the file. + """ + + settings.FILE_UPLOAD_RESTRICTIONS = { + "background_image": { + **settings.FILE_UPLOAD_RESTRICTIONS["background_image"], + "max_size": 0, + } + } + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + file = factories.FileFactory( + type=FileTypeChoices.BACKGROUND_IMAGE, filename="my_file.txt", creator=user + ) + + default_storage.save( + file.file_key, + BytesIO(b"my prose"), + ) + + with caplog.at_level(logging.INFO, logger="core.api.viewsets"): + response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/") + assert ( + f"upload_ended: file size (8) for file {file.file_key} higher than the allowed max size" + in caplog.text + ) + assert response.status_code == 400 + + assert not models.File.objects.filter(id=file.id).exists() + assert not default_storage.exists(file.file_key) diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index d8493e5a..caa75fea 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -14,6 +14,7 @@ router = DefaultRouter() router.register("users", viewsets.UserViewSet, basename="users") router.register("rooms", viewsets.RoomViewSet, basename="rooms") router.register("recordings", viewsets.RecordingViewSet, basename="recordings") +router.register("files", viewsets.FileViewSet, basename="files") router.register( "resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses" ) diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index f4403660..3afba6bc 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -19,6 +19,7 @@ from django.conf import settings from django.core.files.storage import default_storage import aiohttp +import boto3 import botocore import magic from asgiref.sync import async_to_sync @@ -382,8 +383,10 @@ def detect_mimetype(file_buffer: bytes, filename: str | None = None) -> str: # Use guess_file_type (Python 3.13+) instead of deprecated guess_type mimetype_from_extension, _ = mimetypes.guess_file_type(filename, strict=False) - logger.info("detect_mimetype: mimetype_from_content: %s", mimetype_from_content) - logger.info("detect_mimetype: mimetype_from_extension: %s", mimetype_from_extension) + logger.debug("detect_mimetype: mimetype_from_content: %s", mimetype_from_content) + logger.debug( + "detect_mimetype: mimetype_from_extension: %s", mimetype_from_extension + ) # Strategy: Prefer content-based detection, but use extension if: # 1. Content detection returns generic types (application/octet-stream, text/plain) @@ -411,3 +414,44 @@ def detect_mimetype(file_buffer: bytes, filename: str | None = None) -> str: # Default to content-based detection (most reliable) return mimetype_from_content or "application/octet-stream" + + +def generate_upload_policy(file): + """ + Generate a S3 upload policy for a given file. + + Notes: + Originally taken from https://github.com/suitenumerique/drive/blob/564822d31f071c6dfacd112ef4b7146c73077cd9/src/backend/core/api/utils.py#L102 # pylint: disable=line-too-long + """ + + key = file.file_key + + # This settings should be used if the backend application and the frontend application + # can't connect to the object storage with the same domain. This is the case in the + # docker compose stack used in development. The frontend application will use localhost + # to connect to the object storage while the backend application will use the object storage + # service name declared in the docker compose stack. + # This is needed because the domain name is used to compute the signature. So it can't be + # changed dynamically by the frontend application. + if settings.AWS_S3_DOMAIN_REPLACE: + s3_client = boto3.client( + "s3", + aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY, + endpoint_url=settings.AWS_S3_DOMAIN_REPLACE, + config=botocore.client.Config( + region_name=settings.AWS_S3_REGION_NAME, + signature_version=settings.AWS_S3_SIGNATURE_VERSION, + ), + ) + else: + s3_client = default_storage.connection.meta.client + + # Generate the policy + policy = s3_client.generate_presigned_url( + ClientMethod="put_object", + Params={"Bucket": default_storage.bucket_name, "Key": key, "ACL": "private"}, + ExpiresIn=settings.AWS_S3_UPLOAD_POLICY_EXPIRATION, + ) + + return policy diff --git a/src/backend/locale/de_DE/LC_MESSAGES/django.mo b/src/backend/locale/de_DE/LC_MESSAGES/django.mo index fd9e4b59579e2c7b170531b155045c63ee1b7e0e..cd59071495a77454351259d58be72252aceeed08 100644 GIT binary patch delta 3258 zcmY+_d2AGA7{~Fq7llF#Rs;co!E#ifEm9Dn3cV000)mu70iAZI+mY?8&dySb;u`Rb z7tlaLQZFFUfcHjX&?t%;;UC2#CMMn{8lwJzAwV?f_qQ{cIN4`D@667;$Me4L?!(J> ztjc^_Ubx$EWQZ$>p@qi0goFBX;y7AjjDzptYMeCCnDLmwDm;KLeul+(-5_K7VKbKE zV&p5c7R%AWOK~#}QL~Rx;A5T&ZSY`(L9f`UR)pndtsFZcgO78r5Jc7U4Ra zjBQwpkE1&F9uCFhcnf}un>Fm&WyTEV#_Pz(yw7O@p2TT*7Daj+b%08`a@Ms1Ch~>c~6gL}h9c^P-N_<5Wzd7Fi!EBWJM*^GL5UJfD-^Tc4qzk#0gd zZMLFDwiDH0A5O!MQO})6Rj9Y~p#-Vhl;Tx59yLWvQ6pcCs&5NwKo4L6?m|sLW>54$ zA8L-CkG^;jhv%^?@E-1;7-7tvxL~9)SL2&li>I&~$BYUW-5ylM`%rWJ0;s zwN_3d8PAxXC}_2wMlGrfSc7H!8tH|(s27%@Qf#B1+kqPKv#89Ri0+@n2Cjd=*;v6u zD>rkm|imq?M7OvOe{kR{s zmMX1qOcv@Wp>O6WSyEBs7$q>>RXN(cAD8hK@Hu7blz-3>M;9I6@7r3^UqOpS;Usn zlnln@I1W`$3e|xJk!53cqDFoYwYJ_tE!JbGj(lA~{&nLAZm5BCsF6$_A3ksmPUHG| z)QCN7!cC}#j^MTUA4 z5%Z#J?XF6dHGVSw-?1?&RUuQJ+eSUu7Tv$dii-toiOLHw7L_;Pny4(aD7O-e34YIV ze@4HPs)FCWlDZ$3b9RoP)x1*HMf@&bHCu~9sQ@iU1 zLI=AucihBT+qQ;vYi_5|L2M!7#FfN!;tpax;So9-LvnvMDz*~^(Y;!{EGnDqHv@jp z8`5aEZ;boNv=_77E<5h@9v@g$+T=TSkn}A#Wpz2(GXrPl_jePHxq(x5QdwEvY&+3y z`;HZJ0;fH2lb+>z9Z9RZ)A6jJ)3I#NPV}UBI~CYLI;DEjUdrxps(Wh(e^ungTrb{R zJY-6N9qY-~4B3}o-$B)uvpI0QRIa*|9(NSG`mj92_Le$(|*jc+H!BFENY7%dpi-haVzd5jvY?%UUqTCsG_2kF2n3~ HDl7g4hSHd~ delta 2641 zcmYk+drXye9LMn=huZV`F zYC6~$m|hrh)X;{BG~zj3wDJEe(rr=gg#Ua$fUc_uXj#|J+7+?-_kxm&-pSkD5VTj?dZmPosAJE^6WrP^p@IF3sAq?j(T4=I&c8_GeZHcE9ks{%E=k@;yKg{ zXHYx3j(nNsmVN(ER4Srbl~NFgDPeqK_%!1jRylxgViBg^VSOD9_$1?JP&E~}Nheff zcUn1hqE@;Xl~WIrE>na$>T1-38?g}EQNKHkI>`xCO?`pd*fi<{oOfCG6S0zUI^M7E zzk`l;{wiwcZ(u)uiYKvz|4L-VOxn((pYh*Vj>Qy&PG|^KT+dfFqHEQ84{BzO9 zdQd4FM3=t*F*@4OF|5MZQ49JR^}{*T4jt*%M2nEQjR!U1YCM8#Q16*SP52YCdGi}m z7AC=CJ)eQpu_;1VfKCk^eq%P{QXEFT;5aIGr%)@OLZ#vdY{VO=7uE8RYNZuboL#7e z??=_h%SgGHW2jWTZI91jKofjMM=Sr!{y_|R@G*9wc36q}3hGf49m4zY1eW8MScXZ< ztOc*fdl(0hSDN=xwec;IRTGtA%zE@@aQ@2e05_^|3@h*=mS9Y#_2NpTu+4rf#iO_i zuV5d>XIUpSip`7z0XyQ6E;Q8iPCO3?-k$4=DO(2Y(Uu?=7t<5kLk9Z^dR6FS!Igqn8D@5~-T zyH*Z25DycZ2sPzj3(#(q8trl|F~4bp{lwtBo7?E?qEZ^LRr-}!&-F}stD>+HUYS9PEhjv#UfHEJvnI>rhHEkq}w_J~F3O3j3h zlK)fxx0UpiU$sYxVnSa;7ooJdhykLNs3cSqN}HOWplm|z34Q;o>29=d?6pwNt~UhnU~m?SZQQLE>@cU!ThS)?g31QUBTE zi7cXoQ1e=ZzIAnnh;;kjZmhTa`m*wgErg0vg`7tiPkpy%U;nUYSI0>3a_n^2_}RF; z;O}vMN6??RAZ&cpb#=TsX?bugX~Gfgb>~I|Po{Q;k0+#620PLw9l`4Ky\n" "Language-Team: LANGUAGE \n" @@ -29,141 +29,149 @@ msgstr "Berechtigungen" msgid "Important dates" msgstr "Wichtige Daten" -#: core/admin.py:128 core/admin.py:228 +#: core/admin.py:132 core/admin.py:243 msgid "No owner" msgstr "Kein Eigentümer" -#: core/admin.py:131 core/admin.py:231 +#: core/admin.py:135 core/admin.py:246 msgid "Multiple owners" msgstr "Mehrere Eigentümer" -#: core/admin.py:143 +#: core/admin.py:148 msgid "Resend notification to external service" msgstr "Benachrichtigung erneut an externen Dienst senden" -#: core/admin.py:166 +#: core/admin.py:171 #, python-format msgid "Failed to notify for recording %(id)s" msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen" -#: core/admin.py:174 +#: core/admin.py:179 #, python-format msgid "Failed to notify for recording %(id)s: %(error)s" msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s" -#: core/admin.py:182 +#: core/admin.py:187 #, python-format msgid "Successfully sent notifications for %(count)s recording(s)." msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet." -#: core/admin.py:190 +#: core/admin.py:195 #, python-format msgid "Skipped %(count)s expired recording(s)." msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen." -#: core/admin.py:294 +#: core/admin.py:309 msgid "No scopes" msgstr "Keine Scopes" -#: core/admin.py:296 +#: core/admin.py:311 msgid "Scopes" msgstr "Scopes" -#: core/api/serializers.py:68 +#: core/api/filters.py:24 +msgid "Creator is me" +msgstr "Ersteller bin ich" + +#: core/api/serializers.py:84 msgid "You must be administrator or owner of a room to add accesses to it." msgstr "" "Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe " "hinzuzufügen." -#: core/models.py:34 +#: core/api/serializers.py:443 +msgid "This file extension is not allowed." +msgstr "Diese Dateiendung ist nicht erlaubt." + +#: core/models.py:35 msgid "Member" msgstr "Mitglied" -#: core/models.py:35 +#: core/models.py:36 msgid "Administrator" msgstr "Administrator" -#: core/models.py:36 +#: core/models.py:37 msgid "Owner" msgstr "Eigentümer" -#: core/models.py:52 +#: core/models.py:53 msgid "Initiated" msgstr "Gestartet" -#: core/models.py:53 +#: core/models.py:54 msgid "Active" msgstr "Aktiv" -#: core/models.py:54 +#: core/models.py:55 msgid "Stopped" msgstr "Beendet" -#: core/models.py:55 +#: core/models.py:56 msgid "Saved" msgstr "Gespeichert" -#: core/models.py:56 +#: core/models.py:57 msgid "Aborted" msgstr "Abgebrochen" -#: core/models.py:57 +#: core/models.py:58 msgid "Failed to Start" msgstr "Start fehlgeschlagen" -#: core/models.py:58 +#: core/models.py:59 msgid "Failed to Stop" msgstr "Stopp fehlgeschlagen" -#: core/models.py:59 +#: core/models.py:60 msgid "Notification succeeded" msgstr "Benachrichtigung erfolgreich" -#: core/models.py:86 +#: core/models.py:87 msgid "SCREEN_RECORDING" msgstr "BILDSCHIRMAUFZEICHNUNG" -#: core/models.py:87 +#: core/models.py:88 msgid "TRANSCRIPT" msgstr "TRANSKRIPT" -#: core/models.py:93 +#: core/models.py:94 msgid "Public Access" msgstr "Öffentlicher Zugriff" -#: core/models.py:94 +#: core/models.py:95 msgid "Trusted Access" msgstr "Vertrauenswürdiger Zugriff" -#: core/models.py:95 +#: core/models.py:96 msgid "Restricted Access" msgstr "Eingeschränkter Zugriff" -#: core/models.py:107 +#: core/models.py:108 msgid "id" msgstr "ID" -#: core/models.py:108 +#: core/models.py:109 msgid "primary key for the record as UUID" msgstr "Primärschlüssel des Eintrags als UUID" -#: core/models.py:114 +#: core/models.py:115 msgid "created on" msgstr "erstellt am" -#: core/models.py:115 +#: core/models.py:116 msgid "date and time at which a record was created" msgstr "Datum und Uhrzeit der Erstellung eines Eintrags" -#: core/models.py:120 +#: core/models.py:121 msgid "updated on" msgstr "aktualisiert am" -#: core/models.py:121 +#: core/models.py:122 msgid "date and time at which a record was last updated" msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags" -#: core/models.py:141 +#: core/models.py:142 msgid "" "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/" "_ characters." @@ -171,11 +179,11 @@ msgstr "" "Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen " "und die Zeichen @/./+/-/_ enthalten." -#: core/models.py:147 +#: core/models.py:148 msgid "sub" msgstr "Sub" -#: core/models.py:149 +#: core/models.py:150 msgid "" "Optional for pending users; required upon account activation. 255 characters " "or fewer. Letters, numbers, and @/./+/-/_ characters only." @@ -183,55 +191,55 @@ msgstr "" "Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. " "Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt." -#: core/models.py:158 +#: core/models.py:159 msgid "identity email address" msgstr "Identitäts-E-Mail-Adresse" -#: core/models.py:163 +#: core/models.py:164 msgid "admin email address" msgstr "Administrator-E-Mail-Adresse" -#: core/models.py:165 +#: core/models.py:166 msgid "full name" msgstr "Vollständiger Name" -#: core/models.py:167 +#: core/models.py:168 msgid "short name" msgstr "Kurzname" -#: core/models.py:173 +#: core/models.py:174 msgid "language" msgstr "Sprache" -#: core/models.py:174 +#: core/models.py:175 msgid "The language in which the user wants to see the interface." msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte." -#: core/models.py:180 +#: core/models.py:181 msgid "The timezone in which the user wants to see times." msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte." -#: core/models.py:183 +#: core/models.py:184 msgid "device" msgstr "Gerät" -#: core/models.py:185 +#: core/models.py:186 msgid "Whether the user is a device or a real user." msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt." -#: core/models.py:188 +#: core/models.py:189 msgid "staff status" msgstr "Mitarbeiterstatus" -#: core/models.py:190 +#: core/models.py:191 msgid "Whether the user can log into this admin site." msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann." -#: core/models.py:193 +#: core/models.py:194 msgid "active" msgstr "aktiv" -#: core/models.py:196 +#: core/models.py:197 msgid "" "Whether this user should be treated as active. Unselect this instead of " "deleting accounts." @@ -239,66 +247,66 @@ msgstr "" "Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies " "anstelle des Löschens des Kontos." -#: core/models.py:209 +#: core/models.py:210 msgid "user" msgstr "Benutzer" -#: core/models.py:210 +#: core/models.py:211 msgid "users" msgstr "Benutzer" -#: core/models.py:269 +#: core/models.py:270 msgid "Resource" msgstr "Ressource" -#: core/models.py:270 +#: core/models.py:271 msgid "Resources" msgstr "Ressourcen" -#: core/models.py:324 +#: core/models.py:329 msgid "Resource access" msgstr "Ressourcenzugriff" -#: core/models.py:325 +#: core/models.py:330 msgid "Resource accesses" msgstr "Ressourcenzugriffe" -#: core/models.py:331 +#: core/models.py:336 msgid "Resource access with this User and Resource already exists." msgstr "" "Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert " "bereits." -#: core/models.py:387 +#: core/models.py:392 msgid "Visio room configuration" msgstr "Visio-Raumkonfiguration" -#: core/models.py:388 +#: core/models.py:393 msgid "Values for Visio parameters to configure the room." msgstr "Werte für Visio-Parameter zur Konfiguration des Raums." -#: core/models.py:395 +#: core/models.py:400 msgid "Room PIN code" msgstr "PIN-Code für den Raum" -#: core/models.py:396 +#: core/models.py:401 msgid "Unique n-digit code that identifies this room in telephony mode." msgstr "" "Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert." -#: core/models.py:402 core/models.py:556 +#: core/models.py:407 core/models.py:561 msgid "Room" msgstr "Raum" -#: core/models.py:403 +#: core/models.py:408 msgid "Rooms" msgstr "Räume" -#: core/models.py:567 +#: core/models.py:572 msgid "Worker ID" msgstr "Worker-ID" -#: core/models.py:569 +#: core/models.py:574 msgid "" "Enter an identifier for the worker recording.This ID is retained even when " "the worker stops, allowing for easy tracking." @@ -307,108 +315,149 @@ msgstr "" "erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen " "ermöglicht." -#: core/models.py:577 +#: core/models.py:582 msgid "Recording mode" msgstr "Aufzeichnungsmodus" -#: core/models.py:578 +#: core/models.py:583 msgid "Defines the mode of recording being called." msgstr "Definiert den aufgerufenen Aufzeichnungsmodus." -#: core/models.py:583 core/models.py:584 +#: core/models.py:588 core/models.py:589 msgid "Recording options" msgstr "Aufnahmeoptionen" -#: core/models.py:590 +#: core/models.py:595 msgid "Recording" msgstr "Aufzeichnung" -#: core/models.py:591 +#: core/models.py:596 msgid "Recordings" msgstr "Aufzeichnungen" -#: core/models.py:699 +#: core/models.py:704 msgid "Recording/user relation" msgstr "Beziehung Aufzeichnung/Benutzer" -#: core/models.py:700 +#: core/models.py:705 msgid "Recording/user relations" msgstr "Beziehungen Aufzeichnung/Benutzer" -#: core/models.py:706 +#: core/models.py:711 msgid "This user is already in this recording." msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung." -#: core/models.py:712 +#: core/models.py:717 msgid "This team is already in this recording." msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung." -#: core/models.py:718 +#: core/models.py:723 msgid "Either user or team must be set, not both." msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides." -#: core/models.py:735 +#: core/models.py:740 msgid "Create rooms" msgstr "Räume erstellen" -#: core/models.py:736 +#: core/models.py:741 msgid "List rooms" msgstr "Räume auflisten" -#: core/models.py:737 +#: core/models.py:742 msgid "Retrieve room details" msgstr "Raumdetails abrufen" -#: core/models.py:738 +#: core/models.py:743 msgid "Update rooms" msgstr "Räume aktualisieren" -#: core/models.py:739 +#: core/models.py:744 msgid "Delete rooms" msgstr "Räume löschen" -#: core/models.py:752 +#: core/models.py:757 msgid "Application name" msgstr "Anwendungsname" -#: core/models.py:753 +#: core/models.py:758 msgid "Descriptive name for this application." msgstr "Beschreibender Name für diese Anwendung." -#: core/models.py:763 +#: core/models.py:768 msgid "Hashed on Save. Copy it now if this is a new secret." msgstr "" "Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist." -#: core/models.py:774 +#: core/models.py:779 msgid "Application" msgstr "Anwendung" -#: core/models.py:775 +#: core/models.py:780 msgid "Applications" msgstr "Anwendungen" -#: core/models.py:798 +#: core/models.py:803 msgid "Enter a valid domain" msgstr "Geben Sie eine gültige Domain ein" -#: core/models.py:801 +#: core/models.py:806 msgid "Domain" msgstr "Domain" -#: core/models.py:802 +#: core/models.py:807 msgid "Email domain this application can act on behalf of." msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann." -#: core/models.py:814 +#: core/models.py:819 msgid "Application domain" msgstr "Anwendungsdomain" -#: core/models.py:815 +#: core/models.py:820 msgid "Application domains" msgstr "Anwendungsdomains" -#: core/recording/event/notification.py:94 +#: core/models.py:838 +msgid "Pending" +msgstr "Ausstehend" + +#: core/models.py:846 +msgid "Ready" +msgstr "Bereit" + +#: core/models.py:852 +msgid "Background image" +msgstr "Hintergrundbild" + +#: core/models.py:864 +msgid "title" +msgstr "Titel" + +#: core/models.py:890 +msgid "Malware detection info when the analysis status is unsafe." +msgstr "" +"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist." + +#: core/models.py:895 +msgid "File" +msgstr "Datei" + +#: core/models.py:896 +msgid "Files" +msgstr "Dateien" + +#: core/models.py:970 +msgid "This file is already hard deleted." +msgstr "Diese Datei wurde bereits endgültig gelöscht." + +#: core/models.py:980 +#, fuzzy +#| msgid "To hard delete a file, it must first be soft deleted." +msgid "To hard delete a file, it must first be soft deleted." +msgstr "" +"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden " +"sein." + +#: core/recording/event/notification.py:116 msgid "Your recording is ready" msgstr "Ihre Aufzeichnung ist bereit" @@ -536,18 +585,18 @@ msgstr "" " Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte " "an unser Support-Team unter %(support_email)s. " -#: meet/settings.py:169 +#: meet/settings.py:224 msgid "English" msgstr "Englisch" -#: meet/settings.py:170 +#: meet/settings.py:225 msgid "French" msgstr "Französisch" -#: meet/settings.py:171 +#: meet/settings.py:226 msgid "Dutch" msgstr "Niederländisch" -#: meet/settings.py:172 +#: meet/settings.py:227 msgid "German" msgstr "Deutsch" diff --git a/src/backend/locale/en_US/LC_MESSAGES/django.mo b/src/backend/locale/en_US/LC_MESSAGES/django.mo index cdd88d170a1420d061c8efb8a74cd34022d98b3c..bd025f69af908ac48969a8f3359d257a9a099cf4 100644 GIT binary patch delta 3163 zcmc)L>2Fk37{~F`m1QVPfffoa7rOw3ma>$k)q>EnxD*fy$Rb{tJMF+M(7C0pG?_#& zDriWo_`*N}Xo47nFGh*JfCOWRF<^)o1Bod{g@jmnK?oWG==V4GO7tJllX>pvoI7{U zIrll|w3nA3Yfkp%XCE?@9%4LkKg*aC_`?7$l&-lCeFfns0l@I6uP(pU&k)|1&1-d**w%3J|@bg9DA?^k7G7oMh$QUHDI6bb(~GR zAD_pG!_=WMd$ZbyktEoXgW&xLEA(d5B#$XsV)0c4)zKNR1r>KdY z#d`b(`Ix~hs5%~xYFFTBoQIldGipMsQ4_I|Iht0Sj9vNUUtb*KhB9yhHSj6a+I^22 zun#q{B6dzSmSY{R!I^jvOYsZT?{D}HW#Nly&%s(;gL-a1Difzhk^hBM&Tyj&AEGwf zG+sj;ug69_fXdJ%)C6wgG#taaXj3(zGI10acR6^ftOJO-9x=e27je0aRAQ3nWzbckkvCW+<<$r3;R%; zcO4r+Yu<|GxEpJ9{tr`8N-m-X_!c$b72j((oAwRVIUmQg)nNx}iC#gyz(Ld!9P#}C zr_)Yh9sZ2!w`79%`*Iw}_@DF2kgb%W5jxOnXpowh!y^ZBz%BP#yo|xBHO;U>>3-I%=Xf zp|PlmPC~7D73!Qf`1e~;8Hl3BbtjU4t=(a6Xu$VTGy4&B%zj0su81wJ z_{jG=WOdAati>W8(sL`hC=)I&#JyOBU!wNf?b4*zaY>nX&R3yQ^bTqQAEVBBA8Jzt z%Dqg4a53$j$O$x`pni7&^@i6`yT2dxLfXk1Fn~Jdb5YN=C8;R2am>VS)E?-;DcFlz znls4Dne(U)Z=z1g9n8WE(vgEXsP6-qjRpSwahO3{rGZEg>xiUg(@AV2R8|riFibp0 z>>+fF<`6~1-=&+%G9pSS6DkhzEHR&8UXMx(wY7vc?_6Rbv6P^Ye~MD8Bc{?$XcMZ; zBiabP=|+MLZB`HwLg`*kDE%rr`?GYM^Em90dH_x|bjzb?77 zw1Kpivj`oh-NY86iC{U?8w%1T#8j)r2o-O zPMsO_TgLE3_O>0Z?eUIS&vkxskav4W24Y;(i$m=%tN;#Oyy6SLel$FgH~q&vZ{6Rz!cB-BqwEMbS7>fYR8 ze`eXi?$m?gSe+U&@^n^?8+MuNxq>lS|HB(D?d>Vv_n*CG@414)zW|Dz BfC2yj delta 2618 zcmZY9e@xVM9LMp`-4P-`PBaAz<3N6#a3CBKg`l8-X$Vmy3H?Bdi@+0qTr6_VTgzFk z+FB%AIc7GqY0air&E{;WwQ%9qw9MvK%YWS3O4fA!c0FJB{kYNk?B4hIe7?Ky=g0f= z`P|;tbE~4?&q~;3C=p^F@qx#fF3FUqIWjS1lid;tB)#^hrZOK~6e<5|=zwJF9V zVk4$uGx9N=e9giT-i*VTPB-QyDycje!)!c=0X%J=U&BJ$ZhoYWi|{5a$6~C)Mc9Ly zz%HDPW7vg9a2V5=%?#X$UL3^&#y4+MDd)kLr~&@AdiYf(?PTo4I#dU*Bgrrake`|u zvUqdCI)P=hzeV*ojhFPEm#-wuM!oOH48}J>Dhap}pTic^3co=XZ!V$+x@^6M>cB(( z7{jFDOq_?AxDe;!3e?29(T@YD3A~DQVRmD*hRVBC)WKy`$A8$ihlg`$&p}NvfSO1- zYJy9VpPEKofNSjY?WmRSKn?sNYHMPs@s6P;_S;PIzmSTXld%|sSb<^Gi+ikJ;BB<8 zU>>G%g0%7=)?+=E;xnkT^FCJKMQlVb2SOQXM~xT865KtT{Ogd6^FS-lW4R4jib~~X z)Qc~mR`eFqr#XOH!Ew}p<5-SYQNJt5aQZ7jH|;9qW2*To!Zy@aY>iTxPvu!uhx?HO zYYrnf(R^Z`pG0lJ8C2@eVy4TO3mBq3$!v8!JD6=g9ztd09QLE@7H13Aqb3+ZZDn*T z6&&+kwx{@p&$zty>(K~%?UQ4{ROVhrITdt9$#JCOsGsQX_iz4eg(Q(S z4!p@kov9*ZSIja@*8T6IqSGEiP2gqJghsIj_oF(vgzEUJZC^*un@RUM6D&kcB!HS= zHEIj%P}j4~K7SgO!6-&G@J=e)n?tApKSHJKSJZX+2Nz>Wj&rU0Q188AJ&7#V{Dium zNx9C-OR%1H4VL0I)Y&(ux|e4|P9Bkwaomp;n$v?+v&BmC7*c zy&b3(jiC5#m z@1X}jw$G1a0_|~B>d&AHmCY4I6A>ZqARZ=EG@hUF&3dAm&|cm{v=TaKD%$r#B8AWd z)cIY+)S`@SAT~~UsH_bT{R9^*zCB>>C+;L}SA!B=##aN;qZWnR5ns9L4-(4>9iAKI z30u(}zT4K7${a#xL|IS<7ErqwZy$n(`>TpcC`>*-G3bj{>;o{#8N^Sltg9xr`- zue5cYblsA*L^|On0=)l2_sHS-;aGd# l5l^gXZl>pdKRo^4A7u<}>g{\n" "Language-Team: LANGUAGE \n" @@ -29,139 +29,147 @@ msgstr "Permissions" msgid "Important dates" msgstr "Important dates" -#: core/admin.py:128 core/admin.py:228 +#: core/admin.py:132 core/admin.py:243 msgid "No owner" msgstr "No owner" -#: core/admin.py:131 core/admin.py:231 +#: core/admin.py:135 core/admin.py:246 msgid "Multiple owners" msgstr "Multiple owners" -#: core/admin.py:143 +#: core/admin.py:148 msgid "Resend notification to external service" msgstr "Resend notification to external service" -#: core/admin.py:166 +#: core/admin.py:171 #, python-format msgid "Failed to notify for recording %(id)s" msgstr "Failed to notify for recording %(id)s" -#: core/admin.py:174 +#: core/admin.py:179 #, python-format msgid "Failed to notify for recording %(id)s: %(error)s" msgstr "Failed to notify for recording %(id)s: %(error)s" -#: core/admin.py:182 +#: core/admin.py:187 #, python-format msgid "Successfully sent notifications for %(count)s recording(s)." msgstr "Successfully sent notifications for %(count)s recording(s)." -#: core/admin.py:190 +#: core/admin.py:195 #, python-format msgid "Skipped %(count)s expired recording(s)." msgstr "Skipped %(count)s expired recording(s)." -#: core/admin.py:294 +#: core/admin.py:309 msgid "No scopes" msgstr "No scopes" -#: core/admin.py:296 +#: core/admin.py:311 msgid "Scopes" msgstr "Scopes" -#: core/api/serializers.py:68 +#: core/api/filters.py:24 +msgid "Creator is me" +msgstr "Creator is me" + +#: core/api/serializers.py:84 msgid "You must be administrator or owner of a room to add accesses to it." msgstr "You must be administrator or owner of a room to add accesses to it." -#: core/models.py:34 +#: core/api/serializers.py:443 +msgid "This file extension is not allowed." +msgstr "This file extension is not allowed." + +#: core/models.py:35 msgid "Member" msgstr "Member" -#: core/models.py:35 +#: core/models.py:36 msgid "Administrator" msgstr "Administrator" -#: core/models.py:36 +#: core/models.py:37 msgid "Owner" msgstr "Owner" -#: core/models.py:52 +#: core/models.py:53 msgid "Initiated" msgstr "Initiated" -#: core/models.py:53 +#: core/models.py:54 msgid "Active" msgstr "Active" -#: core/models.py:54 +#: core/models.py:55 msgid "Stopped" msgstr "Stopped" -#: core/models.py:55 +#: core/models.py:56 msgid "Saved" msgstr "Saved" -#: core/models.py:56 +#: core/models.py:57 msgid "Aborted" msgstr "Aborted" -#: core/models.py:57 +#: core/models.py:58 msgid "Failed to Start" msgstr "Failed to Start" -#: core/models.py:58 +#: core/models.py:59 msgid "Failed to Stop" msgstr "Failed to Stop" -#: core/models.py:59 +#: core/models.py:60 msgid "Notification succeeded" msgstr "Notification succeeded" -#: core/models.py:86 +#: core/models.py:87 msgid "SCREEN_RECORDING" msgstr "SCREEN_RECORDING" -#: core/models.py:87 +#: core/models.py:88 msgid "TRANSCRIPT" msgstr "TRANSCRIPT" -#: core/models.py:93 +#: core/models.py:94 msgid "Public Access" msgstr "Public Access" -#: core/models.py:94 +#: core/models.py:95 msgid "Trusted Access" msgstr "Trusted Access" -#: core/models.py:95 +#: core/models.py:96 msgid "Restricted Access" msgstr "Restricted Access" -#: core/models.py:107 +#: core/models.py:108 msgid "id" msgstr "id" -#: core/models.py:108 +#: core/models.py:109 msgid "primary key for the record as UUID" msgstr "primary key for the record as UUID" -#: core/models.py:114 +#: core/models.py:115 msgid "created on" msgstr "created on" -#: core/models.py:115 +#: core/models.py:116 msgid "date and time at which a record was created" msgstr "date and time at which a record was created" -#: core/models.py:120 +#: core/models.py:121 msgid "updated on" msgstr "updated on" -#: core/models.py:121 +#: core/models.py:122 msgid "date and time at which a record was last updated" msgstr "date and time at which a record was last updated" -#: core/models.py:141 +#: core/models.py:142 msgid "" "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/" "_ characters." @@ -169,11 +177,11 @@ msgstr "" "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/" "_ characters." -#: core/models.py:147 +#: core/models.py:148 msgid "sub" msgstr "sub" -#: core/models.py:149 +#: core/models.py:150 msgid "" "Optional for pending users; required upon account activation. 255 characters " "or fewer. Letters, numbers, and @/./+/-/_ characters only." @@ -181,55 +189,55 @@ msgstr "" "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ " "characters only." -#: core/models.py:158 +#: core/models.py:159 msgid "identity email address" msgstr "identity email address" -#: core/models.py:163 +#: core/models.py:164 msgid "admin email address" msgstr "admin email address" -#: core/models.py:165 +#: core/models.py:166 msgid "full name" msgstr "full name" -#: core/models.py:167 +#: core/models.py:168 msgid "short name" msgstr "short name" -#: core/models.py:173 +#: core/models.py:174 msgid "language" msgstr "language" -#: core/models.py:174 +#: core/models.py:175 msgid "The language in which the user wants to see the interface." msgstr "The language in which the user wants to see the interface." -#: core/models.py:180 +#: core/models.py:181 msgid "The timezone in which the user wants to see times." msgstr "The timezone in which the user wants to see times." -#: core/models.py:183 +#: core/models.py:184 msgid "device" msgstr "device" -#: core/models.py:185 +#: core/models.py:186 msgid "Whether the user is a device or a real user." msgstr "Whether the user is a device or a real user." -#: core/models.py:188 +#: core/models.py:189 msgid "staff status" msgstr "staff status" -#: core/models.py:190 +#: core/models.py:191 msgid "Whether the user can log into this admin site." msgstr "Whether the user can log into this admin site." -#: core/models.py:193 +#: core/models.py:194 msgid "active" msgstr "active" -#: core/models.py:196 +#: core/models.py:197 msgid "" "Whether this user should be treated as active. Unselect this instead of " "deleting accounts." @@ -237,63 +245,63 @@ msgstr "" "Whether this user should be treated as active. Unselect this instead of " "deleting accounts." -#: core/models.py:209 +#: core/models.py:210 msgid "user" msgstr "user" -#: core/models.py:210 +#: core/models.py:211 msgid "users" msgstr "users" -#: core/models.py:269 +#: core/models.py:270 msgid "Resource" msgstr "Resource" -#: core/models.py:270 +#: core/models.py:271 msgid "Resources" msgstr "Resources" -#: core/models.py:324 +#: core/models.py:329 msgid "Resource access" msgstr "Resource access" -#: core/models.py:325 +#: core/models.py:330 msgid "Resource accesses" msgstr "Resource accesses" -#: core/models.py:331 +#: core/models.py:336 msgid "Resource access with this User and Resource already exists." msgstr "Resource access with this User and Resource already exists." -#: core/models.py:387 +#: core/models.py:392 msgid "Visio room configuration" msgstr "Visio room configuration" -#: core/models.py:388 +#: core/models.py:393 msgid "Values for Visio parameters to configure the room." msgstr "Values for Visio parameters to configure the room." -#: core/models.py:395 +#: core/models.py:400 msgid "Room PIN code" msgstr "Room PIN code" -#: core/models.py:396 +#: core/models.py:401 msgid "Unique n-digit code that identifies this room in telephony mode." msgstr "Unique n-digit code that identifies this room in telephony mode." -#: core/models.py:402 core/models.py:556 +#: core/models.py:407 core/models.py:561 msgid "Room" msgstr "Room" -#: core/models.py:403 +#: core/models.py:408 msgid "Rooms" msgstr "Rooms" -#: core/models.py:567 +#: core/models.py:572 msgid "Worker ID" msgstr "Worker ID" -#: core/models.py:569 +#: core/models.py:574 msgid "" "Enter an identifier for the worker recording.This ID is retained even when " "the worker stops, allowing for easy tracking." @@ -301,111 +309,151 @@ msgstr "" "Enter an identifier for the worker recording.This ID is retained even when " "the worker stops, allowing for easy tracking." -#: core/models.py:577 +#: core/models.py:582 msgid "Recording mode" msgstr "Recording mode" -#: core/models.py:578 +#: core/models.py:583 msgid "Defines the mode of recording being called." msgstr "Defines the mode of recording being called." -#: core/models.py:583 core/models.py:584 +#: core/models.py:588 core/models.py:589 msgid "Recording options" msgstr "Recording options" -#: core/models.py:590 +#: core/models.py:595 msgid "Recording" msgstr "Recording" -#: core/models.py:591 +#: core/models.py:596 msgid "Recordings" msgstr "Recordings" -#: core/models.py:699 +#: core/models.py:704 msgid "Recording/user relation" msgstr "Recording/user relation" -#: core/models.py:700 +#: core/models.py:705 msgid "Recording/user relations" msgstr "Recording/user relations" -#: core/models.py:706 +#: core/models.py:711 msgid "This user is already in this recording." msgstr "This user is already in this recording." -#: core/models.py:712 +#: core/models.py:717 msgid "This team is already in this recording." msgstr "This team is already in this recording." -#: core/models.py:718 +#: core/models.py:723 msgid "Either user or team must be set, not both." msgstr "Either user or team must be set, not both." -#: core/models.py:735 +#: core/models.py:740 #, fuzzy #| msgid "created on" msgid "Create rooms" msgstr "Create rooms" -#: core/models.py:736 +#: core/models.py:741 msgid "List rooms" msgstr "List rooms" -#: core/models.py:737 +#: core/models.py:742 msgid "Retrieve room details" msgstr "Retrieve room details" -#: core/models.py:738 +#: core/models.py:743 #, fuzzy #| msgid "updated on" msgid "Update rooms" msgstr "Update rooms" -#: core/models.py:739 +#: core/models.py:744 msgid "Delete rooms" msgstr "Delete rooms" -#: core/models.py:752 +#: core/models.py:757 msgid "Application name" msgstr "Application name" -#: core/models.py:753 +#: core/models.py:758 msgid "Descriptive name for this application." msgstr "Descriptive name for this application." -#: core/models.py:763 +#: core/models.py:768 msgid "Hashed on Save. Copy it now if this is a new secret." msgstr "Hashed on Save. Copy it now if this is a new secret." -#: core/models.py:774 +#: core/models.py:779 msgid "Application" msgstr "Application" -#: core/models.py:775 +#: core/models.py:780 msgid "Applications" msgstr "Applications" -#: core/models.py:798 +#: core/models.py:803 msgid "Enter a valid domain" msgstr "Enter a valid domain" -#: core/models.py:801 +#: core/models.py:806 msgid "Domain" msgstr "Domain" -#: core/models.py:802 +#: core/models.py:807 msgid "Email domain this application can act on behalf of." msgstr "Email domain this application can act on behalf of." -#: core/models.py:814 +#: core/models.py:819 msgid "Application domain" msgstr "Application domain" -#: core/models.py:815 +#: core/models.py:820 msgid "Application domains" msgstr "Application domains" -#: core/recording/event/notification.py:94 +#: core/models.py:838 +#, fuzzy +#| msgid "Recording" +msgid "Pending" +msgstr "Pending" + +#: core/models.py:846 +msgid "Ready" +msgstr "Ready" + +#: core/models.py:852 +msgid "Background image" +msgstr "Background image" + +#: core/models.py:864 +msgid "title" +msgstr "title" + +#: core/models.py:890 +msgid "Malware detection info when the analysis status is unsafe." +msgstr "Malware detection info when the analysis status is unsafe." + +#: core/models.py:895 +msgid "File" +msgstr "File" + +#: core/models.py:896 +msgid "Files" +msgstr "Files" + +#: core/models.py:970 +#, fuzzy +#| msgid "This user is already in this recording." +msgid "This file is already hard deleted." +msgstr "This file is already hard deleted." + +#: core/models.py:980 +msgid "To hard delete a file, it must first be soft deleted." +msgstr "To hard delete a file, it must first be soft deleted." + +#: core/recording/event/notification.py:116 msgid "Your recording is ready" msgstr "Your recording is ready" @@ -533,18 +581,18 @@ msgstr "" " If you have any questions or need assistance, please contact our support " "team at %(support_email)s. " -#: meet/settings.py:169 +#: meet/settings.py:224 msgid "English" msgstr "English" -#: meet/settings.py:170 +#: meet/settings.py:225 msgid "French" msgstr "French" -#: meet/settings.py:171 +#: meet/settings.py:226 msgid "Dutch" msgstr "Dutch" -#: meet/settings.py:172 +#: meet/settings.py:227 msgid "German" msgstr "German" diff --git a/src/backend/locale/fr_FR/LC_MESSAGES/django.mo b/src/backend/locale/fr_FR/LC_MESSAGES/django.mo index c76470df2be646bf8d833467a95ee452481c1c01..4a56b499805f163dcd7dcee358c484f90971343f 100644 GIT binary patch delta 3341 zcmZYAX>1f_9LMpe-4-Y<1xq~u3nNs@)q)h%76r7R0@i~=K~$XX&bDKBXUpttO9fr5 ziN+`iW`nnHR9;l0plgguJnIpRMl>--iPsB5JY%Buh|&1{&5l=`^go|xj(v{*^E}go z>-TKVe^OVm&v4|4lZpBYV;;krahy0_8gGn)FXQFdRBcQn=CK(cMi<}1v3OREF=KEQ zPQZ1@BeMnT(80;L11G83+bQs89w^-~kKlB!4@LJsLREAO=i_K}{}gV{=DG#d;3llZ zD{&rnVJq%Ib?jxV$HRC9eu6tR?6z8CCUWCR%|hRrndwmiY=vqWL8<#_$&^?X?uzaXG5uLF65?8#N1iQ5D^TC*VV< z4n2pr;ESk^oqt?uq@AdgZ$dR}M`lp(U5~2oCd_NZdnoW{?&owOK8`2h>!==oicRc|UqqI16Vm;re|?WG^;wUBIRIL_PV}i^sU3OjOg| zl{ghyXJ#ulV-ITa-HB@86&sKn zNN+_tZT6x@_Asi!1Gor}qTZ|FSE_I_mSY3bhOuxio`ISo8)xFRsQUJx26Q*Za6f7a z^25;!2QXYz(SsLpR+%xc;SOH>atbROyQUg*3Vw*{*l#$53#OGCI)JM9IaEepLM_I3 zkTIB}NR{RXq~m#0!&gT=n}u2&i*Oz;M=dHBnH{q;y1zHNK7@_jKZ05tqo{g*M@`Lm zCVC}ajC#HoY17<|8GH&0+W(DwZPdUs$htFc_?^cFlwKFhgyt(phiBA zm-M?OcrmVxuJ1*4;2>&SA42}jTb$JJ_oxB=gc?9~Bl%ZFb+mRm&O!Bj8|sB#)OPbx zso#(1;p3u?Dwb-Qsn-iu4{LsSE`e67@xjo6I$;R-yAZCJ%#^sxgqbx-5vID#xIGmRB)VSAoJ zC55fXx-m9Z;f<(@Zo^IZ8jizh%!4x2h~>Bl*@31NHPVZ44DQCIcnhk&=TQTE17mm; z)o%V%3R(ajj!Ww4DkvL?_WfWC=CEZqp_TzY*}iL({6&Pqkr2l!aYH zJ3&VE)BhgYn=6TaVgsSh>CkA&zBz-?6et_|0O{CCXmrYe_V`9Zht>vbEgaZ(Tt$o} zw5Ts2!uBh|Lg|0LR`ZDjgAdcLM;8)T5Gh@7Tv{Sr7gthNx^?K=@z>EuLDRV=x-lNp z#ER&e9UK0`3&+pY{(baDB`qq}O1Lctu`9aoMy`((Yoqe{n25^yAZ?Dy;w<8NVjZCc zxP{P>pTa31dWfwAU&Zjafpa}kQ@RZQ$vBiP;v#|#6CPSz+UpYtEvR#dvk9Bf!fYY5 zQ*>xc>bQi^u3N}A&0IqvNnA&C6Eg|z`D=-*2#?TlZV59G8OK;uZpGuHvMGLC^=;Xt zbM1I_fb@PM{-?4*?Z@F13?G#6=mzIxnQ;s>0Q?aSGwye!g4cNY8 zC7i&C2X4l*TrZii26`ON3VIyN_UzPPmS1NBJIG~KPtMEQNvEaII`M-_FX4LKg|U<7 z$Lz#lap9x~%NvqZZ8Mgzm}im=pBv6 z{gGiiaB_a}*{Mg$Yu$K{>-Y>gw0LtvS6Sy82I;5maKQ157k7Nm_5v$qTZxh3zcNAf zol$z?Hd&?WFbjd@H97RM&n7Ll>lKepA5&Jf+OzC{Q3r*uXPjPni9hm4P_(RMZ2CXB z_~&PKj!AK|Bg0N1KkLq9HrLnZyJ^Q)^GVlp1GnEvQy0l5C1J-cH)SO<bRcsuQbjrPHgE21AP%msqz!YqRx71G02lMr9Yp`A6^1Ilmgu`2Jqc^V|77zvuFO zetNiJWOHCL#qpk@3=nC=c$6_8qCbWUWjfB7cKiYBu{z$E96W}_IF8Ta4b&q|^NpE@ zJ8%JZBY$R)O9~#qMR*jGj0u>JsU&b?6z|0gR{I(j(*DP4XYnI9?Gn@rwqP_iV*$3| zN_-17kn`xo3%C`h@FnyyszeOoLf&u2sN{0v8}#5U)C-m^jOalx?NV$+KdOT-kwr1z zB9EA9WD(4axr^byV)c7IfeMzK(lPGqkf#EP{zey~t^q zg&Kedd6)5FGS=b}+=i*xj~dvUn1@GD12~5P-eJC?Qik85I&gAP$8J>H!(}n9Lk+M2 zHIQc10K1V#O&{jte(U~8)XYCcz4$b0Yo<}}yPC}UYh*cO#&Rq{AGY8Md>{4TgymnD zPCJbZ%0?e*<}Fx`yO3=*pI|YbMdi>nRDX*}h)S$Mt;}I3>#rAo#El}nh)Nba)71(z zV+D4imh>d*!7lFw0lb`>@A-%taH&4|n(9oc46fcjlI9>HqVshC2o;IGKy znpx}qB3EREGf~fbFf~A>ii)zi6}4oqqK@TpR5G1L&G0AGgEROTI@nmX>rfNei8_V@ zsQwP3wsr_Lff3Yzzd$`Vfq_+2E>Y2lmy&t9o}125in@|FuLXxUmMu(2MrmNQb4U7w<)#=W$$%H}FAR z;Wp-Z^rMn;3hVJYvTLR?&lnfBVKlym?1njnTG63A)?Xc+;>J@rgPL(=eq=>9qMddl zlE|hBHNzKi9-hGaaRk-j71RuW$0&3#eSM%}Q7gOzHPCFd;X~U0YNCo5Ahb-agi84M z;V!~Q==eQOXz%L?l@x+8hdWmeXqGj^+)_fLhv=Jgvx&N&2y}DNvD0#?Xce>~$_tfs zL?zLt28GiUULn<+i3)v2e~fuz*nE3mFKX$#NPE#507-lM%vK zawoi$%9HBPh5-QsW<$;oRJF%W{5nG5ZLjR_a%chf=|%%ZQmW5 zh@Z3t&n6Uw{!Z}PL*B&sw&0$mi^2Nj{Lq2qWA\n" @@ -29,143 +29,151 @@ msgstr "Permissions" msgid "Important dates" msgstr "Dates importantes" -#: core/admin.py:128 core/admin.py:228 +#: core/admin.py:132 core/admin.py:243 msgid "No owner" msgstr "Pas de propriétaire" -#: core/admin.py:131 core/admin.py:231 +#: core/admin.py:135 core/admin.py:246 msgid "Multiple owners" msgstr "Plusieurs propriétaires" -#: core/admin.py:143 +#: core/admin.py:148 msgid "Resend notification to external service" msgstr "Renvoyer la notification au service externe" -#: core/admin.py:166 +#: core/admin.py:171 #, python-format msgid "Failed to notify for recording %(id)s" msgstr "Échec de la notification pour l’enregistrement %(id)s" -#: core/admin.py:174 +#: core/admin.py:179 #, python-format msgid "Failed to notify for recording %(id)s: %(error)s" msgstr "Échec de la notification pour l’enregistrement %(id)s : %(error)s" -#: core/admin.py:182 +#: core/admin.py:187 #, python-format msgid "Successfully sent notifications for %(count)s recording(s)." msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)." -#: core/admin.py:190 +#: core/admin.py:195 #, python-format msgid "Skipped %(count)s expired recording(s)." msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)." -#: core/admin.py:294 +#: core/admin.py:309 msgid "No scopes" msgstr "Aucun scopes" -#: core/admin.py:296 +#: core/admin.py:311 msgid "Scopes" msgstr "Scopes" -#: core/api/serializers.py:68 +#: core/api/filters.py:24 +msgid "Creator is me" +msgstr "Je suis le créateur" + +#: core/api/serializers.py:84 msgid "You must be administrator or owner of a room to add accesses to it." msgstr "" "Vous devez être administrateur ou propriétaire d'une salle pour y ajouter " "des accès." -#: core/models.py:34 +#: core/api/serializers.py:443 +msgid "This file extension is not allowed." +msgstr "Cette extension n'est pas autorisée" + +#: core/models.py:35 msgid "Member" msgstr "Membre" -#: core/models.py:35 +#: core/models.py:36 msgid "Administrator" msgstr "Administrateur" -#: core/models.py:36 +#: core/models.py:37 msgid "Owner" msgstr "Propriétaire" -#: core/models.py:52 +#: core/models.py:53 msgid "Initiated" msgstr "Initié" -#: core/models.py:53 +#: core/models.py:54 msgid "Active" msgstr "Actif" -#: core/models.py:54 +#: core/models.py:55 msgid "Stopped" msgstr "Arrêté" -#: core/models.py:55 +#: core/models.py:56 msgid "Saved" msgstr "Enregistré" -#: core/models.py:56 +#: core/models.py:57 msgid "Aborted" msgstr "Abandonné" -#: core/models.py:57 +#: core/models.py:58 msgid "Failed to Start" msgstr "Échec au démarrage" -#: core/models.py:58 +#: core/models.py:59 msgid "Failed to Stop" msgstr "Échec à l'arrêt" -#: core/models.py:59 +#: core/models.py:60 msgid "Notification succeeded" msgstr "Notification réussie" -#: core/models.py:86 +#: core/models.py:87 msgid "SCREEN_RECORDING" msgstr "ENREGISTREMENT_ÉCRAN" -#: core/models.py:87 +#: core/models.py:88 msgid "TRANSCRIPT" msgstr "TRANSCRIPTION" -#: core/models.py:93 +#: core/models.py:94 msgid "Public Access" msgstr "Accès public" -#: core/models.py:94 +#: core/models.py:95 msgid "Trusted Access" msgstr "Accès de confiance" -#: core/models.py:95 +#: core/models.py:96 msgid "Restricted Access" msgstr "Accès restreint" -#: core/models.py:107 +#: core/models.py:108 msgid "id" msgstr "id" -#: core/models.py:108 +#: core/models.py:109 msgid "primary key for the record as UUID" msgstr "clé primaire pour l'enregistrement sous forme d'UUID" -#: core/models.py:114 +#: core/models.py:115 msgid "created on" msgstr "créé le" -#: core/models.py:115 +#: core/models.py:116 msgid "date and time at which a record was created" msgstr "date et heure auxquelles un enregistrement a été créé" -#: core/models.py:120 +#: core/models.py:121 msgid "updated on" msgstr "mis à jour le" -#: core/models.py:121 +#: core/models.py:122 msgid "date and time at which a record was last updated" msgstr "" "date et heure auxquelles un enregistrement a été mis à jour pour la dernière " "fois" -#: core/models.py:141 +#: core/models.py:142 msgid "" "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/" "_ characters." @@ -173,11 +181,11 @@ msgstr "" "Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des " "chiffres et les caractères @/./+/-/_." -#: core/models.py:147 +#: core/models.py:148 msgid "sub" msgstr "sub" -#: core/models.py:149 +#: core/models.py:150 msgid "" "Optional for pending users; required upon account activation. 255 characters " "or fewer. Letters, numbers, and @/./+/-/_ characters only." @@ -185,55 +193,55 @@ msgstr "" "Optionnel pour les utilisateurs en attente ; requis lors de l'activation du " "compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement." -#: core/models.py:158 +#: core/models.py:159 msgid "identity email address" msgstr "adresse e-mail d'identité" -#: core/models.py:163 +#: core/models.py:164 msgid "admin email address" msgstr "adresse e-mail d'administrateur" -#: core/models.py:165 +#: core/models.py:166 msgid "full name" msgstr "nom complet" -#: core/models.py:167 +#: core/models.py:168 msgid "short name" msgstr "nom court" -#: core/models.py:173 +#: core/models.py:174 msgid "language" msgstr "langue" -#: core/models.py:174 +#: core/models.py:175 msgid "The language in which the user wants to see the interface." msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface." -#: core/models.py:180 +#: core/models.py:181 msgid "The timezone in which the user wants to see times." msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures." -#: core/models.py:183 +#: core/models.py:184 msgid "device" msgstr "appareil" -#: core/models.py:185 +#: core/models.py:186 msgid "Whether the user is a device or a real user." msgstr "Si l'utilisateur est un appareil ou un utilisateur réel." -#: core/models.py:188 +#: core/models.py:189 msgid "staff status" msgstr "statut du personnel" -#: core/models.py:190 +#: core/models.py:191 msgid "Whether the user can log into this admin site." msgstr "Si l'utilisateur peut se connecter à ce site d'administration." -#: core/models.py:193 +#: core/models.py:194 msgid "active" msgstr "actif" -#: core/models.py:196 +#: core/models.py:197 msgid "" "Whether this user should be treated as active. Unselect this instead of " "deleting accounts." @@ -241,65 +249,65 @@ msgstr "" "Si cet utilisateur doit être traité comme actif. Désélectionnez cette option " "au lieu de supprimer des comptes." -#: core/models.py:209 +#: core/models.py:210 msgid "user" msgstr "utilisateur" -#: core/models.py:210 +#: core/models.py:211 msgid "users" msgstr "utilisateurs" -#: core/models.py:269 +#: core/models.py:270 msgid "Resource" msgstr "Ressource" -#: core/models.py:270 +#: core/models.py:271 msgid "Resources" msgstr "Ressources" -#: core/models.py:324 +#: core/models.py:329 msgid "Resource access" msgstr "Accès aux ressources" -#: core/models.py:325 +#: core/models.py:330 msgid "Resource accesses" msgstr "Accès aux ressources" -#: core/models.py:331 +#: core/models.py:336 msgid "Resource access with this User and Resource already exists." msgstr "" "L'accès à la ressource avec cet utilisateur et cette ressource existe déjà." -#: core/models.py:387 +#: core/models.py:392 msgid "Visio room configuration" msgstr "Configuration de la salle de visioconférence" -#: core/models.py:388 +#: core/models.py:393 msgid "Values for Visio parameters to configure the room." msgstr "Valeurs des paramètres de visioconférence pour configurer la salle." -#: core/models.py:395 +#: core/models.py:400 msgid "Room PIN code" msgstr "Code PIN de la salle" -#: core/models.py:396 +#: core/models.py:401 msgid "Unique n-digit code that identifies this room in telephony mode." msgstr "" "Code unique à n chiffres qui identifie cette salle en mode téléphonique." -#: core/models.py:402 core/models.py:556 +#: core/models.py:407 core/models.py:561 msgid "Room" msgstr "Salle" -#: core/models.py:403 +#: core/models.py:408 msgid "Rooms" msgstr "Salles" -#: core/models.py:567 +#: core/models.py:572 msgid "Worker ID" msgstr "ID du Worker" -#: core/models.py:569 +#: core/models.py:574 msgid "" "Enter an identifier for the worker recording.This ID is retained even when " "the worker stops, allowing for easy tracking." @@ -307,109 +315,147 @@ msgstr "" "Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est " "conservé même lorsque le Worker s'arrête, permettant un suivi facile." -#: core/models.py:577 +#: core/models.py:582 msgid "Recording mode" msgstr "Mode d'enregistrement" -#: core/models.py:578 +#: core/models.py:583 msgid "Defines the mode of recording being called." msgstr "Définit le mode d'enregistrement appelé." -#: core/models.py:583 core/models.py:584 +#: core/models.py:588 core/models.py:589 msgid "Recording options" msgstr "Options d'enregistrement" -#: core/models.py:590 +#: core/models.py:595 msgid "Recording" msgstr "Enregistrement" -#: core/models.py:591 +#: core/models.py:596 msgid "Recordings" msgstr "Enregistrements" -#: core/models.py:699 +#: core/models.py:704 msgid "Recording/user relation" msgstr "Relation enregistrement/utilisateur" -#: core/models.py:700 +#: core/models.py:705 msgid "Recording/user relations" msgstr "Relations enregistrement/utilisateur" -#: core/models.py:706 +#: core/models.py:711 msgid "This user is already in this recording." msgstr "Cet utilisateur est déjà dans cet enregistrement." -#: core/models.py:712 +#: core/models.py:717 msgid "This team is already in this recording." msgstr "Cette équipe est déjà dans cet enregistrement." -#: core/models.py:718 +#: core/models.py:723 msgid "Either user or team must be set, not both." msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux." -#: core/models.py:735 +#: core/models.py:740 msgid "Create rooms" msgstr "Créer des salles" -#: core/models.py:736 +#: core/models.py:741 msgid "List rooms" msgstr "Lister les salles" -#: core/models.py:737 +#: core/models.py:742 msgid "Retrieve room details" msgstr "Afficher les détails d’une salle" -#: core/models.py:738 +#: core/models.py:743 msgid "Update rooms" msgstr "Mettre à jour les salles" -#: core/models.py:739 +#: core/models.py:744 msgid "Delete rooms" msgstr "Supprimer les salles" -#: core/models.py:752 +#: core/models.py:757 msgid "Application name" msgstr "Nom de l’application" -#: core/models.py:753 +#: core/models.py:758 msgid "Descriptive name for this application." msgstr "Nom descriptif de cette application." -#: core/models.py:763 +#: core/models.py:768 msgid "Hashed on Save. Copy it now if this is a new secret." msgstr "" "Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un " "nouveau secret." -#: core/models.py:774 +#: core/models.py:779 msgid "Application" msgstr "Application" -#: core/models.py:775 +#: core/models.py:780 msgid "Applications" msgstr "Applications" -#: core/models.py:798 +#: core/models.py:803 msgid "Enter a valid domain" msgstr "Saisissez un domaine valide" -#: core/models.py:801 +#: core/models.py:806 msgid "Domain" msgstr "Domaine" -#: core/models.py:802 +#: core/models.py:807 msgid "Email domain this application can act on behalf of." msgstr "Domaine de messagerie au nom duquel cette application peut agir." -#: core/models.py:814 +#: core/models.py:819 msgid "Application domain" msgstr "Domaine d’application" -#: core/models.py:815 +#: core/models.py:820 msgid "Application domains" msgstr "Domaines d’application" -#: core/recording/event/notification.py:94 +#: core/models.py:838 +msgid "Pending" +msgstr "En attente" + +#: core/models.py:846 +msgid "Ready" +msgstr "Prêt" + +#: core/models.py:852 +msgid "Background image" +msgstr "Image de fond" + +#: core/models.py:864 +msgid "title" +msgstr "Titre" + +#: core/models.py:890 +msgid "Malware detection info when the analysis status is unsafe." +msgstr "Information concernant la détection de Malware cand le statut n'est pas sain" + +#: core/models.py:895 +msgid "File" +msgstr "Fichier" + +#: core/models.py:896 +msgid "Files" +msgstr "Fichiers" + +#: core/models.py:970 +#, fuzzy +#| msgid "This user is already in this recording." +msgid "This file is already hard deleted." +msgstr "Ce fichier a été supprimé." + +#: core/models.py:980 +msgid "To hard delete a file, it must first be soft deleted." +msgstr "Pour supprimer définitivement un fichier il doit d'abord avoir été marqué comme supprimé (soft delete)" + +#: core/recording/event/notification.py:116 msgid "Your recording is ready" msgstr "Votre enregistrement est prêt" @@ -537,18 +583,18 @@ msgstr "" " Si vous avez des questions ou besoin d'assistance, veuillez contacter notre " "équipe d'assistance à %(support_email)s. " -#: meet/settings.py:169 +#: meet/settings.py:224 msgid "English" msgstr "Anglais" -#: meet/settings.py:170 +#: meet/settings.py:225 msgid "French" msgstr "Français" -#: meet/settings.py:171 +#: meet/settings.py:226 msgid "Dutch" msgstr "Néerlandais" -#: meet/settings.py:172 +#: meet/settings.py:227 msgid "German" msgstr "Allemand" diff --git a/src/backend/locale/nl_NL/LC_MESSAGES/django.mo b/src/backend/locale/nl_NL/LC_MESSAGES/django.mo index afea428accc4b28a94bbcaa18f07b7546d507a7a..3ca0fa8e5e430223e5ba8e888a0134c120aee78d 100644 GIT binary patch delta 3262 zcmY+_d2AGA7{~Fqmjy~WDyX2+0Si@nfRsZ(rG+Ag6ahg*0iCk%w$pBBad+ra6xT?M z(V#&p#siOd#2|;q8vj7!9V2LrXG}~)V>}{(n3$*#Bk}v2nI<~fXFl)D&dmF~&->2q zy=}W!<&RaB?lEk6;sRn+sWAs}=pc4%Zx1oX;ybt&Yb%VY!8|tLv*_XHSdNzuHRddA z#Y(&y`O2)rDztbuZp9Jmb|(!!=JDu+c>%|9{Ca%;8`Oh-!8-gSK0lt5lQ?cfb+`u0 za6L}Oc5K4?P>H>RqwomcfX8sFhCO?jF~d3W3i2`UvYUvX;Z*z!DT=w6i_}2_s>7z( zIXIo;7F>tD$j5xjZZLk2^kt4CMKq^kOBntXm3>>_R>05j+Q< zK_zq;@5Z-Ki7gr#jkFC_@-?WA-Pknhy6vdv?ZCW7d>;)yW-q&Q@erPeAD}Y+4r}o^ zDxoSSQVE`qbFmKj%5bS=fuVMRs6*nR3%DT zDeAZaHHS^84(Fm0%V8tliK^hsI1NwWMOe**PrwDJ=OknA#|t@rZ4~v_goJX5WP4E+`3o0d5#?2d7qZiR8}c+X z(q1HKvmG_E2T&dE#i{rq>bldY2kP!(7=k?8RN^IAgPNk{sFANlJ#QOoKzCsY?nX^P z{-OASy{I{QKK|kWo?FCQft{Q`I>wkTY#JL?CUgVGzGc%6tsYz)7e|tw*hy&8W&eg6ilgoP+yu z7XBQ6KaL4!3}zy3#Cf<|@Bb4tw3;vFS4Xy>O1Ba<_W^40J&hXC3#ih)8haR*a{M;- zU`2H_!ame}JCRv452D_Z1E>MNiL>%Fj?!3&#ZELgi%}(7hLv~|>V_Ub^6mw_yn7(v)0*x_%{w9U?_BU8oN4K@DIR>bmD9P=Af^ASdeZb<`aGg1X^%oQ$VY zH`K5&G^h2b>k>E>)2KwAijTj*OE@mAiPlIXDv`Cw$MnX>kJV6rojA-+CI1c=qT{stC#@TohRk7MhQ6kN#1aHAE+=p5-qiXpl#Br#8x-fj*^EA%l!~>|+ z|1hc|hj0qMf%EVLYL4q!^7^5gfyLO0djA)p65EKS_$aCZPogSy1T`gJVhR3)Wtjh! zhC2KWmFYjI8_K!62yY@Z(k;YwL>sYy$P#)TdAsxy@mCuC`@#RVnH;H-s$PazOWa22 z6`da4O6xycnszhMOI%B6wAwUUiV&7}Ar2xm_u6hHRL)BYem2Z%LYpeWQV198E!aww z6MD@T6Jh@JHVPHZQ_MTj>L z+VXnO1EP~yPw-ZCYfOaERpu3p?Bfv)97X8q$JlA+8`?g1?0DU-2r` zRuf@X;AI30e3iMGMv~Y@bPyAWX+#&Xf$#}!^CL_yb`zGw?IzUQ6K;iX2cIk&G2h+P z-H}Oe@e_{M<968oBNYvm3o_OX(iz9gIz6`VN5zcdL0-z5tJoE4hYc&5?WS^W#ySZb z*iC_#_8reprkz};^_`&8I~9+Wb(x>={Eq(e5p^YQ zqOUMz#M8yiNgnOkt%3Ek;p4K7pAH;1l}hJqqS37G0WN>iAEg{+B!Mc_bulW0|VA$G~Be`YrT}$!MGdy z>&N|Axx{zeWX>fe4Saa@w6d#HuA3<|S3gv|!fvx<6K0T&D_~VI-?M=eq-}@3bNxos q>IDNA>U%du&q>esNHFP;V9x7G*i54E#rOwH%T{}VP4y=y)%**S8vn=YAp$rnK#I;CcKER%6ekfCmjd>M+!Un8bVoU~}!2-O7ui@{gNA|=R zvk>>=QtU?ln8W-m!(;duzK;pUgv=*YV!3b`*WkF@p20lYf4FTQH)YW-LUmA&3$O)q zu?^SbNz_2DViJyH6W+x)F~F$e(ZV?TH)B*Xxo{KxIEU(BU7VvIOKES!-PnVA!8b@! z%q`>*Gle9<{O0->7SWF4X1%Wz^;|hWi*N@nrhijMWd#N?3J38x4xwh~Ax|X1EJAgZ zoB00+@(3n2gPsf&-|54P!RGhZ?{Y4AF=AmP$GPfOe71pGS3k5w$c^sQ#uC$-hRH!OkeeA`D;VJ89=K$}S9}GVvd(pZt~1aji$aKlBw9 zjqo~Zgg4#4m_)7VuSnL-9MY$WdED8IKGYHvqVB83GHgR-Oqb zpfcn~-MDcn~SIi#xNJhQ4h?bGV?F$zOpoDcUPbW((ks3qmmpfZr38FIeS0WLhvg)Y<@evJCCSm?p4sNMfHYG9KX zg(+Fiv09DFOcmOv0V8R*p*re74R8>3UkD@c3Hmox#IwX8v5DAAs3a3QLcN5Jj0W%m z@jOvSsA!hiL=2%9FCdiK`K5@)L85=&#XZz}h)_2_oG^Rcz`RT-?5 z4m(4tw-DP1eOMk9Er$vpLVGDx*UZ@pcIK3|TtbDV4A~p3f<_~;k5GBZ!Tx25?NsVy zJSy6B9B;e4!tZ+GMPfgp_0|&f5xa>kL<6CHrBX_;dUkn3=l@BnweE%2UE^^Vp*^sW z;A3U~FGzbtrI{!uo+4U_X9zD*PjnLcHzkAUAm*2#+t`WE5eKyX`fScG+ucSx>f5gk z#D1}NJlJ19sS}|xK&-WE&bPhBt?SGe5KV+mnKp7hVZ1f%-opn6y`8OxtQ$-2M1(KJ z=3BF|r5>v^J|-eOlrRx)NX)U0C7$tE-AQ>-)@VveWccS*WmZ<|5s!6Y&8~=WwfA0l Vb6T<0mv+iyHD\n" "Language-Team: LANGUAGE \n" @@ -29,140 +29,148 @@ msgstr "Rechten" msgid "Important dates" msgstr "Belangrijke datums" -#: core/admin.py:128 core/admin.py:228 +#: core/admin.py:132 core/admin.py:243 msgid "No owner" msgstr "Geen eigenaar" -#: core/admin.py:131 core/admin.py:231 +#: core/admin.py:135 core/admin.py:246 msgid "Multiple owners" msgstr "Meerdere eigenaren" -#: core/admin.py:143 +#: core/admin.py:148 msgid "Resend notification to external service" msgstr "Melding opnieuw verzenden naar externe dienst" -#: core/admin.py:166 +#: core/admin.py:171 #, python-format msgid "Failed to notify for recording %(id)s" msgstr "Melding voor opname %(id)s mislukt" -#: core/admin.py:174 +#: core/admin.py:179 #, python-format msgid "Failed to notify for recording %(id)s: %(error)s" msgstr "Melding voor opname %(id)s mislukt: %(error)s" -#: core/admin.py:182 +#: core/admin.py:187 #, python-format msgid "Successfully sent notifications for %(count)s recording(s)." msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)." -#: core/admin.py:190 +#: core/admin.py:195 #, python-format msgid "Skipped %(count)s expired recording(s)." msgstr "%(count)s verlopen opname(n) overgeslagen." -#: core/admin.py:294 +#: core/admin.py:309 msgid "No scopes" msgstr "Geen scopes" -#: core/admin.py:296 +#: core/admin.py:311 msgid "Scopes" msgstr "Scopes" -#: core/api/serializers.py:68 +#: core/api/filters.py:24 +msgid "Creator is me" +msgstr "Maker ben ik" + +#: core/api/serializers.py:84 msgid "You must be administrator or owner of a room to add accesses to it." msgstr "" "Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen." -#: core/models.py:34 +#: core/api/serializers.py:443 +msgid "This file extension is not allowed." +msgstr "Deze bestandsextensie is niet toegestaan." + +#: core/models.py:35 msgid "Member" msgstr "Lid" -#: core/models.py:35 +#: core/models.py:36 msgid "Administrator" msgstr "Beheerder" -#: core/models.py:36 +#: core/models.py:37 msgid "Owner" msgstr "Eigenaar" -#: core/models.py:52 +#: core/models.py:53 msgid "Initiated" msgstr "Gestart" -#: core/models.py:53 +#: core/models.py:54 msgid "Active" msgstr "Actief" -#: core/models.py:54 +#: core/models.py:55 msgid "Stopped" msgstr "Gestopt" -#: core/models.py:55 +#: core/models.py:56 msgid "Saved" msgstr "Opgeslagen" -#: core/models.py:56 +#: core/models.py:57 msgid "Aborted" msgstr "Afgebroken" -#: core/models.py:57 +#: core/models.py:58 msgid "Failed to Start" msgstr "Starten mislukt" -#: core/models.py:58 +#: core/models.py:59 msgid "Failed to Stop" msgstr "Stoppen mislukt" -#: core/models.py:59 +#: core/models.py:60 msgid "Notification succeeded" msgstr "Notificatie geslaagd" -#: core/models.py:86 +#: core/models.py:87 msgid "SCREEN_RECORDING" msgstr "SCHERM_OPNAME" -#: core/models.py:87 +#: core/models.py:88 msgid "TRANSCRIPT" msgstr "TRANSCRIPT" -#: core/models.py:93 +#: core/models.py:94 msgid "Public Access" msgstr "Openbare toegang" -#: core/models.py:94 +#: core/models.py:95 msgid "Trusted Access" msgstr "Vertrouwde toegang" -#: core/models.py:95 +#: core/models.py:96 msgid "Restricted Access" msgstr "Beperkte toegang" -#: core/models.py:107 +#: core/models.py:108 msgid "id" msgstr "id" -#: core/models.py:108 +#: core/models.py:109 msgid "primary key for the record as UUID" msgstr "primaire sleutel voor het record als UUID" -#: core/models.py:114 +#: core/models.py:115 msgid "created on" msgstr "aangemaakt op" -#: core/models.py:115 +#: core/models.py:116 msgid "date and time at which a record was created" msgstr "datum en tijd waarop een record werd aangemaakt" -#: core/models.py:120 +#: core/models.py:121 msgid "updated on" msgstr "bijgewerkt op" -#: core/models.py:121 +#: core/models.py:122 msgid "date and time at which a record was last updated" msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt" -#: core/models.py:141 +#: core/models.py:142 msgid "" "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/" "_ characters." @@ -170,11 +178,11 @@ msgstr "" "Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/" "_ tekens bevatten." -#: core/models.py:147 +#: core/models.py:148 msgid "sub" msgstr "sub" -#: core/models.py:149 +#: core/models.py:150 msgid "" "Optional for pending users; required upon account activation. 255 characters " "or fewer. Letters, numbers, and @/./+/-/_ characters only." @@ -182,55 +190,55 @@ msgstr "" "Optioneel voor gebruikers in afwachting; vereist bij accountactivering. " "Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan." -#: core/models.py:158 +#: core/models.py:159 msgid "identity email address" msgstr "identiteit e-mailadres" -#: core/models.py:163 +#: core/models.py:164 msgid "admin email address" msgstr "beheerder e-mailadres" -#: core/models.py:165 +#: core/models.py:166 msgid "full name" msgstr "volledige naam" -#: core/models.py:167 +#: core/models.py:168 msgid "short name" msgstr "korte naam" -#: core/models.py:173 +#: core/models.py:174 msgid "language" msgstr "taal" -#: core/models.py:174 +#: core/models.py:175 msgid "The language in which the user wants to see the interface." msgstr "De taal waarin de gebruiker de interface wil zien." -#: core/models.py:180 +#: core/models.py:181 msgid "The timezone in which the user wants to see times." msgstr "De tijdzone waarin de gebruiker tijden wil zien." -#: core/models.py:183 +#: core/models.py:184 msgid "device" msgstr "apparaat" -#: core/models.py:185 +#: core/models.py:186 msgid "Whether the user is a device or a real user." msgstr "Of de gebruiker een apparaat is of een echte gebruiker." -#: core/models.py:188 +#: core/models.py:189 msgid "staff status" msgstr "personeelsstatus" -#: core/models.py:190 +#: core/models.py:191 msgid "Whether the user can log into this admin site." msgstr "Of de gebruiker kan inloggen op deze beheersite." -#: core/models.py:193 +#: core/models.py:194 msgid "active" msgstr "actief" -#: core/models.py:196 +#: core/models.py:197 msgid "" "Whether this user should be treated as active. Unselect this instead of " "deleting accounts." @@ -238,64 +246,64 @@ msgstr "" "Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in " "plaats van accounts te verwijderen." -#: core/models.py:209 +#: core/models.py:210 msgid "user" msgstr "gebruiker" -#: core/models.py:210 +#: core/models.py:211 msgid "users" msgstr "gebruikers" -#: core/models.py:269 +#: core/models.py:270 msgid "Resource" msgstr "Bron" -#: core/models.py:270 +#: core/models.py:271 msgid "Resources" msgstr "Bronnen" -#: core/models.py:324 +#: core/models.py:329 msgid "Resource access" msgstr "Brontoegang" -#: core/models.py:325 +#: core/models.py:330 msgid "Resource accesses" msgstr "Brontoegangsrechten" -#: core/models.py:331 +#: core/models.py:336 msgid "Resource access with this User and Resource already exists." msgstr "Brontoegang met deze gebruiker en bron bestaat al." -#: core/models.py:387 +#: core/models.py:392 msgid "Visio room configuration" msgstr "Visio-ruimteconfiguratie" -#: core/models.py:388 +#: core/models.py:393 msgid "Values for Visio parameters to configure the room." msgstr "Waarden voor Visio-parameters om de ruimte te configureren." -#: core/models.py:395 +#: core/models.py:400 msgid "Room PIN code" msgstr "Pincode van de kamer" -#: core/models.py:396 +#: core/models.py:401 msgid "Unique n-digit code that identifies this room in telephony mode." msgstr "" "Unieke n-cijferige code die deze kamer identificeert in telefonie-modus." -#: core/models.py:402 core/models.py:556 +#: core/models.py:407 core/models.py:561 msgid "Room" msgstr "Ruimte" -#: core/models.py:403 +#: core/models.py:408 msgid "Rooms" msgstr "Ruimtes" -#: core/models.py:567 +#: core/models.py:572 msgid "Worker ID" msgstr "Worker ID" -#: core/models.py:569 +#: core/models.py:574 msgid "" "Enter an identifier for the worker recording.This ID is retained even when " "the worker stops, allowing for easy tracking." @@ -303,108 +311,148 @@ msgstr "" "Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, " "zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is." -#: core/models.py:577 +#: core/models.py:582 msgid "Recording mode" msgstr "Opnamemodus" -#: core/models.py:578 +#: core/models.py:583 msgid "Defines the mode of recording being called." msgstr "Definieert de modus van opname die wordt aangeroepen." -#: core/models.py:583 core/models.py:584 +#: core/models.py:588 core/models.py:589 msgid "Recording options" msgstr "Opnameopties" -#: core/models.py:590 +#: core/models.py:595 msgid "Recording" msgstr "Opname" -#: core/models.py:591 +#: core/models.py:596 msgid "Recordings" msgstr "Opnames" -#: core/models.py:699 +#: core/models.py:704 msgid "Recording/user relation" msgstr "Opname/gebruiker-relatie" -#: core/models.py:700 +#: core/models.py:705 msgid "Recording/user relations" msgstr "Opname/gebruiker-relaties" -#: core/models.py:706 +#: core/models.py:711 msgid "This user is already in this recording." msgstr "Deze gebruiker is al in deze opname." -#: core/models.py:712 +#: core/models.py:717 msgid "This team is already in this recording." msgstr "Dit team is al in deze opname." -#: core/models.py:718 +#: core/models.py:723 msgid "Either user or team must be set, not both." msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide." -#: core/models.py:735 +#: core/models.py:740 msgid "Create rooms" msgstr "Ruimtes aanmaken" -#: core/models.py:736 +#: core/models.py:741 msgid "List rooms" msgstr "Ruimtes weergeven" -#: core/models.py:737 +#: core/models.py:742 msgid "Retrieve room details" msgstr "Details van een ruimte ophalen" -#: core/models.py:738 +#: core/models.py:743 msgid "Update rooms" msgstr "Ruimtes bijwerken" -#: core/models.py:739 +#: core/models.py:744 msgid "Delete rooms" msgstr "Ruimtes verwijderen" -#: core/models.py:752 +#: core/models.py:757 msgid "Application name" msgstr "Naam van de applicatie" -#: core/models.py:753 +#: core/models.py:758 msgid "Descriptive name for this application." msgstr "Beschrijvende naam voor deze applicatie." -#: core/models.py:763 +#: core/models.py:768 msgid "Hashed on Save. Copy it now if this is a new secret." msgstr "" "Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is." -#: core/models.py:774 +#: core/models.py:779 msgid "Application" msgstr "Applicatie" -#: core/models.py:775 +#: core/models.py:780 msgid "Applications" msgstr "Applicaties" -#: core/models.py:798 +#: core/models.py:803 msgid "Enter a valid domain" msgstr "Voer een geldig domein in" -#: core/models.py:801 +#: core/models.py:806 msgid "Domain" msgstr "Domein" -#: core/models.py:802 +#: core/models.py:807 msgid "Email domain this application can act on behalf of." msgstr "E-maildomein namens welke deze applicatie kan handelen." -#: core/models.py:814 +#: core/models.py:819 msgid "Application domain" msgstr "Applicatiedomein" -#: core/models.py:815 +#: core/models.py:820 msgid "Application domains" msgstr "Applicatiedomeinen" -#: core/recording/event/notification.py:94 +#: core/models.py:838 +msgid "Pending" +msgstr "In afwachting" + +#: core/models.py:846 +msgid "Ready" +msgstr "Klaar" + +#: core/models.py:852 +msgid "Background image" +msgstr "Achtergrondafbeelding" + +#: core/models.py:864 +msgid "title" +msgstr "Titel" + +#: core/models.py:890 +msgid "Malware detection info when the analysis status is unsafe." +msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is." + +#: core/models.py:895 +msgid "File" +msgstr "Bestand" + +#: core/models.py:896 +msgid "Files" +msgstr "Bestanden" + +#: core/models.py:970 +msgid "This file is already hard deleted." +msgstr "Dit bestand is al definitief verwijderd." + +#: core/models.py:980 +#, fuzzy +#| msgid "To hard delete a file, it must first be soft deleted." +msgid "To hard delete a file, it must first be soft deleted." +msgstr "" +"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd " +"zijn." + +#: core/recording/event/notification.py:116 msgid "Your recording is ready" msgstr "Je opname is klaar" @@ -532,18 +580,18 @@ msgstr "" " Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support " "team via %(support_email)s. " -#: meet/settings.py:169 +#: meet/settings.py:224 msgid "English" msgstr "Engels" -#: meet/settings.py:170 +#: meet/settings.py:225 msgid "French" msgstr "Frans" -#: meet/settings.py:171 +#: meet/settings.py:226 msgid "Dutch" msgstr "Nederlands" -#: meet/settings.py:172 +#: meet/settings.py:227 msgid "German" msgstr "Duits" diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index 937abff5..5a76273f 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -28,6 +28,10 @@ from sentry_sdk.integrations.logging import ignore_logger # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = path.dirname(path.dirname(path.abspath(__file__))) +KB = 1024 +MB = 1024 * KB +GB = 1024 * MB + def get_release(): """ @@ -121,6 +125,9 @@ class Base(Configuration): STATIC_ROOT = path.join(DATA_DIR, "static") MEDIA_URL = "/media/" MEDIA_ROOT = path.join(DATA_DIR, "media") + MEDIA_BASE_URL = values.Value( + "", environ_name="MEDIA_BASE_URL", environ_prefix=None + ) SITE_ID = 1 @@ -154,6 +161,40 @@ class Base(Configuration): environ_name="AWS_STORAGE_BUCKET_NAME", environ_prefix=None, ) + AWS_S3_SIGNATURE_VERSION = values.Value( + "s3v4", + environ_name="AWS_S3_SIGNATURE_VERSION", + environ_prefix=None, + ) + AWS_S3_UPLOAD_POLICY_EXPIRATION = values.Value( + 60, # 1 minute + environ_name="AWS_S3_UPLOAD_POLICY_EXPIRATION", + environ_prefix=None, + ) + AWS_S3_DOMAIN_REPLACE = values.Value( + environ_name="AWS_S3_DOMAIN_REPLACE", + environ_prefix=None, + ) + + FILE_UPLOAD_PATH = values.Value( + "files", environ_name="FILE_UPLOAD_PATH", environ_prefix=None + ) + + FILE_UPLOAD_APPLY_RESTRICTIONS = values.BooleanValue( + default=True, environ_name="FILE_UPLOAD_APPLY_RESTRICTIONS", environ_prefix=None + ) + + FILE_UPLOAD_RESTRICTIONS = values.DictValue( + { + "background_image": { + "max_size": 2 * MB, + "allowed_extensions": [".jpeg", ".jpg", ".png"], + "allowed_mimetypes": ["image/jpeg", "image/png"], + }, + }, + environ_name="FILE_UPLOAD_RESTRICTIONS", + environ_prefix=None, + ) # Internationalization # https://docs.djangoproject.com/en/3.1/topics/i18n/ @@ -394,6 +435,9 @@ class Base(Configuration): THUMBNAIL_ALIASES = {} # Celery + # Defaults to False to avoid breaking change, async task will be run + # synchronously + CELERY_ENABLED = values.BooleanValue(False) CELERY_BROKER_URL = values.Value("redis://redis:6379/0") CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({}) diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 2dcc8be9..5aab64d3 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "django-configurations==2.5.1", "django-cors-headers==4.9.0", "django-countries==8.2.0", + "django-filter==25.2", "django-lasuite[all]==0.0.24", "django-parler==2.3", "redis==5.2.1", diff --git a/src/helm/env.d/dev-dinum/values.meet.yaml.gotmpl b/src/helm/env.d/dev-dinum/values.meet.yaml.gotmpl index a779c16e..11dad834 100644 --- a/src/helm/env.d/dev-dinum/values.meet.yaml.gotmpl +++ b/src/helm/env.d/dev-dinum/values.meet.yaml.gotmpl @@ -63,6 +63,7 @@ backend: AWS_S3_SECRET_ACCESS_KEY: password AWS_STORAGE_BUCKET_NAME: meet-media-storage AWS_S3_REGION_NAME: local + MEDIA_BASE_URL: https://meet.127.0.0.1.nip.io RECORDING_ENABLE: True RECORDING_STORAGE_EVENT_ENABLE: True RECORDING_STORAGE_EVENT_TOKEN: password diff --git a/src/helm/env.d/dev-keycloak/values.meet.yaml.gotmpl b/src/helm/env.d/dev-keycloak/values.meet.yaml.gotmpl index 0aa53d4b..5641137d 100644 --- a/src/helm/env.d/dev-keycloak/values.meet.yaml.gotmpl +++ b/src/helm/env.d/dev-keycloak/values.meet.yaml.gotmpl @@ -64,6 +64,7 @@ backend: AWS_S3_SECRET_ACCESS_KEY: password AWS_STORAGE_BUCKET_NAME: meet-media-storage AWS_S3_REGION_NAME: local + MEDIA_BASE_URL: https://meet.127.0.0.1.nip.io RECORDING_ENABLE: True RECORDING_STORAGE_EVENT_ENABLE: True RECORDING_STORAGE_EVENT_TOKEN: password diff --git a/src/helm/env.d/dev/values.meet.yaml.gotmpl b/src/helm/env.d/dev/values.meet.yaml.gotmpl index 726c06cc..400f48a5 100644 --- a/src/helm/env.d/dev/values.meet.yaml.gotmpl +++ b/src/helm/env.d/dev/values.meet.yaml.gotmpl @@ -80,6 +80,7 @@ backend: AWS_S3_SECRET_ACCESS_KEY: password AWS_STORAGE_BUCKET_NAME: meet-media-storage AWS_S3_REGION_NAME: local + MEDIA_BASE_URL: https://meet.127.0.0.1.nip.io RECORDING_ENABLE: True RECORDING_STORAGE_EVENT_ENABLE: True RECORDING_STORAGE_EVENT_TOKEN: password