mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-21 15:47:09 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ed6091331 |
+4
-1
@@ -8,10 +8,13 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(backend) add OpenShift-compatible recording download endpoint
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- ✨(backend) accept form-urlencoded on the user token endpoint
|
- ✨(backend) accept form-urlencoded on the user token endpoint
|
||||||
- ✨(summary) configurable s3 region
|
|
||||||
- ⬆️(frontend) upgrade i18next and react-i18next patch versions
|
- ⬆️(frontend) upgrade i18next and react-i18next patch versions
|
||||||
- ⬆️(frontend) upgrade posthog-js from 1.395.0 to 1.404.1
|
- ⬆️(frontend) upgrade posthog-js from 1.395.0 to 1.404.1
|
||||||
- ⬆️(frontend) upgrade livekit-client and @livekit/components-react
|
- ⬆️(frontend) upgrade livekit-client and @livekit/components-react
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ from django.core.exceptions import ValidationError as DjangoValidationError
|
|||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import Http404
|
from django.http import Http404, StreamingHttpResponse
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from botocore.exceptions import BotoCoreError, ClientError
|
||||||
from django_filters import rest_framework as django_filters
|
from django_filters import rest_framework as django_filters
|
||||||
from rest_framework import (
|
from rest_framework import (
|
||||||
decorators,
|
decorators,
|
||||||
@@ -113,6 +114,14 @@ from .feature_flag import FeatureFlag
|
|||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class StorageUnavailable(drf_exceptions.APIException):
|
||||||
|
"""Exception raised when recording storage cannot be reached."""
|
||||||
|
|
||||||
|
status_code = drf_status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
default_detail = "The recording storage is temporarily unavailable."
|
||||||
|
default_code = "storage_unavailable"
|
||||||
|
|
||||||
|
|
||||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||||
"""
|
"""
|
||||||
A generic Viewset aims to be used in a nested route context.
|
A generic Viewset aims to be used in a nested route context.
|
||||||
@@ -1220,6 +1229,87 @@ class RecordingViewSet(
|
|||||||
|
|
||||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||||
|
|
||||||
|
@decorators.action(detail=False, methods=["get"], url_path="download")
|
||||||
|
def media_download(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Stream a recording file directly from S3 to the browser.
|
||||||
|
|
||||||
|
This endpoint is registered at /media/recordings/<uuid>.<ext> in core/urls.py
|
||||||
|
and handles the download flow on OpenShift where the Nginx auth_request
|
||||||
|
mechanism is not available.
|
||||||
|
|
||||||
|
Django acts as a streaming proxy: bytes flow from S3 (internal cluster URL)
|
||||||
|
directly to the client response. This avoids any page navigation or redirect,
|
||||||
|
so the browser triggers a native download without leaving the current page.
|
||||||
|
|
||||||
|
Access control mirrors media_auth: the user must have the "retrieve" ability
|
||||||
|
on the recording and the recording must be in a saved state.
|
||||||
|
"""
|
||||||
|
recording_id = self.kwargs.get("recording_id")
|
||||||
|
extension = self.kwargs.get("extension")
|
||||||
|
|
||||||
|
if extension not in [file.value for file in FileExtension]:
|
||||||
|
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
|
||||||
|
|
||||||
|
try:
|
||||||
|
recording = models.Recording.objects.get(id=recording_id)
|
||||||
|
except models.Recording.DoesNotExist as e:
|
||||||
|
raise drf_exceptions.NotFound("No recording found for this id.") from e
|
||||||
|
|
||||||
|
if extension != recording.extension:
|
||||||
|
raise drf_exceptions.NotFound("No recording found with this extension.")
|
||||||
|
|
||||||
|
abilities = recording.get_abilities(request.user)
|
||||||
|
|
||||||
|
if not abilities["retrieve"]:
|
||||||
|
logger.debug(
|
||||||
|
"User '%s' lacks permission for recording download", request.user.id
|
||||||
|
)
|
||||||
|
raise drf_exceptions.PermissionDenied()
|
||||||
|
|
||||||
|
if not recording.is_saved:
|
||||||
|
logger.debug("Recording '%s' has not been saved", recording)
|
||||||
|
raise drf_exceptions.PermissionDenied()
|
||||||
|
|
||||||
|
s3_client = default_storage.connection.meta.client
|
||||||
|
try:
|
||||||
|
s3_object = s3_client.get_object(
|
||||||
|
Bucket=default_storage.bucket_name,
|
||||||
|
Key=recording.key,
|
||||||
|
)
|
||||||
|
except ClientError as error:
|
||||||
|
error_code = error.response.get("Error", {}).get("Code")
|
||||||
|
if error_code in {"404", "NoSuchKey", "NoSuchObject"}:
|
||||||
|
raise drf_exceptions.NotFound(
|
||||||
|
"No recording file found in storage."
|
||||||
|
) from error
|
||||||
|
logger.exception("Unable to retrieve recording from storage")
|
||||||
|
raise StorageUnavailable() from error
|
||||||
|
except BotoCoreError as error:
|
||||||
|
logger.exception("Unable to retrieve recording from storage")
|
||||||
|
raise StorageUnavailable() from error
|
||||||
|
|
||||||
|
content_type = s3_object.get("ContentType") or "video/mp4"
|
||||||
|
content_length = s3_object.get("ContentLength")
|
||||||
|
filename = f"{recording_id}.{extension}"
|
||||||
|
body = s3_object["Body"]
|
||||||
|
|
||||||
|
def stream():
|
||||||
|
try:
|
||||||
|
yield from body.iter_chunks(chunk_size=65536)
|
||||||
|
finally:
|
||||||
|
body.close()
|
||||||
|
|
||||||
|
streaming_response = StreamingHttpResponse(
|
||||||
|
stream(),
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
streaming_response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||||
|
if content_length:
|
||||||
|
streaming_response["Content-Length"] = content_length
|
||||||
|
|
||||||
|
return streaming_response
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=too-many-public-methods
|
# pylint: disable=too-many-public-methods
|
||||||
class FileViewSet(
|
class FileViewSet(
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ Test recordings API endpoints in the Meet core app: retrieve.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from botocore.exceptions import ClientError, EndpointConnectionError
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
@@ -14,6 +18,11 @@ from ...models import RecordingStatusChoices
|
|||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def media_recording_url(recording):
|
||||||
|
"""Return the direct media URL for a recording."""
|
||||||
|
return f"/media/recordings/{recording.id!s}.{recording.extension}"
|
||||||
|
|
||||||
|
|
||||||
def test_api_recording_retrieve_anonymous():
|
def test_api_recording_retrieve_anonymous():
|
||||||
"""Anonymous users should not be able to retrieve recordings."""
|
"""Anonymous users should not be able to retrieve recordings."""
|
||||||
recording = RecordingFactory()
|
recording = RecordingFactory()
|
||||||
@@ -25,6 +34,9 @@ def test_api_recording_retrieve_anonymous():
|
|||||||
"detail": "Authentication credentials were not provided."
|
"detail": "Authentication credentials were not provided."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
response = client.get(media_recording_url(recording))
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_api_recording_retrieve_authenticated():
|
def test_api_recording_retrieve_authenticated():
|
||||||
"""Authenticated users without access receive 404 when requesting recordings.
|
"""Authenticated users without access receive 404 when requesting recordings.
|
||||||
@@ -44,6 +56,9 @@ def test_api_recording_retrieve_authenticated():
|
|||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
assert response.json() == {"detail": "No Recording matches the given query."}
|
assert response.json() == {"detail": "No Recording matches the given query."}
|
||||||
|
|
||||||
|
response = client.get(media_recording_url(recording))
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_api_recording_retrieve_members():
|
def test_api_recording_retrieve_members():
|
||||||
"""
|
"""
|
||||||
@@ -63,6 +78,11 @@ def test_api_recording_retrieve_members():
|
|||||||
"detail": "You do not have permission to perform this action."
|
"detail": "You do not have permission to perform this action."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recording.status = RecordingStatusChoices.SAVED
|
||||||
|
recording.save(update_fields=["status"])
|
||||||
|
response = client.get(media_recording_url(recording))
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_api_recording_retrieve_administrators(settings):
|
def test_api_recording_retrieve_administrators(settings):
|
||||||
"""A user who is an administrator of a recording should be able to retrieve it."""
|
"""A user who is an administrator of a recording should be able to retrieve it."""
|
||||||
@@ -137,6 +157,78 @@ def test_api_recording_retrieve_owners(settings):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", ["owner", "administrator"])
|
||||||
|
def test_api_recording_media_download_authorized(role):
|
||||||
|
"""Owners and administrators can download a saved recording."""
|
||||||
|
user = UserFactory()
|
||||||
|
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
|
||||||
|
UserRecordingAccessFactory(recording=recording, user=user, role=role)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
body = mock.Mock(iter_chunks=mock.Mock(return_value=[b"recording content"]))
|
||||||
|
s3_object = {
|
||||||
|
"Body": body,
|
||||||
|
"ContentType": "video/mp4",
|
||||||
|
"ContentLength": len(b"recording content"),
|
||||||
|
}
|
||||||
|
with mock.patch.object(
|
||||||
|
default_storage.connection.meta.client,
|
||||||
|
"get_object",
|
||||||
|
return_value=s3_object,
|
||||||
|
) as get_object:
|
||||||
|
response = client.get(media_recording_url(recording))
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
get_object.assert_called_once_with(
|
||||||
|
Bucket=default_storage.bucket_name,
|
||||||
|
Key=recording.key,
|
||||||
|
)
|
||||||
|
assert response["Content-Type"] == "video/mp4"
|
||||||
|
assert response["Content-Disposition"] == (
|
||||||
|
f'attachment; filename="{recording.id}.{recording.extension}"'
|
||||||
|
)
|
||||||
|
assert b"".join(response.streaming_content) == b"recording content"
|
||||||
|
body.close.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_recording_media_download_missing_object():
|
||||||
|
"""A missing recording object should return 404."""
|
||||||
|
user = UserFactory()
|
||||||
|
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
|
||||||
|
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
error = ClientError(
|
||||||
|
{"Error": {"Code": "NoSuchKey", "Message": "The object does not exist."}},
|
||||||
|
"GetObject",
|
||||||
|
)
|
||||||
|
with mock.patch.object(
|
||||||
|
default_storage.connection.meta.client, "get_object", side_effect=error
|
||||||
|
):
|
||||||
|
response = client.get(media_recording_url(recording))
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_recording_media_download_storage_unavailable():
|
||||||
|
"""A storage connection failure should return 503."""
|
||||||
|
user = UserFactory()
|
||||||
|
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
|
||||||
|
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
error = EndpointConnectionError(endpoint_url="https://storage.test")
|
||||||
|
with mock.patch.object(
|
||||||
|
default_storage.connection.meta.client, "get_object", side_effect=error
|
||||||
|
):
|
||||||
|
response = client.get(media_recording_url(recording))
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2023-01-15 12:00:00")
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
def test_api_recording_retrieve_compute_expiration_date_correctly(settings):
|
def test_api_recording_retrieve_compute_expiration_date_correctly(settings):
|
||||||
"""Test that the API returns the correct expiration date for a non-expired recording."""
|
"""Test that the API returns the correct expiration date for a non-expired recording."""
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ urlpatterns = [
|
|||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
# The request is handled by RecordingViewSet.media_download which validates
|
||||||
|
# access rights and streams the recording file directly from MinIO to the browser.
|
||||||
|
path(
|
||||||
|
"media/recordings/<uuid:recording_id>.<slug:extension>",
|
||||||
|
viewsets.RecordingViewSet.as_view({"get": "media_download"}),
|
||||||
|
name="recording_media_download",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.EXTERNAL_API_ENABLED:
|
if settings.EXTERNAL_API_ENABLED:
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
import { useStartRecording, useStopRecording } from '@/features/recording'
|
import { useStartRecording, useStopRecording } from '@/features/recording'
|
||||||
import { recordingStore } from '@/stores/recording'
|
import { recordingStore } from '@/stores/recording'
|
||||||
import { captureEvent } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
export const useMutateRecording = () => {
|
export const useMutateRecording = () => {
|
||||||
const { mutateAsync: startRecording, isPending: isPendingToStart } =
|
const { mutateAsync: startRecording, isPending: isPendingToStart } =
|
||||||
useStartRecording({
|
useStartRecording({
|
||||||
onError: () => {
|
onError: () => {
|
||||||
recordingStore.isErrorDialogOpen = 'start'
|
recordingStore.isErrorDialogOpen = 'start'
|
||||||
captureEvent('error-starting-recording')
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const { mutateAsync: stopRecording, isPending: isPendingToStop } =
|
const { mutateAsync: stopRecording, isPending: isPendingToStop } =
|
||||||
useStopRecording({
|
useStopRecording({
|
||||||
onError: () => {
|
onError: () => {
|
||||||
recordingStore.isErrorDialogOpen = 'stop'
|
recordingStore.isErrorDialogOpen = 'stop'
|
||||||
captureEvent('error-stopping-recording')
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import {
|
|||||||
usePersistentUserChoices,
|
usePersistentUserChoices,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import {
|
import {
|
||||||
ConnectionError,
|
|
||||||
ConnectionErrorReason,
|
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
MediaDeviceFailure,
|
MediaDeviceFailure,
|
||||||
Room,
|
Room,
|
||||||
@@ -27,11 +25,7 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||||
import { LocalUserChoices } from '@/stores/userChoices'
|
import { LocalUserChoices } from '@/stores/userChoices'
|
||||||
import {
|
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||||
captureEvent,
|
|
||||||
captureMediaEvent,
|
|
||||||
reportError,
|
|
||||||
} from '@/features/analytics/telemetry'
|
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
import { isFireFox } from '@/utils/livekit'
|
import { isFireFox } from '@/utils/livekit'
|
||||||
import { useIsMobile } from '@/utils/useIsMobile'
|
import { useIsMobile } from '@/utils/useIsMobile'
|
||||||
@@ -233,16 +227,6 @@ export const Conference = ({
|
|||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const failure = MediaDeviceFailure.getFailure(e)
|
const failure = MediaDeviceFailure.getFailure(e)
|
||||||
if (failure && failure !== MediaDeviceFailure.Other) return
|
if (failure && failure !== MediaDeviceFailure.Other) return
|
||||||
|
|
||||||
// connect() was aborted by a disconnect() before the join completed
|
|
||||||
if (
|
|
||||||
e instanceof ConnectionError &&
|
|
||||||
e.reason === ConnectionErrorReason.Cancelled
|
|
||||||
) {
|
|
||||||
void captureEvent('connection-cancelled')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reportError('livekit_room_error', e, {
|
reportError('livekit_room_error', e, {
|
||||||
path: 'connect_publish',
|
path: 'connect_publish',
|
||||||
})
|
})
|
||||||
|
|||||||
+3
-1
@@ -105,7 +105,9 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
|
|
||||||
_initVirtualBackgroundImage() {
|
_initVirtualBackgroundImage() {
|
||||||
if (this.options.type !== 'virtual') {
|
if (this.options.type !== 'virtual') {
|
||||||
return
|
throw new Error(
|
||||||
|
'Virtual background is only supported for virtual background'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsUpdate =
|
const needsUpdate =
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { SubtitlesToggle } from '../../components/controls/SubtitlesToggle'
|
|||||||
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
|
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
|
||||||
import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
||||||
import { MoreOptions } from './MoreOptions'
|
import { MoreOptions } from './MoreOptions'
|
||||||
import { RefObject, useMemo, useState } from 'react'
|
import { useRef } from 'react'
|
||||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
import { useFullScreen } from '../../hooks/useFullScreen'
|
import { useFullScreen } from '../../hooks/useFullScreen'
|
||||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||||
@@ -21,14 +21,7 @@ export function DesktopControlBar({
|
|||||||
onDeviceError,
|
onDeviceError,
|
||||||
}: Readonly<ControlBarAuxProps>) {
|
}: Readonly<ControlBarAuxProps>) {
|
||||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||||
|
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
||||||
const [controlBarElement, setControlBarElement] =
|
|
||||||
useState<HTMLDivElement | null>(null)
|
|
||||||
|
|
||||||
const desktopControlBarEl = useMemo<RefObject<HTMLDivElement>>(
|
|
||||||
() => ({ current: controlBarElement }),
|
|
||||||
[controlBarElement]
|
|
||||||
)
|
|
||||||
|
|
||||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
|
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
|
||||||
|
|
||||||
@@ -52,7 +45,7 @@ export function DesktopControlBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setControlBarElement}
|
ref={desktopControlBarEl}
|
||||||
className={css({
|
className={css({
|
||||||
width: '100vw',
|
width: '100vw',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ class Settings(BaseSettings):
|
|||||||
aws_s3_access_key_id: str
|
aws_s3_access_key_id: str
|
||||||
aws_s3_secret_access_key: SecretStr
|
aws_s3_secret_access_key: SecretStr
|
||||||
aws_s3_secure_access: bool = True
|
aws_s3_secure_access: bool = True
|
||||||
aws_s3_region_name: str | None = None
|
|
||||||
aws_transcript_path: str = "transcripts"
|
aws_transcript_path: str = "transcripts"
|
||||||
aws_summary_path: str = "summaries"
|
aws_summary_path: str = "summaries"
|
||||||
|
|
||||||
|
|||||||
@@ -282,7 +282,6 @@ class FileService:
|
|||||||
access_key=settings.aws_s3_access_key_id,
|
access_key=settings.aws_s3_access_key_id,
|
||||||
secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
|
secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
|
||||||
secure=settings.aws_s3_secure_access,
|
secure=settings.aws_s3_secure_access,
|
||||||
region=settings.aws_s3_region_name,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self._bucket_name = settings.aws_storage_bucket_name
|
self._bucket_name = settings.aws_storage_bucket_name
|
||||||
|
|||||||
Reference in New Issue
Block a user