Compare commits

..

2 Commits

Author SHA1 Message Date
lebaudantoine b2fa7a64d9 wip for performance use a component instead of a hook to avoid render children 2026-06-01 15:22:01 +02:00
lebaudantoine 90c34dd06b wip add endpoint to promote user 2026-06-01 15:13:02 +02:00
36 changed files with 929 additions and 1354 deletions
+1 -14
View File
@@ -8,19 +8,6 @@ and this project adheres to
## [Unreleased]
## [1.18.0] - 2026-06-03
### Added
- 🔧(backend) backport logging configuration from docs
- 🧑‍💻(backend) add management command to merge duplicate users
- 👷(helm) add Kubernetes job for duplicate user merge command
### Fixed
- 🐛(backend) prevent duplicate pending users on concurrent requests
- 🔒️(backend) prevent file change post checks #1377
## [1.17.0] - 2026-05-31
### Added
@@ -33,7 +20,7 @@ and this project adheres to
- ✨(backend) add core.recording.event.parsers.S3Parser
- ✨(summary) extended support for all video / audio files #1358
### Changed
### Changed
- ♻️(fullstack) simplify source serialization
- ✨(backend) expose room configuration to all API consumers
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.18.0"
version = "1.17.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.4.5",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "agents"
version = "1.18.0"
version = "1.17.0"
source = { virtual = "." }
dependencies = [
{ name = "livekit-agents" },
+195 -101
View File
@@ -71,6 +71,11 @@ from core.services.lobby import (
LobbyParticipantNotFound,
LobbyService,
)
from core.services.participant_promotion import (
AlreadyAdminException,
OwnerPromotionException,
ParticipantPromotionService,
)
from core.services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagement,
@@ -875,6 +880,123 @@ class RoomViewSet(
status=drf_status.HTTP_200_OK,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="promote-participant",
url_name="promote-participant",
permission_classes=[permissions.HasPrivilegesOnRoom],
)
# pylint: disable=unused-argument,too-many-return-statements
def promote_participant(self, request, pk=None): # noqa: PLR0911
"""Promote a live participant to room admin.
This endpoint is intentionally non-transactional: the DB transaction and the
LiveKit attribute update are two separate operations. If the participant
leaves the meeting between the presence check and the LiveKit update, the
resource access is kept — their role will be effective the next time they join.
"""
room = self.get_object()
serializer = serializers.BaseParticipantsManagementSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
identity = serializer.validated_data["participant_identity"]
participant_management = ParticipantsManagement()
if str(identity) == str(request.user.sub):
return drf_response.Response(
{"error": "You cannot promote yourself"},
status=drf_status.HTTP_403_FORBIDDEN,
)
try:
is_in_meeting = participant_management.check_if_in_meeting(
room.pk, identity=str(identity)
)
except (ParticipantNotFoundException, ParticipantsManagementException):
logger.warning(
"Could not verify presence of participant %s in room %s before promotion; denying",
identity,
room.pk,
)
return drf_response.Response(
{"error": "Could not verify participant presence"},
status=drf_status.HTTP_403_FORBIDDEN,
)
if not is_in_meeting:
logger.warning(
"Participant %s is not currently in room %s; denying promotion",
identity,
room.pk,
)
return drf_response.Response(
{"error": "Could not verify participant presence"},
status=drf_status.HTTP_403_FORBIDDEN,
)
try:
user = models.User.objects.get(sub=identity)
except models.User.DoesNotExist:
return drf_response.Response(
{"error": "Participant not found"},
status=drf_status.HTTP_404_NOT_FOUND,
)
if not user.is_active:
logger.warning(
"Attempted to promote inactive user %s in room %s; denying",
identity,
room.pk,
)
return drf_response.Response(
{
"error": "This participant account is inactive and cannot be promoted"
},
status=drf_status.HTTP_403_FORBIDDEN,
)
try:
ParticipantPromotionService().promote_to_admin(room=room, user=user)
except AlreadyAdminException:
return drf_response.Response(
{"status": "success"}, status=drf_status.HTTP_200_OK
)
except OwnerPromotionException:
return drf_response.Response(
{
"error": "Owners already have the highest privileges and cannot be promoted"
},
status=drf_status.HTTP_403_FORBIDDEN,
)
# DB transaction is intentionally persisted before the LiveKit update.
# If the participant disconnects meanwhile, the role still applies on rejoin.
try:
participant_management.update(
room_name=str(room.pk),
identity=str(identity),
attributes={"room_admin": "true"},
)
except (ParticipantNotFoundException, ParticipantsManagementException):
logger.exception(
"LiveKit update failed for participant %s in room %s; ADMIN role persisted",
identity,
room.pk,
)
return drf_response.Response(
{
"status": "success",
"warning": "LiveKit update failed; role update persisted",
},
status=drf_status.HTTP_200_OK,
)
return drf_response.Response(
{"status": "success"}, status=drf_status.HTTP_200_OK
)
class ResourceAccessViewSet(
mixins.CreateModelMixin,
@@ -1196,124 +1318,96 @@ class FileViewSet(
"""
Check the actual uploaded file and mark it as ready.
"""
# Ensures we go through authorization checks
file = self.get_object()
# Try to update the file with the new state. If the file is already in this state
# we are in a concurrent request, and we should reject that request
updated_rows = models.File.objects.filter(
upload_state=models.FileUploadStateChoices.PENDING,
pk=kwargs["pk"],
).update(upload_state=models.FileUploadStateChoices.ANALYZING)
if updated_rows != 1:
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",
)
file.refresh_from_db()
s3_client = default_storage.connection.meta.client
validation_error = None
try:
# We copy the file to its final destination, we will run the checks on that
# final file and ignore any updates to the temporary file. (We cannot revoke the policy,
# so the temporary file might still be updated after that.)
# The temporary folders will need to be cleaned periodically
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.temporary_file_key,
"Key": file.file_key,
},
ContentType=mimetype,
Metadata=head_response["Metadata"],
MetadataDirective="REPLACE",
)
head_response = s3_client.head_object(
Bucket=default_storage.bucket_name, Key=file.file_key
)
file_size = head_response["ContentLength"]
# 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()
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]
if file_size > config_for_file_type["max_size"]:
logger.info(
"upload_ended: file size (%s) for file %s higher than the allowed max size",
file_size,
file.file_key,
)
validation_error = drf_exceptions.ValidationError(
detail="The file size is higher than the allowed max size.",
code="file_size_exceeded",
)
else:
# Use improved MIME type detection combining magic bytes and file extension
allowed_file_mimetypes = config_for_file_type["allowed_mimetypes"]
if mimetype not in allowed_file_mimetypes:
logger.warning(
"upload_ended: mimetype not allowed %s for file %s",
mimetype,
file.file_key,
)
validation_error = drf_exceptions.ValidationError(
detail="The file type is not allowed.",
code="file_type_not_allowed",
)
if validation_error is not None:
self._complete_file_deletion(file)
else:
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",
)
except Exception as e:
logger.exception("Failed to analyze file, reverting to pending state")
file.upload_state = models.FileUploadStateChoices.PENDING
file.save()
raise e
if validation_error:
raise validation_error
# Not yet implemented
# Change the file.upload_state when this will be done
# malware_detection.analyse_file(file.file_key, file_id=file.id)
+35 -14
View File
@@ -4,7 +4,7 @@ from logging import getLogger
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.core.exceptions import ValidationError
from django.core.exceptions import SuspiciousOperation, ValidationError
from django.core.validators import validate_email
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
@@ -23,11 +23,6 @@ from core import api, models
from core.api.feature_flag import FeatureFlag
from core.services.jwt_token import JwtTokenService
from ..services.provisional_user_service import (
ProvisionalUserCreationDisabledError,
ProvisionalUserIntegrityError,
ProvisionalUserService,
)
from . import authentication, permissions, serializers
logger = getLogger(__name__)
@@ -99,14 +94,40 @@ class ApplicationViewSet(viewsets.ViewSet):
)
try:
user, _ = ProvisionalUserService().get_or_create(email, client_id)
except ProvisionalUserCreationDisabledError as not_found_error:
raise drf_exceptions.NotFound("User not found.") from not_found_error
except ProvisionalUserIntegrityError:
return drf_response.Response(
{"error": "Failed to create or retrieve provisional user."},
status=drf_status.HTTP_409_CONFLICT,
)
user = models.User.objects.get(email__iexact=email)
except models.User.DoesNotExist as e:
if (
settings.APPLICATION_ALLOW_USER_CREATION
and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
and not settings.OIDC_USER_SUB_FIELD_IMMUTABLE
):
# Create a provisional user without `sub`, identified by email only.
#
# This relies on Django LaSuite implicitly updating the `sub` field on the
# user's first successful OIDC authentication. If this stops working,
# check for behavior changes in Django LaSuite.
#
# `OIDC_USER_SUB_FIELD_IMMUTABLE` comes from Django LaSuite and prevents `sub`
# updates. We override its default value to allow setting `sub` for
# provisional users.
user = models.User(
sub=None,
email=email,
)
user.set_unusable_password()
user.save()
logger.info(
"Provisional user created via application: user_id=%s, email=%s, client_id=%s",
user.id,
email,
application.client_id,
)
else:
raise drf_exceptions.NotFound("User not found.") from e
except models.User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
"Multiple user accounts share a common email."
) from e
scope = " ".join(application.scopes or [])
@@ -1,168 +0,0 @@
"""Management command to merge duplicate users based on their email address."""
# pylint: disable=too-many-locals
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.db.models import Count
from core.models import File, RecordingAccess, ResourceAccess, RoleChoices
User = get_user_model()
ROLE_PRIORITY = {
RoleChoices.OWNER: 3,
RoleChoices.ADMIN: 2,
RoleChoices.MEMBER: 1,
}
class Command(BaseCommand):
"""
Merge duplicate users sharing the same email into the most recently created one.
The KEPT user is the most recently created. All room memberships, recording
accesses and files are transferred to it. When a conflict exists, the
higher-privilege role wins. Stale users are then deleted.
Each email group is processed inside a single database transaction.
"""
help = __doc__
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Simulate the merge without writing any changes to the database.",
)
parser.add_argument(
"--email-filter",
type=str,
default=None,
help="Only merge users whose email contains this string (e.g. '@example.com').",
)
def handle(self, *args, **options):
"""Execute the management command."""
dry_run = options["dry_run"]
email_filter = options["email_filter"]
if dry_run:
self.stdout.write("[DRY-RUN] No changes will be written.\n")
users_qs = User.objects.all()
if email_filter:
users_qs = users_qs.filter(email__icontains=email_filter)
self.stdout.write(f"[INFO] Filtering emails containing '{email_filter}'.\n")
duplicate_emails = (
users_qs.exclude(email__isnull=True)
.exclude(email="")
.values("email")
.annotate(cnt=Count("id"))
.filter(cnt__gt=1)
.values_list("email", flat=True)
)
if not duplicate_emails:
self.stdout.write("[INFO] No duplicate users found. Nothing to do.")
return
self.stdout.write(
f"[INFO] Found {len(duplicate_emails)} email(s) with duplicate users."
)
total_merged = 0
total_deleted = 0
failed_emails = []
for email in duplicate_emails:
# Secondary sort by id ensures a stable, deterministic order when
# created_at timestamps are equal (common in tests and bulk imports).
users = list(User.objects.filter(email=email).order_by("created_at", "id"))
kept_user = users[-1]
stale_users = users[:-1]
self.stdout.write(
f"\n[INFO] Email '{email}': {len(users)} users — "
f"keeping {kept_user.id} (created {kept_user.created_at.date()})."
)
for u in stale_users:
self.stdout.write(
f" stale: {u.id} (created {u.created_at.date()})"
)
if dry_run:
ra_count = ResourceAccess.objects.filter(user__in=stale_users).count()
rca_count = RecordingAccess.objects.filter(user__in=stale_users).count()
f_count = File.objects.filter(creator__in=stale_users).count()
self.stdout.write(
f" [DRY-RUN] Would migrate: {ra_count} ResourceAccess, "
f"{rca_count} RecordingAccess, {f_count} File(s)."
)
continue
try:
group_deleted = 0
with transaction.atomic():
for stale_user in stale_users:
self._merge_resource_accesses(stale_user, kept_user)
self._merge_recording_accesses(stale_user, kept_user)
self._merge_files(stale_user, kept_user)
stale_user.delete()
group_deleted += 1
total_deleted += group_deleted
total_merged += 1
except Exception as exc: # noqa: BLE001 #pylint: disable=broad-exception-caught
failed_emails.append(email)
self.stderr.write(f"[ERROR] Failed to merge '{email}': {exc}")
if failed_emails:
raise CommandError(
f"Failed to merge {len(failed_emails)} email group(s): {', '.join(failed_emails)}"
)
self.stdout.write(
self.style.SUCCESS(
f"\n[DONE] Merged {total_merged} group(s), deleted {total_deleted} user(s)."
)
)
def _merge_resource_accesses(self, stale_user, kept_user):
"""Transfer room memberships from stale_user to kept_user."""
for ra in ResourceAccess.objects.filter(user=stale_user):
existing = ResourceAccess.objects.filter(
user=kept_user, resource=ra.resource
).first()
if existing is None:
ra.user = kept_user
ra.save(update_fields=["user"])
else:
if ROLE_PRIORITY.get(ra.role, 0) > ROLE_PRIORITY.get(existing.role, 0):
existing.role = ra.role
existing.save(update_fields=["role"])
ra.delete()
def _merge_recording_accesses(self, stale_user, kept_user):
"""Transfer recording accesses from stale_user to kept_user."""
for rca in RecordingAccess.objects.filter(user=stale_user):
existing = RecordingAccess.objects.filter(
user=kept_user, recording=rca.recording
).first()
if existing is None:
rca.user = kept_user
rca.save(update_fields=["user"])
else:
if ROLE_PRIORITY.get(rca.role, 0) > ROLE_PRIORITY.get(existing.role, 0):
existing.role = rca.role
existing.save(update_fields=["role"])
rca.delete()
def _merge_files(self, stale_user, kept_user):
"""Re-assign files created by stale_user to kept_user."""
File.objects.filter(creator=stale_user).update(creator=kept_user)
@@ -1,19 +0,0 @@
# Generated by Django 5.2.14 on 2026-06-02 17:31
import django.db.models.functions.text
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('core', '0018_rename_active_application_is_active'),
]
operations = [
migrations.AddConstraint(
model_name='user',
constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('email'), condition=models.Q(('sub__isnull', True)), name='unique_email_when_sub_is_null'),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 5.2.14 on 2026-06-03 12:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0019_user_unique_email_when_sub_is_null'),
]
operations = [
migrations.AlterField(
model_name='file',
name='upload_state',
field=models.CharField(choices=[('pending', 'Pending'), ('analyzing', 'Analyzing'), ('ready', 'Ready')], max_length=25),
),
]
+1 -24
View File
@@ -211,13 +211,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
ordering = ("-created_at",)
verbose_name = _("user")
verbose_name_plural = _("users")
constraints = [
models.UniqueConstraint(
models.functions.Lower("email"),
condition=models.Q(sub__isnull=True),
name="unique_email_when_sub_is_null",
)
]
def __str__(self):
return self.email or self.admin_email or str(self.id)
@@ -846,8 +839,8 @@ class FileUploadStateChoices(models.TextChoices):
"""Possible states of a file."""
PENDING = "pending", _("Pending")
ANALYZING = "analyzing", _("Analyzing")
# 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",
@@ -954,16 +947,6 @@ class File(BaseModel):
return f"{settings.FILE_UPLOAD_PATH}/{self.pk!s}"
@property
def temporary_key_base(self):
"""Temporary key base used while upload is still pending."""
if not self.pk:
raise RuntimeError(
"The file instance must be saved before requesting a storage key."
)
return f"{settings.FILE_UPLOAD_TMP_PATH}/{self.pk!s}"
@property
def file_key(self):
"""Key used to store the file in object storage."""
@@ -972,12 +955,6 @@ class File(BaseModel):
# leaking Personal Information in logs, etc.
return f"{self.key_base}{extension!s}"
@property
def temporary_file_key(self):
"""Temporary key used to upload the file before it is finalized."""
_, extension = splitext(self.filename)
return f"{self.temporary_key_base}{extension!s}"
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the file.
@@ -0,0 +1,44 @@
"""Participant promotion service."""
from logging import getLogger
from core import models
logger = getLogger(__name__)
class PromotionException(Exception):
"""Base exception for promotion errors."""
class OwnerPromotionException(PromotionException):
"""Raised when attempting to promote an owner."""
class AlreadyAdminException(PromotionException):
"""Raised when attempting to promote a user who is already an admin."""
class ParticipantPromotionService:
"""Handles the DB side of promoting a participant to room admin."""
def promote_to_admin(self, room, user) -> None:
"""Promote a user to ADMIN role for the given room."""
access = models.ResourceAccess.objects.filter(resource=room, user=user).first()
if access and access.role == models.RoleChoices.ADMIN:
raise AlreadyAdminException(
f"User {user.pk} is already an admin of room {room.pk}"
)
if access and access.role == models.RoleChoices.OWNER:
raise OwnerPromotionException(
f"User {user.pk} is an owner of room {room.pk} and cannot be promoted"
)
models.ResourceAccess.objects.update_or_create(
resource=room,
user=user,
defaults={"role": models.RoleChoices.ADMIN},
)
@@ -1,110 +0,0 @@
"""Service for provisional user creation."""
import logging
from django.conf import settings
from django.core.exceptions import SuspiciousOperation, ValidationError
from django.db import IntegrityError
from core import models
logger = logging.getLogger(__name__)
class ProvisionalUserError(Exception):
"""Base exception for provisional user service errors."""
class ProvisionalUserCreationDisabledError(ProvisionalUserError):
"""Raised when provisional user creation is disabled by configuration."""
class ProvisionalUserIntegrityError(ProvisionalUserError):
"""Raised when a provisional user cannot be created or retrieved after a race condition."""
class ProvisionalUserService:
"""Handles creation and retrieval of provisional users.
A provisional user is created without a `sub`, identified by email only.
The `sub` is set on first successful OIDC authentication via Django LaSuite.
"""
def __init__(self):
"""Initialize the service."""
# `OIDC_USER_SUB_FIELD_IMMUTABLE` comes from Django LaSuite and prevents `sub`
# updates. We override its default value to allow setting `sub` for
# provisional users.
self._is_creation_enabled = (
settings.APPLICATION_ALLOW_USER_CREATION
and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
and not settings.OIDC_USER_SUB_FIELD_IMMUTABLE
)
def _get_by_email(self, email: str) -> models.User | None:
"""Return the user with this email, or None if not found."""
try:
return models.User.objects.get(email__iexact=email)
except models.User.DoesNotExist:
return None
except models.User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
"Multiple user accounts share a common email."
) from e
def get_or_create(
self, email: str, client_id: str
) -> tuple[models.User | None, bool]:
"""Get or create a provisional user identified by email.
Args:
email: The email address to identify the user.
client_id: The application client_id, used for audit logging only.
Returns:
A (user, created) tuple — mirrors get_or_create conventions.
Raises:
ProvisionalUserError: If creation and retrieval both fail.
"""
user = self._get_by_email(email)
if user:
return user, False
if not self._is_creation_enabled:
raise ProvisionalUserCreationDisabledError(
"Provisional user creation is disabled by configuration."
)
# Create a provisional user without `sub`, identified by email only.
# This relies on Django LaSuite implicitly updating the `sub` field on the
# user's first successful OIDC authentication. If this stops working,
# check for behavior changes in Django LaSuite.
try:
user = models.User(sub=None, email=email)
user.set_unusable_password()
user.save()
logger.info(
"Provisional user created via application: user_id=%s, email=%s, client_id=%s",
user.id,
email,
client_id,
)
return user, True
except (IntegrityError, ValidationError) as e:
logger.warning(
"Race condition on provisional user creation, fetching existing: "
"email=%s, client_id=%s",
email,
client_id,
)
user = self._get_by_email(email)
if user:
return user, False
raise ProvisionalUserIntegrityError(
"Failed to create or retrieve provisional user."
) from e
@@ -346,11 +346,8 @@ def test_authentication_getter_existing_user_change_fields(
# One and only one additional update query when a field has changed
# Note: .save() triggers uniqueness validation queries for unique fields,
# adding extra SELECT queries before the UPDATE:
# - unique=True on 'sub'
# - unique=True on 'admin_email'
# - partial unique index 'unique_email_when_sub_is_null'
with django_assert_num_queries(5):
# adding extra SELECT queries before the UPDATE (e.g., checking unique=True on 'sub')
with django_assert_num_queries(3):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -118,7 +118,7 @@ def test_api_files_create_file_authenticated_success():
assert policy_parsed.scheme == "http"
assert policy_parsed.netloc in ["minio:9000", "localhost:9000"]
assert policy_parsed.path == f"/meet-media-storage/tmp/files/{file.id!s}.png"
assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}.png"
query_params = parse_qs(policy_parsed.query)
@@ -1,7 +1,6 @@
"""Test related to item upload ended API."""
import logging
from concurrent.futures import ThreadPoolExecutor
from io import BytesIO
from django.core.files.storage import default_storage
@@ -82,7 +81,7 @@ def test_api_file_upload_ended_success(settings):
)
default_storage.save(
file.temporary_file_key,
file.file_key,
BytesIO(b"my prose"),
)
@@ -98,7 +97,6 @@ def test_api_file_upload_ended_success(settings):
assert response.json()["mimetype"] == "text/plain"
@pytest.mark.django_db(transaction=True)
def test_api_file_upload_ended_mimetype_not_allowed(settings, caplog):
"""
Test that the API returns a 400 when the mimetype is not allowed.
@@ -121,7 +119,7 @@ def test_api_file_upload_ended_mimetype_not_allowed(settings, caplog):
)
default_storage.save(
file.temporary_file_key,
file.file_key,
BytesIO(b"my prose"),
)
@@ -158,7 +156,7 @@ def test_api_file_upload_ended_mimetype_not_allowed_not_checking_mimetype(settin
)
default_storage.save(
file.temporary_file_key,
file.file_key,
BytesIO(b"my prose"),
)
@@ -202,7 +200,7 @@ def test_api_upload_ended_mismatch_mimetype_with_object_storage(settings, caplog
s3_client.put_object(
Bucket=default_storage.bucket_name,
Key=file.temporary_file_key,
Key=file.file_key,
ContentType="text/html",
Body=BytesIO(
b'<meta http-equiv="refresh" content="0; url=https://fichiers.numerique.gouv.fr">'
@@ -213,7 +211,7 @@ def test_api_upload_ended_mismatch_mimetype_with_object_storage(settings, caplog
)
head_object = s3_client.head_object(
Bucket=default_storage.bucket_name, Key=file.temporary_file_key
Bucket=default_storage.bucket_name, Key=file.file_key
)
assert head_object["ContentType"] == "text/html"
@@ -236,10 +234,9 @@ def test_api_upload_ended_mismatch_mimetype_with_object_storage(settings, caplog
assert head_object["Metadata"] == {"foo": "bar"}
@pytest.mark.django_db(transaction=True)
def test_api_upload_ended_file_size_exceeded(settings, caplog):
"""
Test when the file size exceeds the allowed max upload file size
Test when the file size exceed the allowed max upload file size
should return a 400 and delete the file.
"""
@@ -259,7 +256,7 @@ def test_api_upload_ended_file_size_exceeded(settings, caplog):
)
default_storage.save(
file.temporary_file_key,
file.file_key,
BytesIO(b"my prose"),
)
@@ -273,48 +270,3 @@ def test_api_upload_ended_file_size_exceeded(settings, caplog):
assert not models.File.objects.filter(id=file.id).exists()
assert not default_storage.exists(file.file_key)
@pytest.mark.django_db(transaction=True)
def test_api_file_upload_ended_concurrent_calls_are_serialized(settings):
"""Only one concurrent upload-ended call can finalize a pending upload."""
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"allowed_mimetypes": ["text/plain"],
},
}
user = factories.UserFactory()
file = factories.FileFactory(
type=FileTypeChoices.BACKGROUND_IMAGE,
filename="my_file.txt",
creator=user,
)
default_storage.save(file.temporary_file_key, BytesIO(b"my prose"))
def call_upload_ended():
client = APIClient()
client.force_login(user)
return client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(call_upload_ended),
executor.submit(call_upload_ended),
]
responses = [future.result() for future in futures]
status_codes = sorted(response.status_code for response in responses)
assert status_codes == [200, 400]
failed_response = next(
response for response in responses if response.status_code == 400
)
assert failed_response.json() == {
"file": "This action is only available for files in PENDING state."
}
file.refresh_from_db()
assert file.upload_state == FileUploadStateChoices.READY
@@ -1,460 +0,0 @@
"""Tests for the merge_duplicate_users management command."""
from unittest import mock
from django.core.management import base, call_command
import pytest
from core.factories import (
FileFactory,
UserFactory,
UserRecordingAccessFactory,
UserResourceAccessFactory,
)
from core.models import RecordingAccess, ResourceAccess, RoleChoices, User
pytestmark = pytest.mark.django_db
# pylint: disable=W0613
def test_merge_no_duplicates_does_nothing():
"""Command should do nothing when no duplicate users exist."""
user = UserFactory(email="unique@example.com")
call_command("merge_duplicate_users")
assert User.objects.count() == 1
assert User.objects.filter(id=user.id).exists()
def test_merge_keeps_most_recently_created_user():
"""Command should keep the most recently created user when duplicates exist."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
call_command("merge_duplicate_users")
assert not User.objects.filter(id=user1.id).exists()
assert User.objects.filter(id=user2.id).exists()
def test_merge_deletes_all_stale_users():
"""Command should delete all stale users and keep only the most recently created one."""
email = "many@example.com"
UserFactory(email=email)
UserFactory(email=email)
user_kept = UserFactory(email=email)
call_command("merge_duplicate_users")
assert User.objects.filter(email=email).count() == 1
assert User.objects.filter(id=user_kept.id).exists()
# ── ResourceAccess ─────────────────────────────────────────────────────────────
def test_merge_transfers_resource_access_to_kept_user():
"""ResourceAccess should be transferred to the kept user when stale user is merged."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
ra = UserResourceAccessFactory(user=user1)
call_command("merge_duplicate_users")
ra.refresh_from_db()
assert ra.user == user2
def test_merge_transfers_multiple_room_accesses():
"""All ResourceAccesses should be transferred to the kept user when stale user is merged."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
accesses = UserResourceAccessFactory.create_batch(3, user=user1)
call_command("merge_duplicate_users")
assert not ResourceAccess.objects.filter(user=user1).exists()
for ra in accesses:
assert ResourceAccess.objects.filter(user=user2, resource=ra.resource).exists()
def test_merge_all_resource_accesses_owned_by_kept_user_nothing_changes():
"""ResourceAccesses should remain unchanged when all are already owned by the kept user."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
accesses = UserResourceAccessFactory.create_batch(3, user=user2)
call_command("merge_duplicate_users")
assert not ResourceAccess.objects.filter(user=user1).exists()
for ra in accesses:
ra.refresh_from_db()
assert ra.user == user2
def test_merge_resource_access_conflict_upgrades_to_owner():
"""ResourceAccess role should be upgraded to owner when stale user has a higher role."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
ra1 = UserResourceAccessFactory(user=user1, role=RoleChoices.OWNER)
ra2 = UserResourceAccessFactory(
user=user2, resource=ra1.resource, role=RoleChoices.MEMBER
)
other_accesses = UserResourceAccessFactory.create_batch(
3, user=user1, role=RoleChoices.MEMBER
)
call_command("merge_duplicate_users")
ra2.refresh_from_db()
assert ra2.role == RoleChoices.OWNER
assert not ResourceAccess.objects.filter(user=user1).exists()
for ra in other_accesses:
assert ResourceAccess.objects.filter(
user=user2, resource=ra.resource, role=RoleChoices.MEMBER
).exists()
def test_merge_resource_access_conflict_upgrades_to_admin():
"""ResourceAccess role should be upgraded to admin when stale user has a higher role."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
ra1 = UserResourceAccessFactory(user=user1, role=RoleChoices.ADMIN)
ra2 = UserResourceAccessFactory(
user=user2, resource=ra1.resource, role=RoleChoices.MEMBER
)
other_accesses = UserResourceAccessFactory.create_batch(
3, user=user1, role=RoleChoices.MEMBER
)
call_command("merge_duplicate_users")
ra2.refresh_from_db()
assert ra2.role == RoleChoices.ADMIN
assert not ResourceAccess.objects.filter(user=user1).exists()
for ra in other_accesses:
assert ResourceAccess.objects.filter(
user=user2, resource=ra.resource, role=RoleChoices.MEMBER
).exists()
def test_merge_resource_access_conflict_does_not_downgrade_role():
"""ResourceAccess role should not be downgraded when stale user has a lower role."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
ra1 = UserResourceAccessFactory(user=user1, role=RoleChoices.MEMBER)
ra2 = UserResourceAccessFactory(
user=user2, resource=ra1.resource, role=RoleChoices.OWNER
)
other_accesses = UserResourceAccessFactory.create_batch(
3, user=user1, role=RoleChoices.MEMBER
)
call_command("merge_duplicate_users")
ra2.refresh_from_db()
assert ra2.role == RoleChoices.OWNER
assert not ResourceAccess.objects.filter(user=user1).exists()
for ra in other_accesses:
assert ResourceAccess.objects.filter(
user=user2, resource=ra.resource, role=RoleChoices.MEMBER
).exists()
def test_merge_resource_access_conflict_equal_role_keeps_single_access():
"""ResourceAccess should keep one entry for the kept user when both ones have the same role."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
ra1 = UserResourceAccessFactory(user=user1, role=RoleChoices.MEMBER)
UserResourceAccessFactory(
user=user2, resource=ra1.resource, role=RoleChoices.MEMBER
)
call_command("merge_duplicate_users")
accesses = ResourceAccess.objects.filter(resource=ra1.resource)
assert accesses.count() == 1
assert accesses.first().user == user2
assert accesses.first().role == RoleChoices.MEMBER
# ── RecordingAccess ────────────────────────────────────────────────────────────
def test_merge_transfers_recording_access_to_kept_user():
"""RecordingAccess should be transferred to the kept user when stale user is merged."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
rca = UserRecordingAccessFactory(user=user1)
call_command("merge_duplicate_users")
rca.refresh_from_db()
assert rca.user == user2
def test_merge_transfers_multiple_recording_accesses():
"""All RecordingAccesses should be transferred to the kept user when stale user is merged."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
accesses = UserRecordingAccessFactory.create_batch(3, user=user1)
call_command("merge_duplicate_users")
assert not RecordingAccess.objects.filter(user=user1).exists()
for rca in accesses:
assert RecordingAccess.objects.filter(
user=user2, recording=rca.recording
).exists()
def test_merge_all_recording_accesses_owned_by_kept_user_nothing_changes():
"""RecordingAccesses should remain unchanged when all are already owned by the kept user."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
accesses = UserRecordingAccessFactory.create_batch(3, user=user2)
call_command("merge_duplicate_users")
assert not RecordingAccess.objects.filter(user=user1).exists()
for rca in accesses:
rca.refresh_from_db()
assert rca.user == user2
def test_merge_recording_access_conflict_upgrades_to_owner():
"""RecordingAccess role should be upgraded to owner when stale user has a higher role."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
rca1 = UserRecordingAccessFactory(user=user1, role=RoleChoices.OWNER)
rca2 = UserRecordingAccessFactory(
user=user2, recording=rca1.recording, role=RoleChoices.MEMBER
)
other_accesses = UserRecordingAccessFactory.create_batch(
3, user=user1, role=RoleChoices.MEMBER
)
call_command("merge_duplicate_users")
rca2.refresh_from_db()
assert rca2.role == RoleChoices.OWNER
assert not RecordingAccess.objects.filter(user=user1).exists()
for rca in other_accesses:
assert RecordingAccess.objects.filter(
user=user2, recording=rca.recording, role=RoleChoices.MEMBER
).exists()
def test_merge_recording_access_conflict_upgrades_to_admin():
"""RecordingAccess role should be upgraded to admin when stale user has a higher role."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
rca1 = UserRecordingAccessFactory(user=user1, role=RoleChoices.ADMIN)
rca2 = UserRecordingAccessFactory(
user=user2, recording=rca1.recording, role=RoleChoices.MEMBER
)
other_accesses = UserRecordingAccessFactory.create_batch(
3, user=user1, role=RoleChoices.MEMBER
)
call_command("merge_duplicate_users")
rca2.refresh_from_db()
assert rca2.role == RoleChoices.ADMIN
assert not RecordingAccess.objects.filter(user=user1).exists()
for rca in other_accesses:
assert RecordingAccess.objects.filter(
user=user2, recording=rca.recording, role=RoleChoices.MEMBER
).exists()
def test_merge_recording_access_conflict_does_not_downgrade_role():
"""RecordingAccess role should not be downgraded when stale user has a lower role."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
rca1 = UserRecordingAccessFactory(user=user1, role=RoleChoices.MEMBER)
rca2 = UserRecordingAccessFactory(
user=user2, recording=rca1.recording, role=RoleChoices.OWNER
)
other_accesses = UserRecordingAccessFactory.create_batch(
3, user=user1, role=RoleChoices.MEMBER
)
call_command("merge_duplicate_users")
rca2.refresh_from_db()
assert rca2.role == RoleChoices.OWNER
assert not RecordingAccess.objects.filter(user=user1).exists()
for rca in other_accesses:
assert RecordingAccess.objects.filter(
user=user2, recording=rca.recording, role=RoleChoices.MEMBER
).exists()
def test_merge_recording_access_conflict_equal_role_keeps_single_access():
"""RecordingAccess should keep one entry for the user when both users have the same role."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
rca1 = UserRecordingAccessFactory(user=user1, role=RoleChoices.MEMBER)
UserRecordingAccessFactory(
user=user2, recording=rca1.recording, role=RoleChoices.MEMBER
)
call_command("merge_duplicate_users")
accesses = RecordingAccess.objects.filter(recording=rca1.recording)
assert accesses.count() == 1
assert accesses.first().user == user2
assert accesses.first().role == RoleChoices.MEMBER
# ── Files ──────────────────────────────────────────────────────────────────────
def test_merge_reassigns_files_to_kept_user():
"""Files should be reassigned to the kept user when stale user is merged."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
files = FileFactory.create_batch(3, creator=user1)
call_command("merge_duplicate_users")
for f in files:
f.refresh_from_db()
assert f.creator == user2
def test_merge_kept_user_own_files_untouched():
"""Files already owned by the kept user should remain unchanged after merge."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
FileFactory(creator=user1)
kept_file = FileFactory(creator=user2)
call_command("merge_duplicate_users")
kept_file.refresh_from_db()
assert kept_file.creator == user2
# ── Dry-run ────────────────────────────────────────────────────────────────────
def test_merge_dry_run_does_not_delete_users():
"""Command should not delete any users when dry-run is enabled."""
UserFactory(email="dup@example.com")
UserFactory(email="dup@example.com")
call_command("merge_duplicate_users", dry_run=True)
assert User.objects.filter(email="dup@example.com").count() == 2
def test_merge_dry_run_does_not_move_resource_access():
"""Command should not move resource accesses when dry-run is enabled."""
user1 = UserFactory(email="dup@example.com")
UserFactory(email="dup@example.com")
ra = UserResourceAccessFactory(user=user1)
call_command("merge_duplicate_users", dry_run=True)
ra.refresh_from_db()
assert ra.user == user1
def test_merge_dry_run_does_not_move_recording_access():
"""Command should not move recording accesses when dry-run is enabled."""
user1 = UserFactory(email="dup@example.com")
UserFactory(email="dup@example.com")
ra = UserRecordingAccessFactory(user=user1)
call_command("merge_duplicate_users", dry_run=True)
ra.refresh_from_db()
assert ra.user == user1
def test_merge_dry_run_does_not_move_files():
"""Command should not reassign files when dry-run is enabled."""
user1 = UserFactory(email="dup@example.com")
UserFactory(email="dup@example.com")
f = FileFactory(creator=user1)
call_command("merge_duplicate_users", dry_run=True)
f.refresh_from_db()
assert f.creator == user1
# ── Isolation ──────────────────────────────────────────────────────────────────
def test_merge_non_duplicate_users_untouched():
"""Non-duplicate users should remain untouched when other duplicates are merged."""
unique = UserFactory(email="unique@example.com")
UserFactory(email="dup@example.com")
UserFactory(email="dup@example.com")
call_command("merge_duplicate_users")
assert User.objects.filter(id=unique.id).exists()
def test_merge_non_duplicate_resource_access_untouched():
"""ResourceAccess of non-duplicate users should remain untouched."""
unique = UserFactory(email="unique@example.com")
ra = UserResourceAccessFactory(user=unique)
UserFactory(email="dup@example.com")
UserFactory(email="dup@example.com")
call_command("merge_duplicate_users")
ra.refresh_from_db()
assert ra.user == unique
def test_merge_multiple_email_groups_all_merged():
"""Command should merge all duplicate email groups in a single run."""
for i in range(3):
UserFactory(email=f"group{i}@example.com")
UserFactory(email=f"group{i}@example.com")
call_command("merge_duplicate_users")
for i in range(3):
assert User.objects.filter(email=f"group{i}@example.com").count() == 1
assert User.objects.count() == 3
# ── NULL / blank email guard ───────────────────────────────────────────────────
def test_merge_does_not_merge_users_with_null_email():
"""Users with NULL email must never be merged together, even if multiple exist."""
user1 = UserFactory(email=None)
user2 = UserFactory(email=None)
call_command("merge_duplicate_users")
assert User.objects.filter(id=user1.id).exists()
assert User.objects.filter(id=user2.id).exists()
def test_merge_does_not_merge_users_with_blank_email():
"""Users with empty-string email must never be merged together, even if multiple exist."""
user1 = UserFactory(email="")
user2 = UserFactory(email="")
call_command("merge_duplicate_users")
assert User.objects.filter(id=user1.id).exists()
assert User.objects.filter(id=user2.id).exists()
# ── Atomicity ──────────────────────────────────────────────────────────────────
@mock.patch(
"core.management.commands.merge_duplicate_users.Command._merge_recording_accesses",
side_effect=Exception("forced failure"),
)
def test_merge_is_atomic_rolls_back_all_on_any_failure(mock_reassign_files):
"""Merge should be fully rolled back when any step fails."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
resource_accesses = UserResourceAccessFactory.create_batch(3, user=user1)
recording_accesses = UserRecordingAccessFactory.create_batch(3, user=user1)
files = FileFactory.create_batch(3, creator=user1)
with pytest.raises(base.CommandError):
call_command("merge_duplicate_users")
assert User.objects.filter(id=user1.id).exists()
assert User.objects.filter(id=user2.id).exists()
for ra in resource_accesses:
ra.refresh_from_db()
assert ra.user == user1
for rca in recording_accesses:
rca.refresh_from_db()
assert rca.user == user1
for f in files:
f.refresh_from_db()
assert f.creator == user1
# ── Email filter ───────────────────────────────────────────────────────────────
def test_merge_email_filter_only_merges_matching_emails():
"""Command should only merge users whose email matches the filter."""
UserFactory(email="user1@example.com")
UserFactory(email="user1@example.com")
other1 = UserFactory(email="user1@other.com")
other2 = UserFactory(email="user1@other.com")
call_command("merge_duplicate_users", email_filter="@example.com")
assert User.objects.filter(email="user1@example.com").count() == 1
assert User.objects.filter(id=other1.id).exists()
assert User.objects.filter(id=other2.id).exists()
def test_merge_email_filter_no_match_does_nothing():
"""Command should do nothing when the email filter matches no users."""
UserFactory(email="user1@example.com")
UserFactory(email="user1@example.com")
call_command("merge_duplicate_users", email_filter="@nomatch.com")
assert User.objects.filter(email="user1@example.com").count() == 2
def test_merge_email_filter_is_case_insensitive():
"""Command should match emails case-insensitively when filtering."""
UserFactory(email="user1@Example.com")
UserFactory(email="user1@Example.com")
call_command("merge_duplicate_users", email_filter="@example.com")
assert User.objects.filter(email="user1@Example.com").count() == 1
@@ -0,0 +1,612 @@
"""
Test rooms API endpoints: promote participant.
"""
# pylint: disable=redefined-outer-name,unused-argument,protected-access
import random
from unittest import mock
from uuid import uuid4
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import models
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
from core.services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagementException,
)
pytestmark = pytest.mark.django_db
# ---
# success cases
# ---
@mock.patch("core.api.viewsets.ParticipantsManagement")
def test_promote_participant_success_new_access(mock_participants_management_cls):
"""Should create a new ResourceAccess with ADMIN role and update LiveKit."""
mock_instance = mock.MagicMock()
mock_participants_management_cls.return_value = mock_instance
room = RoomFactory()
admin_user = UserFactory()
UserResourceAccessFactory(
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
)
participant_user = UserFactory(sub=uuid4())
client = APIClient()
client.force_authenticate(user=admin_user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/promote-participant/",
{"participant_identity": str(participant_user.sub)},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_instance.check_if_in_meeting.assert_called_once_with(
room.pk, identity=participant_user.sub
)
assert models.ResourceAccess.objects.filter(
resource=room,
user=participant_user,
role=models.RoleChoices.ADMIN,
).exists()
mock_instance.update.assert_called_once_with(
room_name=str(room.pk),
identity=str(participant_user.sub),
attributes={"room_admin": "true"},
)
@mock.patch("core.api.viewsets.ParticipantsManagement")
def test_promote_participant_success_upgrades_member_to_admin(
mock_participants_management_cls,
):
"""Should upgrade an existing member to ADMIN role."""
mock_instance = mock.MagicMock()
mock_participants_management_cls.return_value = mock_instance
room = RoomFactory()
admin_user = UserFactory()
UserResourceAccessFactory(
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
)
participant_user = UserFactory(sub=uuid4())
UserResourceAccessFactory(resource=room, user=participant_user, role="member")
client = APIClient()
client.force_authenticate(user=admin_user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/promote-participant/",
{"participant_identity": str(participant_user.sub)},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_instance.check_if_in_meeting.assert_called_once_with(
room.pk, identity=str(participant_user.sub)
)
access = models.ResourceAccess.objects.get(resource=room, user=participant_user)
assert access.role == models.RoleChoices.ADMIN
@mock.patch("core.api.viewsets.ParticipantsManagement")
def test_promote_participant_success_already_admin(mock_participants_management_cls):
"""Should succeed idempotently when the participant is already an admin."""
mock_instance = mock.MagicMock()
mock_participants_management_cls.return_value = mock_instance
room = RoomFactory()
admin_user = UserFactory()
UserResourceAccessFactory(
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
)
participant_user = UserFactory(sub=uuid4())
UserResourceAccessFactory(
resource=room, user=participant_user, role="administrator"
)
client = APIClient()
client.force_authenticate(user=admin_user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/promote-participant/",
{"participant_identity": str(participant_user.sub)},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_instance.check_if_in_meeting.assert_called_once_with(
room.pk, identity=str(participant_user.sub)
)
access = models.ResourceAccess.objects.get(resource=room, user=participant_user)
assert access.role == models.RoleChoices.ADMIN
mock_instance.update.assert_not_called()
# ---
# permission / auth
# ---
def test_promote_participant_forbidden_without_authentication():
"""Should return 401 when the request is unauthenticated."""
room = RoomFactory()
response = APIClient().post(
f"/api/v1.0/rooms/{room.id}/promote-participant/",
{"participant_identity": str(uuid4())},
format="json",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_promote_participant_forbidden_for_member():
"""Should return 403 when the requester only has member-level access."""
room = RoomFactory()
member = UserFactory()
UserResourceAccessFactory(resource=room, user=member, role="member")
client = APIClient()
client.force_authenticate(user=member)
response = client.post(
f"/api/v1.0/rooms/{room.id}/promote-participant/",
{"participant_identity": str(uuid4())},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_promote_participant_forbidden_for_unrelated_user():
"""Should return 403 when the requester has no access to the room."""
room = RoomFactory()
unrelated_user = UserFactory()
client = APIClient()
client.force_authenticate(user=unrelated_user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/promote-participant/",
{"participant_identity": str(uuid4())},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_promote_participant_forbidden_for_admin_on_another_room():
"""Should return 403 when the requester is admin on a different room, not the target one."""
target_room = RoomFactory()
other_room = RoomFactory()
admin_other_room = UserFactory()
UserResourceAccessFactory(
resource=other_room,
user=admin_other_room,
role=random.choice(["administrator", "owner"]),
)
client = APIClient()
client.force_authenticate(user=admin_other_room)
response = client.post(
f"/api/v1.0/rooms/{target_room.id}/promote-participant/",
{"participant_identity": str(uuid4())},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
# ---
# self-promotion
# ---
@mock.patch("core.api.viewsets.ParticipantsManagement")
def test_promote_participant_forbidden_self_promotion(mock_participants_management_cls):
"""Should return 403 when the requester attempts to promote themselves."""
mock_participants_management_cls.return_value = mock.MagicMock()
room = RoomFactory()
admin_user = UserFactory(sub=uuid4())
UserResourceAccessFactory(
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
)
client = APIClient()
client.force_authenticate(user=admin_user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/promote-participant/",
{"participant_identity": str(admin_user.sub)},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {"error": "You cannot promote yourself"}
mock_participants_management_cls.check_if_in_meeting.assert_not_called()
# ---
# presence check
# ---
@mock.patch("core.api.viewsets.ParticipantsManagement")
def test_promote_participant_forbidden_when_participant_not_in_meeting(
mock_participants_management_cls,
):
"""Should return 403 when check_if_in_meeting returns False."""
mock_instance = mock.MagicMock()
mock_instance.check_if_in_meeting.return_value = False
mock_participants_management_cls.return_value = mock_instance
room = RoomFactory()
admin_user = UserFactory()
UserResourceAccessFactory(
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
)
participant_user = UserFactory(sub=uuid4())
client = APIClient()
client.force_authenticate(user=admin_user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/promote-participant/",
{"participant_identity": str(participant_user.sub)},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {"error": "Could not verify participant presence"}
mock_instance.update.assert_not_called()
assert not models.ResourceAccess.objects.filter(
resource=room, user=participant_user
).exists()
@mock.patch("core.api.viewsets.ParticipantsManagement")
def test_promote_participant_forbidden_when_presence_check_fails(
mock_participants_management_cls,
):
"""Should return 403 when the participant is not found in the meeting."""
mock_instance = mock.MagicMock()
mock_instance.check_if_in_meeting.side_effect = ParticipantNotFoundException()
mock_participants_management_cls.return_value = mock_instance
room = RoomFactory()
admin_user = UserFactory()
UserResourceAccessFactory(
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
)
participant_user = UserFactory(sub=uuid4())
client = APIClient()
client.force_authenticate(user=admin_user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/promote-participant/",
{"participant_identity": str(participant_user.sub)},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {"error": "Could not verify participant presence"}
mock_instance.update.assert_not_called()
assert not models.ResourceAccess.objects.filter(
resource=room, user=participant_user
).exists()
@mock.patch("core.api.viewsets.ParticipantsManagement")
def test_promote_participant_forbidden_when_presence_management_exception(
mock_participants_management_cls,
):
"""Should return 403 when the presence check raises a management exception."""
mock_instance = mock.MagicMock()
mock_instance.check_if_in_meeting.side_effect = ParticipantsManagementException()
mock_participants_management_cls.return_value = mock_instance
room = RoomFactory()
admin_user = UserFactory()
UserResourceAccessFactory(
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
)
participant_user = UserFactory(sub=uuid4())
client = APIClient()
client.force_authenticate(user=admin_user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/promote-participant/",
{"participant_identity": str(participant_user.sub)},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {"error": "Could not verify participant presence"}
mock_instance.update.assert_not_called()
assert not models.ResourceAccess.objects.filter(
resource=room, user=participant_user
).exists()
# ---
# user resolution
# ---
@mock.patch("core.api.viewsets.ParticipantsManagement")
def test_promote_participant_not_found_when_user_never_logged_in(
mock_participants_management_cls,
):
"""Should return 404 when the identity has no matching db user."""
mock_instance = mock.MagicMock()
mock_participants_management_cls.return_value = mock_instance
room = RoomFactory()
admin_user = UserFactory()
UserResourceAccessFactory(
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
)
unknown_sub = uuid4()
client = APIClient()
client.force_authenticate(user=admin_user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/promote-participant/",
{"participant_identity": str(unknown_sub)},
format="json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.data == {"error": "Participant not found"}
mock_instance.update.assert_not_called()
assert models.ResourceAccess.objects.filter(resource=room).count() == 1
assert models.ResourceAccess.objects.filter(resource=room, user=admin_user).exists()
# ---
# inactive user
# ---
@mock.patch("core.api.viewsets.ParticipantsManagement")
def test_promote_participant_forbidden_when_user_is_inactive(
mock_participants_management_cls,
):
"""Should return 403 when the target participant account is inactive."""
mock_instance = mock.MagicMock()
mock_participants_management_cls.return_value = mock_instance
room = RoomFactory()
admin_user = UserFactory()
UserResourceAccessFactory(
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
)
participant_user = UserFactory(sub=uuid4(), is_active=False)
client = APIClient()
client.force_authenticate(user=admin_user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/promote-participant/",
{"participant_identity": str(participant_user.sub)},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {
"error": "This participant account is inactive and cannot be promoted"
}
mock_instance.update.assert_not_called()
assert not models.ResourceAccess.objects.filter(
resource=room, user=participant_user
).exists()
# ---
# owner protection
# ---
@mock.patch("core.api.viewsets.ParticipantsManagement")
def test_promote_participant_forbidden_when_target_is_owner(
mock_participants_management_cls,
):
"""Should return 403 when trying to promote a participant who is already an owner."""
mock_instance = mock.MagicMock()
mock_participants_management_cls.return_value = mock_instance
room = RoomFactory()
admin_user = UserFactory()
UserResourceAccessFactory(
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
)
participant_user = UserFactory(sub=uuid4())
UserResourceAccessFactory(resource=room, user=participant_user, role="owner")
client = APIClient()
client.force_authenticate(user=admin_user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/promote-participant/",
{"participant_identity": str(participant_user.sub)},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {
"error": "Owners already have the highest privileges and cannot be promoted"
}
mock_instance.update.assert_not_called()
assert models.ResourceAccess.objects.filter(
resource=room,
user=participant_user,
role=models.RoleChoices.OWNER,
).exists()
# ---
# LiveKit update failures
# ---
@mock.patch("core.api.viewsets.ParticipantsManagement")
def test_promote_participant_partial_success_when_livekit_participant_missing(
mock_participants_management_cls,
):
"""Should return 200 with warning when participant left during the promotion.
The DB resource access is kept — they will have admin privileges on rejoin.
"""
mock_instance = mock.MagicMock()
mock_instance.update.side_effect = ParticipantNotFoundException()
mock_participants_management_cls.return_value = mock_instance
room = RoomFactory()
admin_user = UserFactory()
UserResourceAccessFactory(
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
)
participant_user = UserFactory(sub=uuid4())
client = APIClient()
client.force_authenticate(user=admin_user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/promote-participant/",
{"participant_identity": str(participant_user.sub)},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {
"status": "success",
"warning": "LiveKit update failed; role update persisted",
}
assert models.ResourceAccess.objects.filter(
resource=room,
user=participant_user,
role=models.RoleChoices.ADMIN,
).exists()
@mock.patch("core.api.viewsets.ParticipantsManagement")
def test_promote_participant_partial_success_on_livekit_management_exception(
mock_participants_management_cls,
):
"""Should return 200 with warning when LiveKit update fails but DB transaction succeeded."""
mock_instance = mock.MagicMock()
mock_instance.update.side_effect = ParticipantsManagementException()
mock_participants_management_cls.return_value = mock_instance
room = RoomFactory()
admin_user = UserFactory()
UserResourceAccessFactory(
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
)
participant_user = UserFactory(sub=uuid4())
client = APIClient()
client.force_authenticate(user=admin_user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/promote-participant/",
{"participant_identity": str(participant_user.sub)},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {
"status": "success",
"warning": "LiveKit update failed; role update persisted",
}
assert models.ResourceAccess.objects.filter(
resource=room,
user=participant_user,
role=models.RoleChoices.ADMIN,
).exists()
# ---
# payload validation
# ---
@pytest.mark.parametrize(
"payload",
[
{"participant_identity": "not-a-uuid"},
{"participant_identity": ""},
{"participant_identity": " "},
{"participant_identity": None},
{},
],
)
def test_promote_participant_invalid_payload(payload):
"""Should return 400 for invalid, empty, whitespace, null, or missing participant_identity."""
room = RoomFactory()
admin_user = UserFactory()
UserResourceAccessFactory(
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
)
client = APIClient()
client.force_authenticate(user=admin_user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/promote-participant/",
payload,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
# ---
# room not found
# ---
def test_promote_participant_room_not_found():
"""Should return 404 when the room does not exist."""
user = UserFactory()
client = APIClient()
client.force_authenticate(user=user)
response = client.post(
f"/api/v1.0/rooms/{uuid4()}/promote-participant/",
{"participant_identity": str(uuid4())},
format="json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -4,8 +4,6 @@ Tests for external API /token endpoint
# pylint: disable=W0621
from unittest import mock
import jwt
import pytest
from freezegun import freeze_time
@@ -17,7 +15,6 @@ from core.factories import (
UserFactory,
)
from core.models import ApplicationScope, User
from core.services import provisional_user_service
pytestmark = pytest.mark.django_db
@@ -439,88 +436,3 @@ def test_api_applications_token_existing_user(settings):
"delegated": True,
"scope": "rooms:list rooms:create",
}
@mock.patch.object(provisional_user_service.ProvisionalUserService, "_get_by_email")
def test_api_applications_token_new_user_race_condition(mock_get_by_email, settings):
"""Should handle race condition where two concurrent requests create the same user."""
settings.APPLICATION_ALLOW_USER_CREATION = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
application = ApplicationFactory(
is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
email = "john.doe@example.com"
# First call: lie and say user doesn't exist, simulating the race window
# Second call (recovery path): return the real user
existing_user = UserFactory(sub=None, email=email)
mock_get_by_email.side_effect = [None, existing_user]
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": email,
},
format="json",
)
assert response.status_code == 200
assert mock_get_by_email.call_count == 2
token = response.data["access_token"]
payload = jwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
assert payload["user_id"] == str(existing_user.id)
assert User.objects.filter(email=email).count() == 1
@mock.patch.object(
provisional_user_service.ProvisionalUserService,
"get_or_create",
side_effect=provisional_user_service.ProvisionalUserIntegrityError,
)
def test_api_applications_token_new_user_race_condition_unrecoverable(
mock_get_or_create, settings
):
"""Should return 500 when ProvisionalUserIntegrityError is raised."""
settings.APPLICATION_ALLOW_USER_CREATION = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
application = ApplicationFactory(
is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "john.doe@example.com",
},
format="json",
)
assert response.status_code == 409
assert mock_get_or_create.call_count == 1
@@ -44,73 +44,3 @@ def test_models_users_send_mail_main_missing():
user.email_user("my subject", "my message")
assert str(excinfo.value) == "User has no email address."
def test_models_users_email_unique_when_sub_is_null():
"""Email should be unique among users with no sub (pending users)."""
user = factories.UserFactory(sub=None, email="test@example.com")
with pytest.raises(
ValidationError, match="Constraint “unique_email_when_sub_is_null” is violated."
):
factories.UserFactory(sub=None, email=user.email)
def test_models_users_email_unique_case_insensitive_when_sub_is_null():
"""Email uniqueness should be case-insensitive among users with no sub (pending users)."""
factories.UserFactory(sub=None, email="Test@example.com")
with pytest.raises(
ValidationError, match="Constraint “unique_email_when_sub_is_null” is violated."
):
factories.UserFactory(sub=None, email="test@example.com")
def test_models_users_email_not_unique_when_sub_is_set():
"""Email uniqueness should not be enforced when users have a sub."""
user = factories.UserFactory(sub="sub-1", email="test@example.com")
user2 = factories.UserFactory(sub="sub-2", email=user.email)
assert user2.email == user.email
def test_models_users_email_not_unique_between_sub_null_and_sub_set():
"""A user with a sub and a pending user (sub=None) can share the same email."""
user = factories.UserFactory(sub="sub-1", email="test@example.com")
user2 = factories.UserFactory(sub=None, email=user.email)
assert user2.email == user.email
def test_models_users_email_unique_constraint_allows_multiple_null_emails():
"""Multiple users with sub=None and email=None should be allowed."""
factories.UserFactory(sub=None, email=None)
factories.UserFactory(sub=None, email=None)
def test_models_users_sub_null_email_null_does_not_prevent_creation():
"""Multiple pending users (sub=None, email=None) can be created without conflict.
sub=None is not unique-constrained. email uniqueness is only enforced among
sub=None users with a non-null email, so email=None bypasses it (NULL != NULL in SQL).
"""
# Ghost row can still appear from bad code path
u1 = factories.UserFactory(sub=None, email=None)
u2 = factories.UserFactory(sub=None, email=None)
assert u1.pk != u2.pk
def test_models_users_sub_can_be_null():
"""sub is nullable: pending users exist before OIDC activation."""
user = factories.UserFactory(sub=None)
user.refresh_from_db()
assert user.sub is None
def test_models_users_sub_null_does_not_prevent_creation():
"""Multiple users can be created with sub=None (pending state)."""
u1 = factories.UserFactory(sub=None)
u2 = factories.UserFactory(sub=None)
assert u1.pk != u2.pk
def test_models_users_sub_blank_is_accepted():
"""sub='' passes validation because blank=True; null is preferred but not enforced."""
user = factories.UserFactory.build(sub="")
user.full_clean()
+1 -1
View File
@@ -426,7 +426,7 @@ def generate_upload_policy(file):
Originally taken from https://github.com/suitenumerique/drive/blob/564822d31f071c6dfacd112ef4b7146c73077cd9/src/backend/core/api/utils.py#L102 # pylint: disable=line-too-long
"""
key = file.temporary_file_key
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
-46
View File
@@ -186,9 +186,6 @@ class Base(Configuration):
FILE_UPLOAD_PATH = values.Value(
"files", environ_name="FILE_UPLOAD_PATH", environ_prefix=None
)
FILE_UPLOAD_TMP_PATH = values.Value(
"tmp/files", environ_name="FILE_UPLOAD_TMP_PATH", environ_prefix=None
)
FILE_UPLOAD_APPLY_RESTRICTIONS = values.BooleanValue(
default=True, environ_name="FILE_UPLOAD_APPLY_RESTRICTIONS", environ_prefix=None
@@ -1014,44 +1011,6 @@ class Base(Configuration):
environ_prefix=None,
)
# Logging
# We want to make it easy to log to console but by default we log production
# to Sentry and don't want to log to console.
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"simple": {
"format": "{asctime} {name} {levelname} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "simple",
},
},
# Override root logger to send it to console
"root": {
"handlers": ["console"],
"level": values.Value(
"INFO", environ_name="LOGGING_LEVEL_LOGGERS_ROOT", environ_prefix=None
),
},
"loggers": {
"core": {
"handlers": ["console"],
"level": values.Value(
"INFO",
environ_name="LOGGING_LEVEL_LOGGERS_APP",
environ_prefix=None,
),
"propagate": False,
},
},
}
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -1090,11 +1049,6 @@ class Base(Configuration):
"""
super().post_setup()
if cls.FILE_UPLOAD_TMP_PATH == cls.FILE_UPLOAD_PATH:
raise ValueError(
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
)
# The SENTRY_DSN setting should be available to activate sentry for an environment
if cls.SENTRY_DSN is not None:
sentry_sdk.init(
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.18.0"
version = "1.17.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+1 -1
View File
@@ -1173,7 +1173,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.18.0"
version = "1.17.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.18.0",
"version": "1.17.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.18.0",
"version": "1.17.0",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.18.0",
"version": "1.17.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -1,23 +1,20 @@
import { Button } from '@/primitives'
import { useTranslation } from 'react-i18next'
import type { Participant } from 'livekit-client'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useMuteParticipants } from '@/features/rooms/api/muteParticipants'
import { RiMicOffLine } from '@remixicon/react'
import { css } from '@/styled-system/css'
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
type MuteEveryoneButtonProps = {
participants: Array<Participant>
}
export const MuteEveryoneButton = ({
participants,
}: MuteEveryoneButtonProps) => {
const MuteEveryoneButtonInner = ({ participants }: MuteEveryoneButtonProps) => {
const { muteParticipants } = useMuteParticipants()
const { t } = useTranslation('rooms')
const isAdminOrOwner = useIsAdminOrOwner()
if (!isAdminOrOwner || !participants.length) return null
if (!participants.length) return null
return (
<Button
@@ -36,3 +33,13 @@ export const MuteEveryoneButton = ({
</Button>
)
}
export const MuteEveryoneButton = ({
participants,
}: MuteEveryoneButtonProps) => {
return (
<AdminOrOwnerOnly>
<MuteEveryoneButtonInner participants={participants} />
</AdminOrOwnerOnly>
)
}
+1 -1
View File
@@ -1,4 +1,4 @@
apiVersion: v2
type: application
name: meet
version: 0.0.22
version: 0.0.21
@@ -1,126 +0,0 @@
{{- if .Values.backend.mergeDuplicateUsers.enabled }}
{{- $envVars := include "meet.common.env" (list . .Values.backend) -}}
{{- $fullName := include "meet.backend.fullname" . -}}
{{- $component := "backend-merge-duplicate-users" -}}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ $fullName }}-merge-duplicate-users
namespace: {{ .Release.Namespace | quote }}
annotations:
argocd.argoproj.io/sync-options: Replace=true,Force=true
{{- with .Values.backend.mergeDuplicateUsers.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
labels:
{{- include "meet.common.labels" (list . $component) | nindent 4 }}
spec:
ttlSecondsAfterFinished: {{ .Values.backend.jobs.ttlSecondsAfterFinished }}
backoffLimit: {{ .Values.backend.jobs.backoffLimit }}
template:
metadata:
annotations:
{{- with .Values.backend.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "meet.common.selectorLabels" (list . $component) | nindent 8 }}
spec:
{{- if $.Values.image.credentials }}
imagePullSecrets:
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
{{- end }}
shareProcessNamespace: {{ .Values.backend.shareProcessNamespace }}
{{- with .Values.backend.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
{{- with .Values.backend.sidecars }}
{{- toYaml . | nindent 8 }}
{{- end }}
- name: {{ .Chart.Name }}
image: "{{ (.Values.backend.image | default dict).repository | default .Values.image.repository }}:{{ (.Values.backend.image | default dict).tag | default .Values.image.tag }}"
imagePullPolicy: {{ (.Values.backend.image | default dict).pullPolicy | default .Values.image.pullPolicy }}
{{- with .Values.backend.mergeDuplicateUsers.command }}
command:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.backend.args }}
args:
{{- toYaml . | nindent 12 }}
{{- end }}
env:
{{- if $envVars }}
{{- $envVars | indent 12 }}
{{- end }}
{{- with .Values.backend.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.backend.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
volumeMounts:
{{- range $index, $value := .Values.mountFiles }}
- name: "files-{{ $index }}"
mountPath: {{ $value.path }}
subPath: content
{{- end }}
{{- range $name, $volume := .Values.backend.persistence }}
- name: "{{ $name }}"
mountPath: "{{ $volume.mountPath }}"
{{- end }}
{{- range .Values.backend.extraVolumeMounts }}
- name: {{ .name }}
mountPath: {{ .mountPath }}
subPath: {{ .subPath | default "" }}
readOnly: {{ .readOnly }}
{{- end }}
{{- with .Values.backend.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.backend.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
restartPolicy: {{ .Values.backend.mergeDuplicateUsers.restartPolicy }}
volumes:
{{- range $index, $value := .Values.mountFiles }}
- name: "files-{{ $index }}"
configMap:
name: "{{ include "meet.fullname" $ }}-files-{{ $index }}"
{{- end }}
{{- range $name, $volume := .Values.backend.persistence }}
- name: "{{ $name }}"
{{- if eq $volume.type "emptyDir" }}
emptyDir: {}
{{- else }}
persistentVolumeClaim:
claimName: "{{ $fullName }}-{{ $name }}"
{{- end }}
{{- end }}
{{- range .Values.backend.extraVolumes }}
- name: {{ .name }}
{{- if .existingClaim }}
persistentVolumeClaim:
claimName: {{ .existingClaim }}
{{- else if .hostPath }}
hostPath:
{{ toYaml .hostPath | nindent 12 }}
{{- else if .csi }}
csi:
{{- toYaml .csi | nindent 12 }}
{{- else if .configMap }}
configMap:
{{- toYaml .configMap | nindent 12 }}
{{- else if .emptyDir }}
emptyDir:
{{- toYaml .emptyDir | nindent 12 }}
{{- else }}
emptyDir: {}
{{- end }}
{{- end }}
{{- end }}
+2 -13
View File
@@ -262,8 +262,8 @@ backend:
python manage.py migrate --no-input
restartPolicy: Never
## @param backend.createsuperuser.command backend createsuperuser command
## @param backend.createsuperuser.restartPolicy backend createsuperuser job restart policy
## @param backend.createsuperuser.command backend migrate command
## @param backend.createsuperuser.restartPolicy backend migrate job restart policy
createsuperuser:
command:
- "/bin/sh"
@@ -279,17 +279,6 @@ backend:
python manage.py createsuperuser --email $DJANGO_SUPERUSER_EMAIL --password $DJANGO_SUPERUSER_PASSWORD
restartPolicy: Never
## @param backend.mergeDuplicateUsers.command backend merge_duplicate_users command
## @param backend.mergeDuplicateUsers.restartPolicy backend merge_duplicate_users job restart policy
mergeDuplicateUsers:
annotations: {}
enabled: false
command:
- python
- manage.py
- merge_duplicate_users
restartPolicy: Never
## @param backend.probes.liveness.path [nullable] Configure path for backend HTTP liveness probe
## @param backend.probes.liveness.targetPort [nullable] Configure port for backend HTTP liveness probe
## @param backend.probes.liveness.initialDelaySeconds [nullable] Configure initial delay for backend liveness probe
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.18.0",
"version": "1.17.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.18.0",
"version": "1.17.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.18.0",
"version": "1.17.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.18.0",
"version": "1.17.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.18.0",
"version": "1.17.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.18.0",
"version": "1.17.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.18.0"
version = "1.17.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",