Compare commits

..

187 Commits

Author SHA1 Message Date
lebaudantoine ec7442f0e1 🐛(frontend) harmonize cache configuration for MediaPipe assets
The wasm and js files shipped by MediaPipe were served with
different cache policies, which could leave the two out of sync on
the client (fresh js with stale wasm, or vice versa).

Align the cache configuration across the MediaPipe assets so they
are always cached and invalidated together.
2026-08-06 13:16:20 +02:00
lebaudantoine e6f9b28a64 🐛(frontend) serve MediaPipe assets under a versioned path
The MediaPipe assets were served under /assets, where the cache
behavior differs between wasm and js files. As a result, clients
could end up with a fresh js loader paired with a stale wasm binary
(or vice versa), leaving MediaPipe out of sync.

Copy the assets under a versioned route so the URL changes whenever
the dependency version bumps. Clients then reload both the js and
the wasm together, keeping them in sync.
2026-08-06 13:00:21 +02:00
lebaudantoine 41e937c1f1 🔖(patch)) release 1.25.1 2026-08-06 11:31:08 +02:00
lebaudantoine d988c72208 🚑️(frontend) fix background crash from MediaPipe WASM version mismatch 2026-08-06 11:27:26 +02:00
lebaudantoine b96d591db3 🔖(minor) bump release to 1.25.0 2026-08-05 18:12:42 +02:00
lebaudantoine bffc51ac4c 🐛(frontend) add trailing slash on the fetch-room URL
The fetch-room URL was missing its trailing slash, which caused the
backend to issue a 301 redirect. Query parameters were being dropped
in the process, leading to incorrect requests.

Append the trailing slash so the request hits the correct endpoint
directly, without going through a redirect.
2026-08-05 18:04:52 +02:00
lebaudantoine 22a1713c60 🐛(frontend) fix concurrent PATCH races on room settings
Rapid toggles could persist a stale configuration: each PATCH
replaces the full room config, and every call site built it from a
render-time snapshot. A toggle issued before the previous one
resolved therefore overwrote the newer value with an older one.

Handle the cache centrally in usePatchRoom so the next toggle always
reads an up-to-date configuration.
2026-08-05 17:23:48 +02:00
lebaudantoine c23f449520 🐛(frontend) stop passing username as a query param when undefined
Skip adding the username query parameter when its value is
undefined, so the request URL no longer ends up with an
`?username=undefined` (or similar) that the backend has to handle.
2026-08-05 17:23:48 +02:00
lebaudantoine 0536896373 (sdk) add a room configuration popup from CreateMeetingButton
Introduce a room configuration popup opened from the SDK's
CreateMeetingButton, laid out like the Google Meet "call options"
dialog: logo header, grey section bands, and a footer bar with the
close action.

Like CreatePopup, it runs in a dedicated popup window so it can
access session cookies, which would be blocked in an iframe. If the
user is not authenticated, they are redirected to login and come
back to this popup afterwards.

Permissions are enforced server-side. The room is fetched with the
user's session, and settings are only shown when the room is
administrable by this user. Since #1482 removed the
is_administrable flag from the room serializer (roles now live in
the LiveKit participant attributes, only available in-meeting),
administrability is detected here through the presence of the
`accesses` field, which the backend only serializes for
administrators and owners. The PATCH endpoint enforces the same
permissions server-side regardless.

The settings mirror the in-room Admin panel. Unlike the Admin panel,
there is no LiveKit connection here, so changes are only persisted
in the room configuration (and applied when a session starts):
participants of an ongoing session are not live-synced or notified.
2026-08-05 17:23:48 +02:00
lebaudantoine f49c61d9bf (sdk) allow passing a background color to the calendar iframe
Let integrators pass a custom background color to the iframe used by
the calendar SDK, so it can match the surrounding product's theme.
2026-08-05 17:23:48 +02:00
lebaudantoine b593516802 🔒️(backend) derive connection-test room max age from token TTL
Refactor CONNECTION_TEST_ROOM_MAX_AGE_SECONDS so it is no longer an
independent setting but a quantity derived from (or added on top of)
the token TTL.

This prevents a misconfiguration where the token would outlive the
delete-room callback. In that case, an attacker holding a valid
token could recreate the room after the callback fired and escape
the intended cleanup.
2026-08-05 15:33:13 +02:00
Arnaud Robin 1328098c45 🐛(frontend) keep Unicode initials intact in avatar
Some characters span multiple UTF-16 code units. Taking a naive first
index for avatar initials can split them and show a broken glyph when
the camera is off.

Optically fix initials centering with a more complex approach.
2026-08-05 14:47:29 +02:00
lebaudantoine 58205f81d4 🐛(frontend) fix icon centering in the Switch primitive
Icons inside the Switch primitive were not properly centered.

Use relative sizes for the icons and switch to a grid-based
placement strategy so they stay centered regardless of the switch
size.
2026-08-05 13:47:50 +02:00
lebaudantoine b7892431be 💄(frontend) show pointer cursor on interactive switches
Set the cursor to a pointer on Switch components when they are
actually interactive, so it is visually clear that they can be
toggled.
2026-08-05 13:47:50 +02:00
lebaudantoine 00a2bd9558 🐛(backend) serialize lazy title in summary payload
_generate_title returned a lazy gettext_lazy proxy in the
recording_datetime is None branch, which json.dumps cannot
serialize.

This crashed requests.post(json=payload) with "Object of type
__proxy__ is not JSON serializable" whenever the LiveKit egress
lookup failed (started_at=None).

Force evaluation with a non-lazy method.

Add a regression test asserting the v2 payload is a real str and
is JSON-serializable when timestamps are unavailable.
The existing without_metadata test missed this: mocked
requests.post never serialized, and a lazy proxy compares equal
to its string.
2026-08-05 12:48:45 +02:00
lebaudantoine bc003f928e ⚗️(frontend) add candidate pair diagnostic to WebRTC checks
Add a custom diagnostic step that reports which ICE candidate pair
was selected on the WebRTC connection, as well as all working pairs
observed during the check.

Experimental and vibe-coded for now; the output is meant to help
debugging and will likely be revisited.
2026-08-05 12:16:40 +02:00
Arnaud Robin d756825fd7 (frontend) add connection test feature
Introduce a new connection test page to allow users to verify
their device and network compatibility with the application.
The feature also supports generating and downloading a detailed report
of the test results.
2026-08-05 12:16:40 +02:00
Arnaud Robin b01a47bfd7 (backend) add connection-test API
Currently users have no way to reliably test their connection before
joining a room. To address this, we plan to build a connection-test
page.

The testing requires a dedicated LiveKit token, issued without going
through the room API, which is tied to registered meetings, lobby
rules, and longer-lived access tokens.

Introduce a new viewset for all diagnostics-related features. The
first route issues a token for diagnostics, even for anonymous
users. Each request creates a new dedicated room so users never
share the same LiveKit room during tests. Tokens are short-lived
(default 10 minutes) to limit reuse, and the endpoint is throttled
to prevent abuse.

A Celery worker also schedules a callback that deletes the room
after a certain delay, in every case.
2026-08-05 12:16:40 +02:00
davd-gzl 06e73d7a5e 🐛(frontend) stop the installed app reopening the room it came from
site.webmanifest declared no start_url, so the page that linked
it became one and an install started inside a room reopened that
room on every launch. It now declares "/", moves out of public/
and takes VITE_APP_TITLE for name and short_name, which shipped
empty and leaned on the browser falling back to the title.

The frontend Dockerfile now declares that build argument, so the
value compose.yml passes stops being dropped.
2026-08-04 20:38:37 +02:00
lebaudantoine 6c69c3d6e3 🎨(frontend) apply lint fixes
Run the frontend linter/formatter and commit the resulting fixes to
keep the codebase clean and consistent.
2026-08-04 20:28:44 +02:00
leo 7fd4d20ea7 ⬆️(dependencies) update python dependencies
Update python dependencies. Fix linting due to ruff bump.

Co-Authored-By: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-04 20:27:26 +02:00
lebaudantoine 15c9ee225a ️(frontend) revert GridLayout re-render optimization too
Out of precaution, also revert the previous GridLayout re-render
optimization to avoid any layout regression alongside the
CarouselLayout revert.

The useSize-based re-render optimization will be reintroduced in a
dedicated small PR and release. That will also be a good occasion to
polish the layout code along the way.
2026-08-04 20:01:41 +02:00
lebaudantoine d86768bb59 ️(frontend) revert CarouselLayout re-render optimization
The previous optimization of the CarouselLayout was broken: the
approach did not hold in practice, and strict-mode rendering was
hiding the issue during development.

Revert the change for now and revisit the optimization later with a
sounder approach.
2026-08-04 20:01:41 +02:00
lebaudantoine 7bd5ab7a13 (frontend) expose media state to external gateways
Add a hidden div in the DOM that reflects the current state of the
microphone and camera, so that external SIP media gateways (e.g. the
Renater one) can observe it and keep an accurate view of the media
state.

Also emit a custom event from the page whenever the microphone or
camera state changes, so external consumers can subscribe to updates
instead of polling the DOM.
2026-08-04 19:04:16 +02:00
lebaudantoine ac8eae7295 (backend) apply user preferences on unconfigured room creation
Update the API so that, when a user creates a new meeting without
passing an explicit configuration, the user's persisted preferences
are applied as defaults.

This allows a user to, for example, enable the waiting room by
default on every meeting they create.
2026-08-04 19:04:16 +02:00
lebaudantoine 15b1ab7e0a (frontend) let users set default configuration for generated links
Extend the existing out-of-room settings so users can configure a
default room configuration that is applied to every link they
generate from the app.
2026-08-04 19:04:16 +02:00
lebaudantoine 2e509eff28 (backend) persist user preferences for room defaults on the User model
Add attributes on the User model to persist per-user preferences for
the default link access level and the default room configuration.

The frontend will let users update these preferences and then reuse
them when generating a link through the webapp.

Persisting them on the backend (rather than in application memory
only) ensures the preferences survive across sessions and devices.
2026-08-04 19:04:16 +02:00
lebaudantoine ca56ae87c2 (backend) expose the default room access level in settings
Expose the default access level for rooms in the backend settings
response, so the frontend can initialize the global room preferences
UI with the current default value.
2026-08-04 19:04:15 +02:00
lebaudantoine 2c5a766d02 🔥(ci) remove unused Anthropic security step
Drop the Anthropic security step from the project CI, as it is no
longer used and only added noise to the pipeline.
2026-08-04 19:04:15 +02:00
davd-gzl a67467b193 🩹(all) clear the SonarCloud reliability finding and the lint debt
The SonarCloud gate fails on main, so every commit lands red, and
gh run list hides it: it lists only Actions workflows, and the
failure is an app check run.

Reliability rests on one bug, in test_file_service.py, which wrapped
an assertion in an except Exception re-raised through pytest.fail.
Removing it takes the rating from D to A.

Two pieces of debt ride along. The SDK callback id now comes from
crypto.getRandomValues, since it guards an endpoint with no auth. And
core/tasks gets the __init__.py that lets pylint see it, with the
debt that exposes, which is why #1533 fails lint-back.
2026-08-04 19:04:15 +02:00
Florent Chehab 24d5a5a035 🧑‍💻(backend) use solo pool celery worker in dev
Change to reduce memory usage in dev.
2026-08-04 19:04:15 +02:00
lebaudantoine 9e500a59ea 🧑‍💻(backend) add commented the roomkit env variables
Useful for an easier devex when working on the feature.
2026-08-04 19:04:15 +02:00
lebaudantoine 8a0d4b1ad6 ♻️(backend) refactor tests to rely on decorators
Slightly refactor the existing tests to use decorators for common
setup and configuration.
2026-08-04 19:04:15 +02:00
lebaudantoine d0726ba631 🐛(backend) ensure SIP dispatch rule instead of creating it
The roomkit can now create a SIP dispatch rule before the LiveKit
webhook that used to trigger this creation is fired. In practice,
when the roomkit connects to the room, it also triggers the
webhook, leading to a duplicated dispatch rule.

Switch from "create dispatch rule" to "ensure dispatch rule exists"
semantics, so subsequent calls are idempotent and no duplicate rule
is created.
2026-08-04 19:04:14 +02:00
lebaudantoine e65036fe90 🚚(backend) rename TelephonyService to SIPManagement
Rename the telephony service to a more descriptive name,
SIPManagementService, which clearly states what the service is used
for.

It is no longer used only by the telephony feature; the roomkit
feature also relies on it now.
2026-08-04 19:04:14 +02:00
lebaudantoine 8b198726e9 (backend) add roomkit viewset to start a room without WebRTC join
Introduce a new viewset that lets the roomkit start a room even when
no WebRTC participant has joined yet.

This is a first entry point that will be extended over time with
more actions a roomkit needs to be able to trigger.

Known limitations:

* The responsibility around SIP rules is currently split between
  the telephony feature and the roomkit one. This may need a
  refactor later on to consolidate ownership in a single place.
* The default throttle might be too low for production usage and
  will likely need to be revisited.
2026-08-04 19:04:14 +02:00
lebaudantoine fc4774199b 🔧(devx) stop declaring LiveKit as an app-dev dependency
LiveKit was declared as an app-dev dependency, which caused it
(along with its egress) to be started whenever we ran unrelated
commands such as tests, migrate or makemigrations.

Drop that dependency and start LiveKit explicitly only when it is
actually needed, i.e. when calling run-backend.
2026-08-04 19:04:14 +02:00
lebaudantoine 9a3e54e6ec 🐛(backend) disable recording events in the default env file
The tests were failing when the Django settings did not disable
recording events, which was the case by default.

We do not rely on these events anymore by default, so set the
corresponding environment variable to false in the env file to make
the tests pass out of the box.
2026-08-04 19:04:14 +02:00
snyk-bot a0d4d82e18 fix: upgrade i18next from 26.3.4 to 26.3.6
Snyk has created this PR to upgrade i18next from 26.3.4 to 26.3.6.

See this package in npm:
i18next

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/af693e79-8c43-4c09-ab65-60580515c9e8?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-08-04 19:04:14 +02:00
renovate[bot] 8ed49c880b ⬆️(dependencies) update django to v5.2.16 2026-08-04 19:04:13 +02:00
leo be369bc04b ♻️(devex) optimize Makefile linting workflow
The linting workflow was unnecessarily building Docker dependencies and
creating containers multiple times. Optimize the Makefile to fix both
issues, for faster and lighter linting.
2026-08-04 19:04:13 +02:00
lebaudantoine be98c5210e 💄(frontend) adjust centering of Avatar initials
Fine-tune the vertical alignment of the initials in the Avatar so
they sit properly centered inside the circle.
2026-08-04 19:04:13 +02:00
lebaudantoine e400d05a7c 🚸(frontend) show two initials in the Avatar when possible
Display two initials in the Avatar whenever the participant's name
allows it, instead of a single letter.

A single initial makes it too hard to distinguish participants when
their cameras are off, especially in larger
2026-08-04 19:04:13 +02:00
lebaudantoine 9af42568bf 💄(frontend) improve participant name rendering in the list
Rework how the participant name is displayed in the participant
list to show as much of the name as possible before truncating.

When the name has to be truncated, add a tooltip so users can hover
to see the full name.

Requested by users.
2026-08-04 19:04:13 +02:00
lebaudantoine 7eba983c15 (frontend) introduce an "unauthenticated" participant badge
Add a visual badge on participants who are not authenticated, so it
is immediately clear who could be an anonymous participant. This is
a small but explicit security signal in the participant list.

Beyond that, the badge also plays a functional role: since only
authenticated participants can be promoted or demoted, the badge
helps users see at a glance who is eligible for a role change.
2026-08-04 19:04:12 +02:00
lebaudantoine b7210e62bb (frontend) notify user when their meeting role changes
Show a notification to the user whenever their role in the meeting
changes, so they immediately see when they have been promoted or
demoted.
2026-08-04 19:04:12 +02:00
lebaudantoine 653326347b 🐛(frontend) fall back to user.full_name on request-entry
Since the username refactoring, the username in the store could be
undefined when the join input was pre-filled from user.full_name,
because no keystroke was needed to populate the store.

This led to a 400 error on the request-entry endpoint whenever the
user joined without editing the pre-filled name.

Fall back to user.full_name when the store username is missing, so
the endpoint always receives a value.

Acknowledged as a somewhat wobbly fix, but ships as-is until the
underlying flow is reworked.
2026-08-04 19:04:12 +02:00
lebaudantoine 187e289548 (frontend) close admin side panel when the user is demoted
Listen to role changes in the admin panel and close the side panel
if the current user is demoted while it is open. Without this,
unprivileged users could still see the admin side panel until they
closed it manually.

I checked the other features that could be affected by hot role
changes; this was the only one still exposing admin-only UI after a
demotion. Everything else already handles live permission updates
correctly.
2026-08-04 19:04:12 +02:00
lebaudantoine 496a192ced (frontend) allow promoting authenticated participants
Introduce a new feature that lets a user promote one of the
authenticated participants of the meeting to a role with additional
privileges.

Known limitations:

* Only authenticated participants can be promoted, but there is no
  visual indicator yet distinguishing authenticated from anonymous
  participants. This will be added in a follow-up commit.
* The resource_access data fetched in the initial API call becomes
  stale after a promotion. It is not currently used in the product,
  so this is not visible, but it should either be refreshed later
  or removed from the initial fetch.
* Demoting a promoted user turns them into a member, which is still
  a privileged role. This is a deliberate choice until we introduce
  finer-grained tuning of participant roles.
2026-08-04 19:04:12 +02:00
lebaudantoine 715c18d276 ♻️(frontend) extract closeSidePanel action at the store level
Extract the logic that closes the side panel into a utility function
declared at the store module level, as recommended by Valtio.

This avoids re-creating the function on every render and prevents
extra re-renders in components that use it.
2026-08-04 19:04:12 +02:00
lebaudantoine dd3f23b1bf 🐛(backend) allow any string as sub in the API serializer
The API serializer was too restrictive on the `sub` field, expecting
a UUID. This worked in our development and production setups because
our Keycloak is configured to emit UUID subs, but it broke for other
providers.

Per the OIDC spec and the DB model, `sub` can be any string. Align
the serializer with this and accept arbitrary string values.

Fixes #1525.
2026-08-04 19:04:11 +02:00
lebaudantoine 066cd5f704 💄(frontend) render Avatar initials in uppercase
Uppercase the initials rendered in the Avatar so their vertical
centering stays consistent.

With lowercase letters, the initials were slightly shifted toward
the bottom of the Avatar, which broke the alignment.
2026-08-04 19:04:11 +02:00
lebaudantoine 19dfa26ee4 🔥(frontend) remove leftover console.count call
Drop a stray console.count call that was accidentally committed in a
previous PR and had been left in the codebase.
2026-08-04 19:04:11 +02:00
lebaudantoine 82aafa2030 ♻️(frontend) derive is_administrable from participant metadata
The is_administrable flag was previously read from the room API
response through the room serializer, giving the frontend static
information about the user's rights.

Refactor the frontend so it derives this flag from the participant
role carried in the participant metadata instead.

Two benefits:

* The flag now updates live along with the participant
  attributes/metadata, so role changes are reflected immediately.
* It removes the duplication between the API response and the
  metadata, which both used to determine the user's capabilities.
2026-08-04 19:04:11 +02:00
lebaudantoine bfb02b2242 (frontend) add client for the update participant role endpoint
Add the frontend client that calls the update participant role
endpoint. Straightforward API call, no special handling.
2026-08-04 19:04:11 +02:00
lebaudantoine ec9cd84cf8 (backend) expose is_authenticated in the LiveKit token
Include the is_authenticated flag on the user in the LiveKit token
and participant metadata.

The frontend needs this information (used in the next commit) to
know whether it can offer to promote a user with access to the room
admin.
2026-08-04 19:04:10 +02:00
lebaudantoine 65172447ca ♻️(backend) pass the participant role in the LiveKit token
The backend previously passed an abstract is_admin_or_owner boolean
flag in the LiveKit token. That kept the frontend minimalistic and
saved it from having to handle role comparisons.

As we introduce more features that need to distinguish between the
room owner and admins, refactor the token to carry the role
directly. The frontend can then derive the relevant flags from a
richer piece of information.
2026-08-04 19:04:10 +02:00
lebaudantoine 71d76abc0f (backend) add endpoint to update a participant role during a meeting
Add an endpoint that allows updating a user's role while in a
meeting. The goal is to let users promote other connected
participants to admin or moderator, so the burden of administrating
a meeting can be shared.
2026-08-04 19:04:10 +02:00
lebaudantoine 47f3da4153 ️(backend) add permission class checking the user is in the call
Introduce a new permission class that verifies the caller making a
request is both authenticated and actually present in the call.

It will be used to gate actions that require the user to be live in
the room, for example:

* allowing someone in from the waiting room
* promoting another participant to a different role

More generally, this covers every action where, for security
reasons, we need to make sure the user is truly present in the call
and that someone is not reusing their cookie as an API key.
2026-08-04 19:04:10 +02:00
Arnaud Robin ab0b2d67c3 📝(legal) update terms of service
Update terms of service content after legal review
2026-08-04 19:04:10 +02:00
leo a911226d07 🐛(backend) preserve recording metadata when updating room access
Updating room access rewrote the entire metadata payload, removing information
about active recordings. This caused the frontend to lose track of ongoing
recordings and could trigger 409 errors when attempting to start a new
recording.

Consolidate the duplicated metadata update logic into
`RoomManagement.update_metadata()` and preserve merge behavior instead of
overwriting the full metadata object.
2026-08-04 19:04:10 +02:00
snyk-bot adaffb0343 ⬆️(frontend) upgrade i18next from 26.3.2 to 26.3.4
Snyk has created this PR to upgrade i18next from 26.3.2 to 26.3.4.

See this package in npm:
i18next

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/af693e79-8c43-4c09-ab65-60580515c9e8?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-08-04 19:04:09 +02:00
Camille Moulin ee5710e61c 📝(metadata): Add publiccode.yml file
Recommended for Public Administration Open Source software projects.
See https://yml.publiccode.tools/

Signed-off-by: Camille Moulin <camille.moulin@numerique.gouv.fr>
2026-08-04 19:04:09 +02:00
lebaudantoine 6dd914fe66 📝(frontend) add changelog entry for PR #1510
Document in the CHANGELOG the set of changes shipped in PR #1510,
which groups the recent chat, layout and participant tile render
optimizations.
2026-08-04 19:04:09 +02:00
lebaudantoine a52ea119ac 🐛(frontend) reset chat state when the ChatProvider mounts
Reset the chat state on the first render of the ChatProvider, to
make sure no chat messages from a previous room leak into the new
one.

This covers SPA navigations where the user switches from one room
to another without a full page reload.
2026-08-04 19:04:09 +02:00
lebaudantoine fc4ee70265 🐛(frontend) fix pinnedTrackRef always evaluating to true
pinnedTrackRef was always truthy when evaluated in this
code path.
2026-08-04 19:04:09 +02:00
lebaudantoine 81f23aeb88 ️(frontend) tripwire promotion of off-screen active speakers
Introduce a tripwire component that listens to
RoomEvent.ActiveSpeakersChanged imperatively and forces a single
re-render of its host only when an active speaker has none of their
tiles within the visible span (maxVisibleTiles). That re-render
re-runs useVisualStableUpdate, which reads live isSpeaking state
and performs the actual tile swap.

Speakers already visible are ignored, so this costs zero React work
in the common case.

This lets us drop the ActiveSpeakersChanged subscription from
useTracks in the StageLayout upstream (updateOnlyOn: []), which was
re-rendering the whole stage on every speaker change.
2026-08-04 19:04:09 +02:00
lebaudantoine a5651249c5 ️(frontend) memoize the EffectsButton
Memoize the EffectsButton so it does not re-render on unrelated
parent updates when its props have not changed.
2026-08-04 19:04:08 +02:00
lebaudantoine 167839ebd0 ️(frontend) reduce re-renders of the ParticipantTile focus overlay
Optimize the idle mouse handling and the FocusOverlay component so
they no longer trigger frequent re-renders of the ParticipantTile
focus.

State related to hover and focus is now scoped closer to where it
is used, keeping updates local instead of propagating up the tile.
2026-08-04 19:04:08 +02:00
lebaudantoine 18ec920ebe 🚚(frontend) extract ParticipantTile sub-components into their own files
Split the sub-components currently declared inside ParticipantTile
into dedicated files.

This makes the ParticipantTile file easier to read and lets each
sub-component be imported and reasoned about on its own.
2026-08-04 19:04:08 +02:00
lebaudantoine 8a24502295 ♻️(frontend) harmonize participant name handling in ParticipantTile
Align how the participant name is retrieved and rendered inside the
ParticipantTile, so the different code paths use a single consistent
approach instead of a mix of ad hoc logic.
2026-08-04 19:04:08 +02:00
lebaudantoine c3f212bf54 ️(frontend) synchronize room metadata in a leaf component
Wrap the hook in a leaf component.
2026-08-04 19:03:18 +02:00
lebaudantoine a0274a2d54 ️(frontend) memoize the Placeholder component
Turn the Placeholder into a pure leaf component and memoize it, so
it does not re-render on unrelated parent updates when its props
have not changed.
2026-08-04 19:02:40 +02:00
lebaudantoine 13c9d3635c ️(frontend) size the Avatar with CSS instead of useSize
Stop using the useSize hook to compute the Avatar size in JS. Rely
on CSS to size the Avatar responsively instead.

This removes a set of unnecessary re-renders triggered by the
useSize subscription every time the container resized.
2026-07-24 18:31:47 +02:00
lebaudantoine eced9891c6 ️(frontend) optimize re-renders of the CarouselLayout
Apply the same pattern as previous layouts: push state and useSize
subscriptions down into child components.

The CarouselLayout now only re-renders when maxVisibles or
orientation actually change, instead of on every size update.
2026-07-24 18:31:47 +02:00
lebaudantoine ef05c0ab19 ️(frontend) isolate useSize in a child of MoreOptions
Move the useSize subscription into a dedicated child component of
MoreOptions.

The options now only re-render when they actually need to collapse,
instead of on every size change of the container.
2026-07-24 18:31:47 +02:00
lebaudantoine 932332c858 ️(frontend) isolate useSize subscription in a GridLayout leaf
Move the useSize observer into a leaf component of the GridLayout
tree. That leaf then propagates size changes back into the
GridLayout state through a callback.

This prevents every size change from re-rendering the whole
GridLayout tree, keeping the re-render local to the leaf that
actually observes the size.
2026-07-24 18:31:47 +02:00
lebaudantoine 7343560a9b ️(frontend) reduce unnecessary re-renders in the participant panel
Cut down the number of unnecessary re-renders happening in the
participant panel by narrowing subscriptions and isolating state to
the components that actually depend on it.
2026-07-24 18:31:47 +02:00
lebaudantoine fca7c3b8b8 🐛(frontend) fix long-standing initials centering in Avatar
The Avatar was rendered as an image, which made the initials
centering unreliable across sizes and browsers.

Render the Avatar as an SVG instead, which allows the initials to be
properly centered by construction.
2026-07-24 18:31:47 +02:00
lebaudantoine 29672cda6b ️(frontend) gate LowerAllHandsButton with AdminOrOwnerOnly
Align LowerAllHandsButton with the MuteEveryoneButton pattern by
wrapping it with the AdminOrOwnerOnly component.

This prevents mounting its hooks and rendering its logic for users
who are not admin or owner, instead of relying on an internal check
that still ran on every re-render.
2026-07-24 18:31:47 +02:00
lebaudantoine c3c1e918b0 ️(frontend) extract participant count into a dedicated component
Extract the participant count out of the participant badge into a
dedicated, optimized component.

This isolates the subscription to the participant count so it no
longer triggers a re-render of the whole toggle when the count
changes.
2026-07-24 18:31:47 +02:00
lebaudantoine d1dde71bea 🚚(frontend) move participant list code into a feature folder
Reorganize all participant list related code elements into a
dedicated feature folder, so related components, hooks and helpers
are grouped together and easier to locate.
2026-07-24 18:31:47 +02:00
lebaudantoine 64b4200fc3 ♻️(frontend) major chat refactoring
- Dissociate the chat observer (which persists messages in a store)
  from chat rendering.
- Do not keep the chat mounted in the DOM anymore.
- Recompute the minimal amount of data when a participant renames.
- Memoize most of the layout.
- Drop the manual textarea row-size computation: recent React Aria
  already handles it.

Known regressions still to solve: focus behavior on the input,
scroll behavior on message updates, and edge cases around list
sizing.

Not sure yet whether there is a better way to memoize the
virtualized list; open to feedback.
2026-07-24 18:31:47 +02:00
lebaudantoine 4b393e28ca ️(frontend) memoize the ActiveSpeaker component
Memoize the ActiveSpeaker component used in the push-to-talk
component so it does not re-render on unrelated parent updates when
its props have not changed.
2026-07-24 18:31:47 +02:00
lebaudantoine 8dc1bacc38 🚚(frontend) move chat-related code into a feature folder
Reorganize all chat-related code elements into a dedicated feature
folder, so chat components, hooks and helpers are grouped together
and easier to locate.
2026-07-24 18:31:47 +02:00
lebaudantoine 97a9735c0b ️(frontend) avoid subscribing to full local participant changes
Subscribing to the whole local participant caused re-renders on
every local participant update, even when the consumer only cared
about a specific attribute.

Narrow the subscription so only the relevant attributes trigger a
re-render.
2026-07-24 18:31:47 +02:00
lebaudantoine d2e13cc826 ️(frontend) push video resolution subscription down the tree
Move the video resolution subscription down into a lower component
so it no longer re-renders the whole Videoconference component on
every resolution change.

The overall approach still needs validation, but this already avoids
the top-level re-render and is a clear improvement over the current
behavior.i
2026-07-24 18:31:47 +02:00
lebaudantoine 183b0fc697 ️(frontend) narrow canPublishTrack subscription to permissions
canPublishTrack was mistakenly subscribing to the whole local
participant changes, so every component using this hook re-rendered
on any local participant update, not only on permission changes.

Fix it to subscribe only to the permission attributes.
2026-07-24 18:31:47 +02:00
lebaudantoine b7be0eabb0 ️(frontend) render full-screen warning only for the local participant
Restrict the full-screen warning to the local participant tile.

Previously, mounting it on every participant tile meant that when
the local participant toggled their camera or microphone, the
warning re-evaluated and triggered a re-render on every tile.
2026-07-24 18:31:47 +02:00
lebaudantoine f21c4c91ed ️(frontend) optimize participant metadata rendering on raised hand
Push state down into the participant metadata subtree so a raised
hand change no longer re-renders the whole metadata block.

Use the children-as-props pattern so only the item actually related
to the raised hand re-renders when its state changes.

This also better encapsulates the color-update logic tied to the
raised-hand state.
2026-07-24 18:31:47 +02:00
lebaudantoine 5f52fa8d8d ️(frontend) minimize observables consumed by useRaisedHandPosition
useRaisedHandPosition is called on every participant tile, so any
unnecessary subscription in it multiplies re-renders across the
whole conference.

Reduce the number of events subscribed to for remote participants
to the strict minimum, while keeping the subscription to the
relevant attributes of the local participant intact.
2026-07-24 18:31:47 +02:00
lebaudantoine 9420fed563 ️(frontend) encapsulate participant metadata in a boundary component
Encapsulate the participant metadata rendering in a clear boundary
component, especially by pushing the raised-hand logic down into a
dedicated child.

This way, when a participant raises their hand, only the child
component re-renders instead of the whole ParticipantTile.
2026-07-24 18:31:47 +02:00
lebaudantoine 4598aafa23 ️(frontend) narrow chat event subscriptions to reduce re-renders
Reduce the set of events the chat subscribes to, keeping only those
that actually affect its rendering.

This avoids frequent re-renders triggered by unrelated events on
the room or participants.
2026-07-24 18:31:47 +02:00
lebaudantoine 94abdfc0f9 ️(frontend) read pinned track imperatively in ParticipantTile
In the participant tile, there is no need to read the pinned track
through a Valtio snapshot, which would trigger a re-render every
time the pinned track changes, for every participant tile.

Switch to an imperative read of the store to avoid these unnecessary
re-renders.
2026-07-24 18:31:47 +02:00
lebaudantoine 56a8f72e39 🚚(frontend) move participant tile components into a feature folder
Reorganize all components related to the participant tile into a
dedicated feature folder, so the code organization is clearer and
related components are grouped together.
2026-07-24 18:31:47 +02:00
lebaudantoine 86fcc2c7fe ♻️(frontend) drop useSettingsDialogs hook
The useSettingsDialogs hook only encapsulated Valtio store
manipulation that should be declared at the module level. Keeping it
as a hook triggered unnecessary re-renders in components that used
it, subscribing to the store.

Remove the hook and use the store actions directly at the module
level.
2026-07-24 18:31:47 +02:00
lebaudantoine 0a0306b0a2 ♻️(frontend) move keyboard shortcut registration to a leaf
Move the keyboard shortcut registration down into the
SettingsDialogProvider leaf component, so shortcut-related
re-renders no longer bubble up and re-render the whole
Videoconference component.
2026-07-24 18:31:47 +02:00
lebaudantoine c00fcc78e1 ️(frontend) narrow participant event subscriptions in admin panel
Reduce the set of participant change events the admin side panel
subscribes to, so it only re-renders on events that actually affect
its display.
2026-07-24 18:31:47 +02:00
lebaudantoine 39a59d1b35 ️(frontend) narrow ParticipantToggle event subscriptions
Reduce the set of events the ParticipantToggle subscribes to, so it
does not re-render on every LocalParticipant event but only on the
ones that actually affect its rendering.
2026-07-24 18:31:47 +02:00
lebaudantoine f4493d412f ♻️(frontend) turn ConnectionObserver hook into a leaf component
Refactor the connection observer, previously a hook consumed inside
the Videoconference component, into a dedicated leaf component in
the tree.

This avoids re-rendering the whole Videoconference component every
time the connection state changes; only the leaf reacts to it.
2026-07-24 18:31:47 +02:00
lebaudantoine e1457604ee ️(frontend) memoize the ParticipantName component
Memoize the ParticipantName component so it does not re-render on
unrelated parent updates when its props have not changed.
2026-07-24 18:31:47 +02:00
lebaudantoine bf6351e838 ️(frontend) memoize the KeyboardShortcutHint component
The KeyboardShortcutHint component only renders a string and has no
reason to re-render on unrelated updates. Memoize it to skip those
re-renders.
2026-07-24 18:31:47 +02:00
lebaudantoine aba5fca655 ️(frontend) memoize the Avatar component
The Avatar component is simple and stateless. Memoize it to avoid
unnecessary re-renders when its props have not changed.
2026-07-24 18:31:47 +02:00
lebaudantoine acb583b14f ️(frontend) narrow chat participant list update events
Reduce the set of events that trigger an update of the participant
list in the chat, keeping only those relevant to display: attribute
and name changes.

This avoids unnecessary re-renders when other, unrelated participant
events fire.
2026-07-24 18:31:47 +02:00
lebaudantoine fb3a54a224 ️(frontend) virtualize chat messages to reduce DOM size
At this stage, the chat is kept mounted in the DOM at all times to
preserve state and keep receiving new messages. As a consequence,
every chat message ends up rendered in the DOM, which can bloat it
significantly in large meetings with hundreds of participants
exchanging messages.

Virtualize the chat message list so only the few messages actually
visible are rendered in the DOM.

The fact that the chat is always mounted in the DOM will be
addressed in a later commit.
2026-07-24 18:31:47 +02:00
lebaudantoine 8cd6496b5b ♻️(frontend) move InviteDialog state down the React tree
The InviteDialog open state was managed very high in the React tree,
which triggered a re-render of the whole conference tree whenever
the dialog was opened or closed.

Push the state down to a narrower component to limit the scope of
re-renders.

Part of a broader effort to isolate as much as possible what should
re-render, so we can then focus on tackling the real performance
issues.
2026-07-24 18:31:47 +02:00
lebaudantoine 63a7de402b ♻️(frontend) extract pinned track a11y announcement into a leaf component
Extract the code responsible for announcing pinned track changes
(for accessibility) into a well-scoped leaf component.

Previously, calling the useScreenReaderAnnounce hook inside
StageLayout triggered a re-render of the whole layout subtree.
Encapsulating the announcement logic in a narrow component keeps
those re-renders local to that leaf.
2026-07-24 18:31:47 +02:00
lebaudantoine f842cc340e ♻️(frontend) vendor pinned track context using a Valtio store
Vendor the pinned track context and its associated hook from
LiveKit, and switch the underlying state management to a Valtio
store.

The LiveKit ContextProvider was injected high in the tree, so any
pinned track change triggered a re-render of a large portion of the
app. With a Valtio store, only the parts of the app that actually
subscribe to the pinned track or need to pin one re-render when
present in the DOM.
2026-07-24 18:31:47 +02:00
lebaudantoine 8c235571eb ♻️(frontend) isolate track rendering in a StageLayout component
Move track manipulation into a leaf component to avoid re-rendering
the whole videoconference tree when a track update occurs.

The new StageLayout component is now responsible for rendering the
video tracks in the relevant layout, while the Videoconference
component keeps the responsibility of building the overall
conference view.

We save re-rendering the ControlBar for every track/layout changes
for example.
2026-07-24 18:31:47 +02:00
lebaudantoine d6f39e602d 🚚(frontend) move focus layout component to the layout feature
Reorganize the codebase so the focus layout component lives in the
layout feature folder, alongside the other layout-related code.
2026-07-24 18:31:47 +02:00
Arnaud Robin 0c0b2f2616 (frontend) add configurable documentation link
Expose a room options Documentation link via FRONTEND_DOCUMENTATION_URL
so deployers can set or hide it.
2026-07-24 18:21:26 +02:00
Florent Chehab a5b79afde1 ⚰️(summary) cleaned tasks failure handling
Code in task failure was assuming that the failure
signal would be called on retry which is not the case.
2026-07-24 12:07:44 +02:00
Florent Chehab 052d3c1b22 (summary) report exception type in failure analytics
We know capture also the exception type that was raised
in failure analytics to provide more insights about what happened.
2026-07-24 10:14:59 +02:00
Florent Chehab dff5aa9575 🐛(summary) properly detect if task will be retried
In previous version, webhook may not be called in case of failure,
because the task wouldn't be actually retried.
We know check the exception raised against the auto_retry for
config.
Analytics capture also happens in case of definitive failure.
2026-07-24 10:02:39 +02:00
Florent Chehab ded93bf24f 🐛(summary) retry on RequestException instead of HTTPError
Request may fail for other reasons than HTTPError (ConnectionError for
instance). This change switches to the more generalized error for the
retry logic on call webhook & transcribe audio
2026-07-24 09:58:58 +02:00
leo 5ba1885411 (transcription) fix broken speaker assignment tests
Fix broken speaker assignement tests following #1522.
2026-07-22 14:23:10 +02:00
snyk-bot 273af221e6 ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
Snyk has created this PR to upgrade livekit-client from 2.19.2 to 2.20.0.

See this package in npm:
livekit-client

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-07-21 19:00:04 +02:00
snyk-bot e8d6aba306 ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
Snyk has created this PR to upgrade @tanstack/react-query from 5.101.0 to 5.101.1.

See this package in npm:
@tanstack/react-query

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-07-21 18:41:15 +02:00
snyk-bot 449503208a ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
Snyk has created this PR to upgrade posthog-js from 1.391.2 to 1.395.0.

See this package in npm:
posthog-js

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-07-21 18:34:52 +02:00
snyk-bot 7eb3553bea ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.2
Snyk has created this PR to upgrade i18next from 26.3.1 to 26.3.2.

See this package in npm:
i18next

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/af693e79-8c43-4c09-ab65-60580515c9e8?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-07-21 18:24:25 +02:00
snyk-bot f0356af365 ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
Snyk has created this PR to upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35.

See this package in npm:
@mediapipe/tasks-vision

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-07-21 18:19:26 +02:00
leo 9921ef9d09 🐛(transcription) fix silent fail of speaker assignment
Fix bug introduced by #1362 which caused speaker assignment to
silently fail. Bug was due to change in input variable type.
2026-07-21 18:16:23 +02:00
lebaudantoine 68e999a037 🔖(minor) bump release to 1.24.0 2026-07-21 18:01:20 +02:00
lebaudantoine 34103bf326 🩹(agents) announce the update of uv.lock via prepare-release script
Announce it among the other files. Minor issue.
2026-07-21 18:01:20 +02:00
lebaudantoine e273ac9e43 🔖(helm) release chart 0.0.27 2026-07-21 17:26:01 +02:00
Florent Chehab adcdbc0695 🐛(summary) whisper call error handling
* Consider that HTTP 400 errors are due to corrupted
audio files by  default.
* Properly reraise the http error otherwise so that
the retry mechanism actually works.
2026-07-21 12:05:06 +02:00
lebaudantoine 8f9008f1e0 🔖(addons) remove the beta tag
Promote the addon to a stable v1 release.
2026-07-21 10:30:00 +02:00
lebaudantoine 9320a1af0c (addon) show add-in tools when creating meetings in shared calendars
The existing manifest was not exposing the add-in tools when
creating a meeting in a shared calendar. Amend the manifest with the
missing instructions so the add-in shows up in that context as well.

Manifest changes generated with Claude's assistance.
2026-07-21 10:30:00 +02:00
David Wagner e3827970c8 (docs) Fix CSS theming env var name
While here, also mention *where* this env var should be set.
2026-07-20 12:38:07 +02:00
lebaudantoine ab707d866f 🩹(backend) fix the mail-builder step in docker
Upgrading mjml now requires node >=22. I forgot to update the step,
responsible to build the mail template in the production image.
2026-07-13 11:32:46 +02:00
snyk-bot dac0b9c000 ⬆️(frontend) upgrade react-aria related dependencies
Initiated by Snyk.
2026-07-13 11:32:46 +02:00
Cyril 53cc8642eb ️(frontend) fix focus restore when switching side panels
restore trigger focus on panel switch while panel remains open
2026-07-11 16:08:57 +02:00
Cyril c8d9d2fea8 ️(frontend) focus side panel container on open
Focus aside on side panel open so screen readers announce the aria-label
2026-07-11 16:08:57 +02:00
snyk-bot e910b1f0b7 ⬆️(frontend) upgrade posthog-js from 1.387.0 to 1.391.2
Snyk has created this PR to upgrade posthog-js from 1.387.0 to 1.391.2.

See this package in npm:
posthog-js

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-07-11 16:06:18 +02:00
lebaudantoine 581e115c03 ♻️(frontend) inline MediaPipe WASM modules to avoid loading from remote
Copy the MediaPipe WASM modules into the frontend build output and
serve them locally, instead of loading them from the Google CDN at
runtime.

Mediapipe was a transitive dependency, add it explicitly.

Requested by some members of the community.
It closes #1168
2026-07-11 15:57:59 +02:00
lebaudantoine 8118ef0612 🎨(frontend) format vite config file
Apply the project's formatter to the Vite config file to align its
style with the rest of the codebase.
2026-07-11 15:57:59 +02:00
lebaudantoine 85f886d9c5 (frontend) install vite-plugin-static-copy for MediaPipe WASM assets
Add vite-plugin-static-copy as a dependency. It will be used to copy
and serve the WASM modules shipped by the MediaPipe JS dependency
from the frontend build output.
2026-07-11 15:57:59 +02:00
lebaudantoine b66f82e4b7 ♻️(frontend) reorganize JS packages in package.json
Sort and regroup the entries in package.json that were
not in the expected order.
2026-07-11 15:57:59 +02:00
lebaudantoine aabb7d629c ♻️(frontend) inline model weights to avoid loading them from remote
Inline the model weights instead of fetching them from a Google
remote location at runtime. The weights do not update frequently, so
inlining removes an external dependency.
2026-07-11 15:57:59 +02:00
lebaudantoine 1e5f5c4fe9 ♻️(frontend) refactor background processors to use the new API
Update the calls to the background processors imported from
livekit-track-processor so they use the new instantiation method
instead of the previous, now-deprecated one.
2026-07-11 15:57:59 +02:00
lebaudantoine f55fa0c42b 🐛(backend) fix info panel crash for unregistered rooms
The info panel was crashing when opening a room that was not
registered in the database (with ALLOW_UNREGISTERED_ROOMS=true),
because the API response did not include the room slug.

Instead of adding frontend fallbacks, update the unregistered-room
response to include the slug, keeping the API contract consistent
with registered rooms.

Note: this still relies on the unregistered-room response staying
aligned with the registered-room schema. Any future divergence
between the two responses could introduce similar issues.
A refactoring on the backend side is needed.

It closes #1441.
2026-07-10 19:45:56 +02:00
lebaudantoine 2bc5e47c75 🚸(frontend) initialize the join input name with the persisted full name
Prefill the "name" field on the join screen with the full name
persisted in the database, instead of leaving it empty by default.
2026-07-10 19:02:26 +02:00
lebaudantoine 0d5136206f (all) allow forcing SSO display name for authenticated users
Add a new setting that controls whether an authenticated user can
rename or modify their display name, or if they must use the one
returned by the SSO.

When the setting is disabled, only anonymous users can set a display
name freely; authenticated users always use their SSO display name.

Requested by many self-hosters.
2026-07-10 19:02:26 +02:00
lebaudantoine 05a67320b1 🩹(backend) fix the LiveKit token to use full name as display name
Use the user's full name as the display name in the emitted LiveKit
token instead of the email.

Requested by several self-hosters.
2026-07-10 19:02:26 +02:00
lebaudantoine 2fbaa49089 ♻️(frontend) refactor the username storage
Create a new dedicated store for user choices, starting with the
username, to decouple this part from any LiveKit elements. This is
better for tree-shaking, and depending on LiveKit for storing the
username brings no real value.

The refactoring will be continued later for the other user choices
that can be persisted.
2026-07-10 19:02:26 +02:00
lebaudantoine 76542b2235 🔧(tilt) configure Tilt stack for full-name and short-name mapping
Update the Tilt dev stack configuration so that the full-name and
short-name claim mapping works correctly with the current Keycloak
configuration.
2026-07-10 19:02:26 +02:00
lebaudantoine 10231b0333 🩹(backend) identify externally provisioned users to PostHog
In the external API, applications authenticate with a
client_id/secret and can provision users with only an email. In that
flow, users are not identified to PostHog, so the user DB id alone
is not enough to identify them in analytics afterwards.

The issue does not exist in the public API, where users are
authenticated and therefore already identified.

Inspired by @flochehab's approach in summary.
2026-07-10 10:47:04 +02:00
Cyril 9b9e6578ee (frontend) add participant color gradient when camera is off
use participant color radial gradient for video-off placeholder
2026-07-10 10:46:46 +02:00
lebaudantoine 0c81d29ee7 (backend) allow searching the recording admin table by owner email
Add the owner's email to the searchable fields of the recording
admin table. This makes it much more convenient for the support team
to look up recordings by user.
2026-07-10 10:15:57 +02:00
Cyril 839a8f1c71 💄(frontend) add consistent spacing to picture-in-picture tiles
Add padding to StageFrame and bigger gaps so PiP tiles aren't flush with window.
2026-07-10 08:45:53 +02:00
Cyril 40ef420cda (frontend) use focus layout for solo PiP screen sharing
Solo cam in screen share PiP is now a BR thumbnail over shared screen
2026-07-10 08:45:51 +02:00
Cyril d995bd9041 (frontend) prioritize screen share in picture-in-picture layout
Show screen share large with cameras in a top row; match main room focus.
2026-07-10 08:45:51 +02:00
lebaudantoine 8ac4c2409d 💚(mail) update the node version for the mail building step
Broken with the dependencies update.
2026-07-09 16:22:54 +02:00
lebaudantoine e47ad42656 ⬆️(mail) update mjml to v5 and @html-to/text-cli
@html-to/text-cli was containing CVEs reported by the audit.
2026-07-09 16:22:54 +02:00
Florent Chehab 2509452c52 🗑️(settings) deprecate SUMMARY_SERVICE_VERSION=1
Add a deprecation warning when SUMMARY_SERVICE_VERSION=1 and
an endpoint is configured meaning summary service is in use.
2026-07-09 16:14:23 +02:00
Florent Chehab 1022780027 📝(upgrade) add note related to summary v1 removal
Add upgrade notes related to the summary changes in v1.23.0
2026-07-09 15:49:53 +02:00
Florent Chehab 4a1a04f86e 🔖(minor) bump release to 1.23.0 2026-07-08 10:07:09 +02:00
Florent Chehab 8702638cf3 (release) uv lock agents
In release script also run uv lock for agents.
2026-07-08 10:06:53 +02:00
Florent Chehab f88c0307ea 🐛(summary) do not save null emails in analytics
In case the email is not provided we should not update
the analytics email value.
2026-07-08 02:09:30 +02:00
Florent Chehab e7f15b50ff (summary) more precise analytics events
* Specific transcript and summary events
* Improve observability on summary tasks
2026-07-08 02:09:30 +02:00
snyk-bot 75ba2ff146 🔒️(frontend) update docker image to nginx-unprivileged:1.30.3-alpine3.23
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-ALPINE323-EXPAT-17675120
- https://snyk.io/vuln/SNYK-ALPINE323-EXPAT-17675112
- https://snyk.io/vuln/SNYK-ALPINE323-EXPAT-17675114
- https://snyk.io/vuln/SNYK-ALPINE323-EXPAT-17675124
- https://snyk.io/vuln/SNYK-ALPINE323-EXPAT-17675125
2026-07-08 01:48:59 +02:00
lebaudantoine bbe2a32efc ⬆️(frontend) update the frontend build image to Node 22
Bump the Node.js version used in the frontend build image from
Node 20 to Node 22.
2026-07-08 01:39:20 +02:00
snyk-bot 86fff16eed ⬆️(frontend) upgrade @tanstack/react-query from 5.100.14 to 5.101.0
Snyk has created this PR to upgrade @tanstack/react-query from 5.100.14 to 5.101.0.
2026-07-08 01:02:50 +02:00
snyk-bot 195d2b5006 ⬆️(frontend) upgrade posthog-js from 1.386.5 to 1.387.0
Snyk has created this PR to upgrade posthog-js from 1.386.5 to 1.387.0.
2026-07-08 00:56:41 +02:00
snyk-bot 30cf264276 ⬆️(frontend) upgrade livekit-client from 2.19.0 to 2.19.2
Snyk has created this PR to upgrade livekit-client from 2.19.0 to 2.19.2.
2026-07-08 00:48:27 +02:00
lebaudantoine 945d779b45 ♻️(frontend) wrap MuteEveryoneButton with AdminOrOwnerOnly
Avoids mounting the button's hooks and rendering its logic for users who
are not admin or owner, which previously happened on every re-render
of the parent.
2026-07-07 19:11:41 +02:00
leo d43b335546 (agents) add Sentry instrumentation for agents
Add Sentry observability to the `agents`. Introduce a dedicated
`observability.py` module. Refactor both agents to use the shared
observability layer and extract task helpers into a new `tasks.py` module,
making task execution easier to instrument and maintain.
2026-07-07 14:56:21 +02:00
Florent Chehab e6f0ff5112 🔥(summary) remove call to summary enabled feature flag
We now rely only on the value coming from
the API request. This feature flag should
be handled by the caller.
2026-07-07 12:09:19 +02:00
Florent Chehab 224d6ce358 (backend) use feature flag in call to summary-v2
Summary enabled is now infered directly from
the feature flag analytics backend.
2026-07-07 12:09:19 +02:00
Florent Chehab 20718f2d5a (backend) implement feature flags in Posthog analytics backend
Implement feature flags related functions in
Posthog analytics backend.
We cache the results in django cache to avoid
too frequent calls to Posthog.
Especially for when we are checking multiple features in
the same user request.
2026-07-07 12:09:19 +02:00
Florent Chehab 8edcb6e973 (backend) extend analytics module to support feature flags
Extend the AnalyticsBackend class to support
user feature flags, without a breaking change to the
current implementation.

A flag value is considered to be a bool or a string.
2026-07-07 12:09:19 +02:00
lebaudantoine b00c4e5d6d ♻️(backend) refactor analytics backend from Protocol to abstract class
Switch the analytics backend interface from a typing Protocol to an
abstract base class, so the contract that backends must implement
is explicit and enforced at instantiation time.
2026-07-07 11:51:11 +02:00
Florent Chehab 3c0ba03976 ♻️(backend) refactor Bearer Auth based authentification
Created a shared class for handling Header based authentification.
This avoid duplicating logic and helps with a maintaining a signle
security related peace of code.
2026-07-06 20:53:29 +02:00
Florent Chehab 7fa172d100 (visio) use compatible with summary v2
This commit introduces the compatibility with summary v2.
It tecnically doesn't break the compatibility with v1 as
v1 params are still sent. But we advise people using the
transcribe feature in their own deployments to adapt to the
new v2 API, as this compatibility will be removed in a
future major version.

* RecordingStatusChoices now has
EXTERNAL_PROCESS_SUCCESSFUL
& EXTERNAL_PROCESS_FAILED
Values, which are changed by a new webhook that
can be called by the transcribe service.
This webhook is protected by its own bearer token.
* Title for the document is computed in visio,
* Tests are added / updated accordingly
2026-07-06 20:53:29 +02:00
Florent Chehab ecf8f0fe3f (summary) extend v2 routes to support visio usecase
* Update the v2 create transcribe payload to support
publishing directly to docs and performing automatically
a summary if requested
* Note that title computation is to be handled by the caller
now as it makes more sense and avoids throwing a bunch
of parameters to the endpoint.
* All files are send as signed URL now, we don't read
directly from s3 anymore,
* Docs integration is now explicit in summary settings
* To make analytics work properly accross projects,
we use the user sub as distinct id,
this will require a new posthog project to be deployed
to work properly.
We create a post hog event at the creation request processing,
to make sure feature flags work properly after that.
* User email should now be provided to the API, it's not
mandatory to avoid a breaking change.
* Use a specific user agent for better tracking
Finally note that existing helpers don't always make use of
 the pydantic models, so that's why there
are model_dump in some places. To avoid bigger changes.
2026-07-06 20:53:29 +02:00
Florent Chehab 27da57aff5 💥(summary) remove v1 related code
This commit cleans up most of the code related to v1 route that
is used only by visio.
This is first step before introducing an update to the v2 route.
2026-07-06 20:53:29 +02:00
leo e327a5e35f ⬆️(dependencies) update python dependencies
Update python dependencies.
2026-07-06 20:43:54 +02:00
snyk-bot c3abd0441f ⬆️(agents) upgrade to python 3.14 slim
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-DEBIAN13-OPENSSL-17269387
- https://snyk.io/vuln/SNYK-DEBIAN13-OPENSSL-17269392
- https://snyk.io/vuln/SNYK-DEBIAN13-OPENSSL-17269400
- https://snyk.io/vuln/SNYK-DEBIAN13-OPENSSL-17269409
- https://snyk.io/vuln/SNYK-DEBIAN13-OPENSSL-17269415
2026-07-06 20:06:55 +02:00
snyk-bot 79245389ce ⬆️(frontend) dedupe react-stately, align react-aria packages
Add react-aria 3.49.0 + react-stately 3.47.0 as direct deps and bump
react-aria-components to 1.18.0 so react-stately resolves to one version.
Fixes the nominal Timer type clash and missing @react-stately/toast
imports introduced by the React Aria v1.17 monopackage consolidation.
2026-07-06 20:00:52 +02:00
Florent Chehab 3b3f992834 🐛(summary) support media files with bad streams
Rarely media files may have one or multiple
empty streams when they are badly formatted.
The extract metadata code would crash when that happened.
We now avoid crashing and create a clean file
from the bad one to make sure API calls with that data
works properly (observed some failures otherwise
in my tests).
2026-07-06 16:39:40 +02:00
lebaudantoine a98dc1484a 🩹(backend) fix case-insensitive email deduplication in merge command
The management command that merges users with duplicate emails was
comparing emails in a case-insensitive manner, which left some
duplicates in the database when their emails only differed by case.
2026-07-06 16:03:50 +02:00
snyk-bot dca24a1b25 ⬆️(frontend) upgrade i18next from 26.2.0 to 26.3.1
Snyk has created this PR to upgrade i18next from 26.2.0 to 26.3.1.

See this package in npm:
i18next

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-07-06 16:00:58 +02:00
leo 74b791e207 🐛(makefile) fix passing args to tests in makefile
When using Makefile to launch tests, passing flags as well as specific
classes (using "::") was broken. This PR fixes this issue, by adding
an `ARGS` argument allowing to do
`make test ARGS="core/tests/foo.py::Test::x -vv"`.
2026-07-06 15:17:05 +02:00
Bastien Ogier df1495c97b 🚀(paas) fix scalingo frontend build failure
(paas) fix scalingo frontend build failure
2026-07-03 19:40:15 +02:00
Florent Chehab c95e1c67bd ⬆️(summary) update ffmpeg to 8.1.2
Update ffmpeg to 8.1.2 inside summary docker image.
Maintenance task.
2026-07-03 18:51:11 +02:00
Florent Chehab f115c83752 ⬆️(summary) update alpine base image
Update alpine docker base image to 3.24.
Maintenance routine task
2026-07-03 18:51:11 +02:00
snyk-bot ebfcb42a7d fix: upgrade posthog-js from 1.382.0 to 1.386.5
Snyk has created this PR to upgrade posthog-js from 1.382.0 to 1.386.5.

See this package in npm:
posthog-js

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-07-03 17:53:56 +02:00
348 changed files with 15654 additions and 8125 deletions
+1 -2
View File
@@ -82,7 +82,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: "18"
node-version: "22"
- name: Restore the mail templates
uses: actions/cache@v5
@@ -305,7 +305,6 @@ jobs:
working-directory: src/summary
env:
V1_TENANT_ID: 'test-tenant'
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
AWS_S3_ENDPOINT_URL: "minio:9000"
-29
View File
@@ -1,29 +0,0 @@
# /!\
# Security Note: This action is not hardened against prompt injection attacks and should only be used
# to review trusted PRs. Configure your repository with "Require approval for all external contributors"
# to ensure workflows only run after a maintainer has reviewed the PR.
name: Security Review
permissions:
pull-requests: write # Needed for leaving PR comments
contents: read
on:
pull_request:
branches:
- 'main'
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 2
- uses: anthropics/claude-code-security-review@0c6a49f1fa56a1d472575da86a94dbc1edb78eda
with:
comment-pr: true
exclude-directories: docs,gitlint,LICENSES,bin
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
+120
View File
@@ -8,6 +8,124 @@ and this project adheres to
## [Unreleased]
### Fixed
- 🐛(frontend) serve MediaPipe assets under a versioned path
- 🐛(frontend) harmonize cache configuration for MediaPipe assets
## [1.25.1] - 2026-08-06
### Fixed
- 🚑️(frontend) fix background crash from MediaPipe WASM version mismatch
## [1.25.0] - 2026-08-05
### Added
- ✨(summary) report exception type in failure analytics
- ✨(frontend) add configurable documentation menu item
- ✨(frontend) allow promoting authenticated participants
- ✨(frontend) introduce an "unauthenticated" participant badge
- ✨(backend) add roomkit viewset to start a room without WebRTC join
- ✨(frontend) let users set default configuration for generated links
- ✨(frontend) expose media state to external gateways
- ✨(frontend) add connection test feature
- ✨(sdk) allow passing a background color to the calendar iframe
- ✨(sdk) add a room configuration popup from CreateMeetingButton
### Changed
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.6
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
- ⚡️(frontend) limit unnecessary re-renders #1510
- 📝(legal) update terms of service
- 💄(frontend) render Avatar initials in uppercase
- 💄(frontend) improve participant name rendering in the list
- 🚚(backend) rename TelephonyService to SIPManagement
- ⬆️(dependencies) update python dependencies
### Fixed
- 🐛(transcription) fix silent bug in speaker assignment
- 🐛(summary) extend tasks auto retry logic
- 🐛(summary) properly detect when failure webhook should be sent
- 🐛(backend) preserve recording metadata when updating room access
- 🐛(backend) allow any string as sub in the API serializer
- 🐛(frontend) fall back to user.full_name on request-entry
- 🚸(frontend) show two initials in the Avatar when possible
- 🩹(all) clear the SonarCloud reliability finding and the lint debt
- 🐛(frontend) stop the installed app reopening the room it came from
- 🐛(backend) serialize lazy title in summary payload
- 💄(frontend) show pointer cursor on interactive switches
- 🐛(frontend) fix icon centering in the Switch primitive
- 🐛(frontend) keep Unicode initials intact in avatar
- 🐛(frontend) prevent concurrent settings updates from overwriting each other
## [1.24.0] - 2026-07-21
### Added
- ✨(backend) allow searching the recording admin table by owner email
- ✨(frontend) add participant color gradient when camera is off #1490
- ✨(all) allow forcing SSO display name for authenticated users
- (frontend) install vite-plugin-static-copy for MediaPipe WASM assets
- ✨(addon) show add-in tools when creating meetings in shared calendars
### Changed
- 🗑️(settings) deprecate SUMMARY_SERVICE_VERSION=1
- ⬆️(mail) update mjml to v5 and @html-to/text-cli
- 🚸(frontend) initialize the join input name with the persisted full name
- ♻️(frontend) refactor background processors to use the new API
- ♻️(frontend) inline model weights to avoid loading them from remote
- ♻️(frontend) inline MediaPipe WASM modules to avoid loading from remote
- ⬆️(frontend) upgrade posthog-js from 1.387.0 to 1.391.2
- ⬆️(frontend) upgrade react-stately from 3.47.0 to 3.48.0
- ⬆️(frontend) upgrade react-aria from 3.49.0 to 3.50.0
- ⬆️(frontend) upgrade react-aria-components from 1.18.0 to 1.19.0
### Fixed
- 🩹(backend) identify externally provisioned users to PostHog
- 🐛(backend) fix info panel crash for unregistered rooms
- ♿️(frontend) focus side panel container on open #1452
- 🐛(summary) whisper call error handling
## [1.23.0] - 2026-07-08
### Added
- ✨(backend) extend analytics module to support feature flags
- ✨(backend) implement feature flags in Posthog analytics backend
- ✨(agents) report errors to Sentry for all LiveKit agents
### Changed
- ⬆️(agents) upgrade to python 3.14 slim
- ⬆️(dependencies) update python dependencies
- 💥(summary) remove v1 related code #1362
- ✨(meet) use compatible with summary v2 #1362
- ♻️(backend) refactor analytics backend from Protocol to abstract class
- 🔥(summary) remove call to summary enabled feature flag
- ♻️(frontend) wrap MuteEveryoneButton with AdminOrOwnerOnly
- ⬆️(frontend) upgrade livekit-client from 2.19.0 to 2.19.2
- ⬆️(frontend) upgrade posthog-js from 1.386.5 to 1.387.0
- ⬆️(frontend) upgrade @tanstack/react-query from 5.100.14 to 5.101.0
- ⬆️(frontend) update the frontend build image to Node 22
- 🔒️(frontend) update docker image to nginx-unprivileged:1.30.3-alpine3.23
- ✨(summary) more precise analytics events
### Fixed
- 🚀(front) fix frontend build failure
- 🐛(makefile) fix args in make test
- 🩹(backend) fix case-insensitive email deduplication in merge command
- 🐛(summary) support media files with bad streams #1478
## [1.22.0] - 2026-07-03
### Added
@@ -19,6 +137,7 @@ and this project adheres to
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks
- 🩹(frontend) enable screen share button in PiP #1458
- 🐛(backend) support unencoded S3 notification object keys #1455
- ✨(frontend) prioritize screen share in picture-in-picture layout #1467
### Changed
@@ -26,6 +145,7 @@ and this project adheres to
- ♻️(env) refactor env variables handling
- 🚸(frontend) use "Advanced" instead of "Premium" in the sidepanel
- ♿️(frontend) make fullscreen share warning keyboard accessible #1459
- ⬆️(summary) update docker alpine to 3.24 & ffmpeg to 8.1.2 #1471
### Fixed
+1 -1
View File
@@ -37,7 +37,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- mails ----
FROM node:20 AS mail-builder
FROM node:22 AS mail-builder
COPY ./src/mail /mail/app
+27 -20
View File
@@ -44,6 +44,7 @@ COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_LINT = $(COMPOSE_RUN) --no-deps app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
@@ -51,6 +52,14 @@ WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT
MANAGE = $(COMPOSE_RUN_APP) python manage.py
MAIL_NPM = $(COMPOSE_RUN) -w /app/src/mail node npm
# -- Linters
LINT_RUFF_FORMAT = ruff format .
LINT_RUFF_CHECK = ruff check . --fix
LINT_PYLINT = pylint meet demo core
LINT_BACK = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK) \
&& echo 'lint:pylint started…' && $(LINT_PYLINT)
# -- Frontend
PATH_FRONT = ./src/frontend
@@ -124,6 +133,7 @@ logs: ## display app-dev logs (follow mode)
run-backend: ## start only the backend application and all needed services
@$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans
@$(COMPOSE) up --force-recreate -d nginx
@$(COMPOSE) up -d livekit
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
.PHONY: run-backend
@@ -188,47 +198,44 @@ demo: ## flush db then create a demo for load testing purpose
@$(MANAGE) create_demo
.PHONY: demo
# Nota bene: Black should come after isort just in case they don't agree...
lint: ## lint back-end python sources
lint: \
lint-ruff-format \
lint-ruff-check \
lint-pylint
@$(COMPOSE_RUN_LINT) sh -c "$(LINT_BACK)"
.PHONY: lint
lint-ruff-format: ## format back-end python sources with ruff
@echo 'lint:ruff-format started…'
@$(COMPOSE_RUN_APP) ruff format .
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_FORMAT)
.PHONY: lint-ruff-format
lint-ruff-check: ## lint back-end python sources with ruff
@echo 'lint:ruff-check started…'
@$(COMPOSE_RUN_APP) ruff check . --fix
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_CHECK)
.PHONY: lint-ruff-check
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
@echo 'lint:pylint started…'
@$(COMPOSE_RUN_APP) pylint meet demo core
@$(COMPOSE_RUN_LINT) $(LINT_PYLINT)
.PHONY: lint-pylint
test: ## run project tests
@$(MAKE) test-back-parallel
@$(MAKE) test-summary
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
$(MAKE) test-back-parallel ARGS="$${args}" && \
$(MAKE) test-summary ARGS="$${args}"
.PHONY: test
test-back: ## run back-end tests
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest $${args:-${1}}
test-back: ## run back-end tests (pass extra pytest args via ARGS)
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest $${args}
.PHONY: test-back
test-back-parallel: ## run all back-end tests in parallel
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest -n auto $${args:-${1}}
test-back-parallel: ## run all back-end tests in parallel (pass extra pytest args via ARGS)
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest -n auto $${args}
.PHONY: test-back-parallel
test-summary: ## run summary tests
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest-summary $${args:-${1}}
test-summary: ## run summary tests (pass extra pytest args via ARGS)
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest-summary $${args}
.PHONY: test-summary
makemigrations: ## run django makemigrations for the Meet project.
+12
View File
@@ -15,3 +15,15 @@ the following command inside your docker container:
(Note : in your development environment, you can `make migrate`.)
## [Unreleased]
## v1.23.0
As part of the 1.23.0 release, the legacy `api/v1` implementation has been removed from the _experimental_ Summary service and Meet has been migrated to the new `api/v2`.
**To avoid a breaking change, the Meet backend continues to use the Summary service's v1-compatible API format by default (`SUMMARY_SERVICE_VERSION` setting defaults to `1`).**
If you are deploying both Meet and Summary from this repository, you must configure the Meet backend to use the v2 API by setting the following environment variable `SUMMARY_SERVICE_VERSION=2`.
If you are upgrading only the Meet deployment while keeping an older Summary v1 compatible deployment, no action is required, as the v1-compatible API remains the default.
Note that we plan on removing the legacy `v1` summary compatibility in a future major version. If you have your own implementation for the summary service, we recommend updating its API contract and setting `SUMMARY_SERVICE_VERSION=2`.
+7
View File
@@ -113,6 +113,12 @@ update_python_version "summary"
# Update agents pyproject.toml
update_python_version "agents"
# Run uv lock in agents
print_info "Running uv lock in agents..."
cd "src/agents"
uv lock
cd -
# Update CHANGELOG
print_info "Updating CHANGELOG..."
@@ -158,6 +164,7 @@ echo " - src/backend/pyproject.toml"
echo " - src/backend/uv.lock"
echo " - src/summary/pyproject.toml"
echo " - src/agents/pyproject.toml"
echo " - src/agents/uv.lock"
echo " - CHANGELOG.md"
echo ""
print_warning "Next steps:"
+4 -5
View File
@@ -85,7 +85,6 @@ services:
- postgresql
- mailcatcher
- redis
- livekit
- createbuckets
- createwebhook
extra_hosts:
@@ -97,7 +96,7 @@ services:
celery-dev:
user: ${DOCKER_USER:-1000}
image: meet:backend-development
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "DEBUG"]
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "DEBUG", "--pool=solo"]
environment:
- DJANGO_CONFIGURATION=Development
env_file:
@@ -132,7 +131,7 @@ services:
celery:
user: ${DOCKER_USER:-1000}
image: meet:backend-production
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "INFO"]
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "INFO", "--pool=solo"]
environment:
- DJANGO_CONFIGURATION=Demo
env_file:
@@ -301,7 +300,7 @@ services:
context: ./src/summary
dockerfile: Dockerfile
target: production
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue-v2
env_file:
- env.d/development/summary
volumes:
@@ -321,7 +320,7 @@ services:
context: ./src/summary
dockerfile: Dockerfile
target: production
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue-v2
env_file:
- env.d/development/summary
volumes:
+2 -2
View File
@@ -1,5 +1,5 @@
# ---- Front-end image ----
FROM node:20-alpine AS frontend-deps
FROM node:22-alpine AS frontend-deps
WORKDIR /home/frontend/
@@ -54,7 +54,7 @@ RUN npx webpack --mode production
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
USER root
+5
View File
@@ -65,6 +65,11 @@ server {
sub_filter_once off;
}
location ^~ /assets/mediapipe/wasm/ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
+1
View File
@@ -344,6 +344,7 @@ These are the environmental options available on meet backend.
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
| FRONTEND_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | |
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
+4 -4
View File
@@ -11,14 +11,14 @@ There are two ways to customize LaSuite Meet:
### How to Use
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
To use this feature, simply set the `FRONTEND_CUSTOM_CSS_URL` environment variable (of the **backend** service) to the URL of your custom CSS file. For example:
```javascript
FRONTEND_CSS_URL=https://example.com/custom-style.css
FRONTEND_CUSTOM_CSS_URL=https://example.com/custom-style.css
```
> [!TIP]
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CUSTOM_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
Setting this variable makes the app load your CSS at runtime, adding a `<link>` to `<head>` so you can override CSS variables and customize the frontend without rebuilding.
@@ -37,7 +37,7 @@ Let's say you want to change the font of our application to a custom font. You c
}
```
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
Then, set the `FRONTEND_CUSTOM_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
> [!IMPORTANT]
> You can override any CSS token—semantic or palette. See [panda.config.ts](../src/frontend/panda.config.ts) for all defined semantic tokens.
+10 -2
View File
@@ -63,10 +63,11 @@ ALLOW_UNREGISTERED_ROOMS=False
# Recording
RECORDING_ENABLE=True
RECORDING_STORAGE_EVENT_ENABLE=True
RECORDING_STORAGE_EVENT_ENABLE=False
RECORDING_STORAGE_EVENT_TOKEN=password
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
SUMMARY_SERVICE_API_TOKEN=password
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Recording encoding (LiveKit Egress advanced options).
@@ -84,6 +85,10 @@ RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Telephony
ROOM_TELEPHONY_ENABLED=True
# RoomKit
# ROOMKIT_ENABLED = True
# ROOMKIT_SERVER_TO_SERVER_API_TOKEN = ThisIsAnExampleKeyForDevPurposeOnly
# Metadata
METADATA_COLLECTOR_ENABLED=True
@@ -99,3 +104,6 @@ APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/
APPLICATION_JWT_SECRET_KEY=devKey
APPLICATION_BASE_URL=http://localhost:3000
# Diagnostics
CONNECTION_TEST_ENABLED = True
@@ -9,3 +9,6 @@ DEEPGRAM_API_KEY=
KYUTAI_STT_BASE_URL=
KYUTAI_API_KEY=
SENTRY_DSN=
SENTRY_ENVIRONMENT=
+1
View File
@@ -30,3 +30,4 @@ POSTHOG_ENABLED="False"
# Transcription
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL=
AUTHORIZED_TENANTS='[{"id": "meet","api_key": "password","webhook_url": "https://configure-your-url.com/api/v1.0/recordings/external-process-hook/","webhook_api_key": "webhook-password","allowed_push_to_docs": true}]'
+52
View File
@@ -0,0 +1,52 @@
publiccodeYmlVersion: 0.5.0
name: LaSuite Meet
applicationSuite: LaSuite
url: https://github.com/suitenumerique/meet
releaseDate: 2026-07-22
platforms:
- web
organisation:
name: DINUM
uri: https://numerique.gouv.fr
fundedBy:
- name: Direction interministérielle du numérique (DINUM)
uri: https://www.numerique.gouv.fr
developmentStatus: stable
softwareType: standalone/web
intendedAudience:
countries:
- FR
description:
en:
localisedName: LaSuite Meet
shortDescription: "Open Source video conference solution, based on LiveKit"
longDescription: "Open Source video conference application, based on LiveKit,
Django and React. It is the official web video conference application of
French Ministries."
features:
- Optimized for stability in large meetings (+100 p.)
- Support for multiple screen sharing streams
- Non-persistent, secure chat
- Meeting recording
- Meeting transcription & Summary
- Telephony integration
- Secure participation with robust authentication and access control
- Customizable frontend style
legal:
license: MIT
maintenance:
type: internal
contacts:
- name: "Samuel Paccoud"
email: samuel.paccoud@numerique.gouv.fr
affiliation: DINUM
- name: "Antoine Lebaud"
email: antoine.lebaud.ext@numerique.gouv.fr
affiliation: DINUM
localisation:
localisationReady: true
availableLanguages:
- fr
- de
- en
- nl
+170 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
<Version>0.0.2.0</Version>
<Version>1.0.0.0</Version>
<ProviderName>__APP_NAME__</ProviderName>
<DefaultLocale>fr-FR</DefaultLocale>
<DisplayName DefaultValue="__APP_NAME__"/>
@@ -205,5 +205,174 @@
</bt:String>
</bt:LongStrings>
</Resources>
<!-- ─── V1.1 override: required for shared folder / delegate support ─── -->
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides/1.1" xsi:type="VersionOverridesV1_1">
<Requirements>
<bt:Sets DefaultMinVersion="1.8">
<bt:Set Name="Mailbox"/>
</bt:Sets>
</Requirements>
<Hosts>
<Host xsi:type="MailHost">
<DesktopFormFactor>
<FunctionFile resid="Commands.Url"/>
<SupportsSharedFolders>true</SupportsSharedFolders>
<!-- ─── Mail: Read ─────────────────────────────────────────── -->
<ExtensionPoint xsi:type="MessageReadCommandSurface">
<OfficeTab id="TabDefault">
<Group id="msgReadGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="msgReadOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- ─── Mail: Compose ─────────────────────────────────────── -->
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
<OfficeTab id="TabDefault">
<Group id="msgComposeGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="msgComposeGenerateLinkButton">
<Label resid="GenerateLink.Label"/>
<Supertip>
<Title resid="GenerateLink.Label"/>
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromMail</FunctionName>
</Action>
</Control>
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Settings.16x16"/>
<bt:Image size="32" resid="Settings.32x32"/>
<bt:Image size="80" resid="Settings.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- ─── Calendar: Compose (New/Edit appointment) ──────────── -->
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
<OfficeTab id="TabDefault">
<Group id="apptComposeGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="apptGenerateLinkButton">
<Label resid="GenerateLink.Label"/>
<Supertip>
<Title resid="GenerateLink.Label"/>
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
</Action>
</Control>
<Control xsi:type="Button" id="apptOpenSettingsButton">
<Label resid="OpenSettings.Label"/>
<Supertip>
<Title resid="OpenSettings.Label"/>
<Description resid="OpenSettings.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Settings.16x16"/>
<bt:Image size="32" resid="Settings.32x32"/>
<bt:Image size="80" resid="Settings.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="Settings.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-16.png"/>
<bt:Image id="Settings.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-32.png"/>
<bt:Image id="Settings.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-80.png"/>
<bt:Image id="Add.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/add-16.png"/>
<bt:Image id="Add.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/add-32.png"/>
<bt:Image id="Add.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/add-80.png"/>
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-16.png"/>
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-32.png"/>
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-80.png"/>
</bt:Images>
<bt:Urls>
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/addons/outlook/commands.html"/>
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/addons/outlook/taskpane.html"/>
</bt:Urls>
<bt:ShortStrings>
<!-- Default (French) -->
<bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/>
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__">
<bt:Override Locale="en-US" Value="Add a __APP_NAME__ link"/>
<bt:Override Locale="de-DE" Value="__APP_NAME__-Link hinzufügen"/>
</bt:String>
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres">
<bt:Override Locale="en-US" Value="Open settings"/>
<bt:Override Locale="de-DE" Value="Einstellungen öffnen"/>
</bt:String>
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres">
<bt:Override Locale="en-US" Value="Settings"/>
<bt:Override Locale="de-DE" Value="Einstellungen"/>
</bt:String>
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement.">
<bt:Override Locale="de-DE" Value="Generiert einen __APP_NAME__-Besprechungslink und fügt ihn in den Termin ein."/>
<bt:Override Locale="en-US" Value="Generates a __APP_NAME__ meeting link and inserts it into the item."/>
</bt:String>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
</bt:String>
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
</bt:String>
</bt:LongStrings>
</Resources>
</VersionOverrides>
</VersionOverrides>
</OfficeApp>
+5 -5
View File
@@ -10,7 +10,7 @@
"license": "MIT",
"dependencies": {
"core-js": "3.49.0",
"i18next": "26.3.1",
"i18next": "^26.3.6",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
@@ -9364,9 +9364,9 @@
}
},
"node_modules/i18next": {
"version": "26.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
"version": "26.3.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
"funding": [
{
"type": "individual",
@@ -9383,7 +9383,7 @@
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6"
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"typescript": {
+1 -1
View File
@@ -27,7 +27,7 @@
},
"dependencies": {
"core-js": "3.49.0",
"i18next": "26.3.1",
"i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
@@ -57,8 +57,7 @@
data-i18n="footer.feedback"
></a>
<div id="footer-right">
<span class="version-badge">beta</span>
<span class="version-number">0.0.2</span>
<span class="version-number">1.0.0</span>
</div>
</footer>
</body>
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.13.13-slim AS base
FROM python:3.14.6-slim AS base
# Install system dependencies required by LiveKit
RUN apt-get update && apt-get install -y \
+26 -10
View File
@@ -32,6 +32,8 @@ from minio import Minio
from minio.error import S3Error
from exceptions import MissingConfigError
from observability import configure_sentry, set_job_context
from tasks import done_callback
load_dotenv()
@@ -42,6 +44,7 @@ AGENT_NAME = os.getenv("METADATA_COLLECTOR_AGENT_NAME", "metadata-collector")
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
configure_sentry(AGENT_NAME)
proc.userdata["vad"] = silero.VAD.load()
@@ -174,7 +177,13 @@ class MetadataCollector:
self.on_chat_message_received(reader, participant_identity)
)
self._tasks.add(task)
task.add_done_callback(lambda _: self._tasks.remove(task))
task.add_done_callback(
done_callback(
logger,
self._tasks,
f"process chat stream from {participant_identity}",
)
)
def save(self):
"""Serialize collected events and upload as JSON to S3."""
@@ -270,16 +279,18 @@ class MetadataCollector:
logger.info("Participant disconnected: %s", participant.identity)
task = asyncio.create_task(self._close_session(session))
self._tasks.add(task)
def on_close_done(_):
self._tasks.discard(task)
logger.info(
"VAD session closed for %s (remaining sessions: %d)",
participant.identity,
len(self._sessions),
task.add_done_callback(
done_callback(
logger,
self._tasks,
f"close VAD session for {participant.identity}",
on_success=lambda _: logger.info(
"VAD session closed for %s (remaining sessions: %d)",
participant.identity,
len(self._sessions),
),
)
task.add_done_callback(on_close_done)
)
def on_participant_name_changed(self, participant: rtc.RemoteParticipant):
"""Update stored participant name when it changes."""
@@ -360,6 +371,8 @@ async def handle_job_request(job_req: JobRequest) -> None:
@server.rtc_session(agent_name=AGENT_NAME, on_request=handle_job_request)
async def entrypoint(ctx: JobContext):
"""Initialize and run the metadata collector."""
set_job_context(room=ctx.room.name, job_id=ctx.job.id)
logger.info("Starting metadata agent in room: %s", ctx.room.name)
recording_id = ctx.job.metadata
metadata_collector = MetadataCollector(ctx, recording_id)
@@ -377,4 +390,7 @@ async def entrypoint(ctx: JobContext):
if __name__ == "__main__":
# Initialize Sentry for the worker process. Each job runs in its own
# (forked) process and re-initializes Sentry via prewarm().
configure_sentry(AGENT_NAME)
cli.run_app(server)
+25 -10
View File
@@ -25,6 +25,9 @@ from livekit.agents import (
)
from livekit.plugins import deepgram, silero
from observability import configure_sentry, set_job_context
from tasks import done_callback
load_dotenv()
logger = logging.getLogger("transcriber")
@@ -99,24 +102,29 @@ class MultiUserTranscriber:
logger.info(f"starting session for {participant.identity}")
task = asyncio.create_task(self._start_session(participant))
self._tasks.add(task)
def on_task_done(task: asyncio.Task):
try:
self._sessions[participant.identity] = task.result()
finally:
self._tasks.discard(task)
task.add_done_callback(on_task_done)
task.add_done_callback(
done_callback(
logger,
self._tasks,
f"start transcription session for {participant.identity}",
)
)
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
"""Handle participant disconnection by closing transcription session."""
if (session := self._sessions.pop(participant.identity)) is None:
if (session := self._sessions.pop(participant.identity, None)) is None:
return
logger.info(f"closing session for {participant.identity}")
task = asyncio.create_task(self._close_session(session))
self._tasks.add(task)
task.add_done_callback(lambda _: self._tasks.discard(task))
task.add_done_callback(
done_callback(
logger,
self._tasks,
f"close transcription session for {participant.identity}",
)
)
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
"""Create and start transcription session for participant."""
@@ -139,6 +147,7 @@ class MultiUserTranscriber:
participant_identity=participant.identity,
)
)
self._sessions[participant.identity] = session
return session
async def _close_session(self, sess: AgentSession) -> None:
@@ -149,6 +158,8 @@ class MultiUserTranscriber:
async def entrypoint(ctx: JobContext):
"""Initialize and run the multi-user transcriber."""
set_job_context(room=ctx.room.name, job_id=ctx.job.id)
transcriber = MultiUserTranscriber(ctx)
transcriber.start()
@@ -193,11 +204,15 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
configure_sentry(TRANSCRIBER_AGENT_NAME)
if ENABLE_SILERO_VAD:
proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__":
# Initialize Sentry for the worker process. Each job runs in its own
# (forked) process and re-initializes Sentry via prewarm().
configure_sentry(TRANSCRIBER_AGENT_NAME)
cli.run_app(
WorkerOptions(
entrypoint_fnc=entrypoint,
+83
View File
@@ -0,0 +1,83 @@
"""Sentry helpers for the LiveKit agents."""
import logging
import os
import tomllib
from os import path
import sentry_sdk
from sentry_sdk.integrations.logging import LoggingIntegration
logger = logging.getLogger("observability")
BASE_DIR = path.dirname(path.abspath(__file__))
def get_release():
"""Get the current release of the application.
By release, we mean the ``version`` declared in ``pyproject.toml``.
If the file cannot be read or declares no version, it defaults to "NA".
"""
try:
with open(path.join(BASE_DIR, "pyproject.toml"), "rb") as pyproject:
return tomllib.load(pyproject)["project"]["version"]
except (FileNotFoundError, KeyError, tomllib.TOMLDecodeError):
return "NA" # Default: not available
def configure_sentry(agent_name: str) -> None:
"""Initialize Sentry for the current agent process.
No-op if ``SENTRY_DSN`` is not configured. Otherwise (re)initializes Sentry
unconditionally so the calling process gets its own live transport.
Must be called once per process: in the worker entrypoint and again in the
per-job ``prewarm``/``setup_fnc`` hook, because LiveKit runs each job in a
forked process. A forked child inherits the parent's initialized Sentry
client but not its background transport thread (threads do not survive
``fork()``), so it must re-init to get a working transport. For that reason,
do NOT guard this with ``sentry_sdk.is_initialized()``: the child inherits it
as ``True`` and would skip init, silently dropping every event.
Args:
agent_name: Identifier of the agent, attached as a tag to Sentry issues
"""
# Read the DSN at call time so it picks up variables that load_dotenv()
# populated after this module was first imported.
sentry_dsn = os.getenv("SENTRY_DSN")
if not sentry_dsn:
logger.debug("SENTRY_DSN not defined for agent '%s'", agent_name)
return
sentry_sdk.init(
dsn=sentry_dsn,
environment=os.getenv("SENTRY_ENVIRONMENT"),
release=get_release(),
debug=False,
integrations=[
# Capture log records emitted at ERROR and above as Sentry events.
# This covers the agents' explicit logger.exception(...) calls as
# well as asyncio's "Exception in callback" / "Task exception was
# never retrieved" records, so unhandled task failures surface too.
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
],
)
sentry_sdk.set_tag("application", "agents")
sentry_sdk.set_tag("agent", agent_name)
logger.info("Sentry initialized for agent '%s' (pid %d)", agent_name, os.getpid())
def set_job_context(*, room: str | None = None, job_id: str | None = None) -> None:
"""Tag the current Sentry scope with the LiveKit job being handled.
Args:
room: Name of the room the job is serving.
job_id: LiveKit job identifier.
"""
scope = sentry_sdk.get_current_scope()
if room is not None:
scope.set_tag("room", room)
if job_id is not None:
scope.set_tag("job_id", job_id)
+7 -6
View File
@@ -1,21 +1,22 @@
[project]
name = "agents"
version = "1.22.0"
version = "1.25.1"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.5.13",
"livekit-plugins-deepgram==1.5.13",
"livekit-plugins-silero==1.5.13",
"livekit-agents==1.6.7",
"livekit-plugins-deepgram==1.6.7",
"livekit-plugins-silero==1.6.7",
"livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.2",
"protobuf==6.33.6",
"minio==7.2.20"
"minio==7.2.20",
"sentry-sdk==2.66.1",
]
[project.optional-dependencies]
dev = [
"ruff==0.15.14",
"ruff==0.16.0",
]
[tool.uv]
+42
View File
@@ -0,0 +1,42 @@
"""Helpers for managing asyncio tasks."""
import asyncio
import logging
from collections.abc import Callable
from typing import Any
def done_callback(
logger: logging.Logger,
tasks: set[asyncio.Task],
description: str,
*,
on_success: Callable[[Any], None] | None = None,
) -> Callable[[asyncio.Task], None]:
"""Build a done-callback for a background task.
Meant to be passed to `asyncio.Task.add_done_callback`.
Args:
logger: Logger used to report failures, so records keep the caller's
logger name.
tasks: Set the task was registered in; the task is discarded from it.
description: Human-readable intended action
on_success: Optional callback invoked with the task's result when it
completes without error.
Returns:
A callback suitable for ``task.add_done_callback(...)``.
"""
def _finalize(task: asyncio.Task) -> None:
tasks.discard(task)
if task.cancelled():
return
if (exc := task.exception()) is not None:
logger.exception("failed to %s", description, exc_info=exc)
return
if on_success is not None:
on_success(task.result())
return _finalize
+936 -832
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -11,7 +11,7 @@ from core.recording.event import notification
from . import models
from .tasks.file import process_file_deletion
from .utils import generate_download_file_url
from .utils import generate_download_s3_url
def hard_delete_file(file):
@@ -242,7 +242,7 @@ class FileAdmin(admin.ModelAdmin):
"""Return a clickable preview URL for the file."""
if not obj.is_ready:
return "-"
url = generate_download_file_url(obj, expires_in=60 * 60)
url = generate_download_s3_url(obj.key, expires_in=60 * 60)
return format_html(
'<a href="{}" target="_blank" rel="noopener noreferrer">Open File</a>', url
@@ -402,7 +402,14 @@ class RecordingAdmin(admin.ModelAdmin):
"""Recording admin interface declaration."""
inlines = (RecordingAccessInline,)
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
search_fields = [
"status",
"=id",
"worker_id",
"room__slug",
"=room__id",
"accesses__user__email",
]
list_display = (
"id",
"status",
+8
View File
@@ -19,6 +19,7 @@ from django.utils.module_loading import import_string
from .base import AnalyticsBackend, NoOpAnalytics
from .events import AnalyticsEvent
from .user_feature_flags import UserFeatureFlag
__all__ = [
"get_analytics",
@@ -26,6 +27,8 @@ __all__ = [
"capture",
"AnalyticsBackend",
"AnalyticsEvent",
"is_user_feature_flag_enabled",
"UserFeatureFlag",
]
@@ -57,3 +60,8 @@ def capture(
) -> None:
"""Record an event performed by an identified user."""
analytics_instance.capture(user, event, properties)
def is_user_feature_flag_enabled(user, feature_name: UserFeatureFlag) -> bool:
"""Check if a feature is enabled at the user level."""
return analytics_instance.is_user_feature_enabled(user, feature_name)
+23 -8
View File
@@ -1,12 +1,14 @@
"""Analytics backend protocol and default no-op implementation."""
from typing import Any, Protocol
from abc import ABC, abstractmethod
from typing import Any, Mapping
from ..models import User
from .events import AnalyticsEvent
from .user_feature_flags import UserFeatureFlag
class AnalyticsBackend(Protocol):
class AnalyticsBackend(ABC):
"""
Interface every analytics backend must implement.
@@ -17,11 +19,11 @@ class AnalyticsBackend(Protocol):
ANALYTICS_BACKEND_SETTINGS = {"api_key": "...", "host": "..."}
"""
def __init__(self, **kwargs: Any) -> None: ...
@abstractmethod
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
"""Associate traits (email, name, ...) with an identified user."""
@abstractmethod
def capture(
self,
user: User,
@@ -30,16 +32,29 @@ class AnalyticsBackend(Protocol):
) -> None:
"""Record an event performed by an identified user."""
@abstractmethod
def shutdown(self) -> None:
"""Flush pending events. Called on process exit."""
def get_user_feature_flags(
self,
user: User, # pylint: disable=unused-argument
) -> Mapping[UserFeatureFlag, bool | str | None]:
"""Return a dict of feature flags for the given user."""
# We return an empty dict here by default to avoid a breaking change
# By making this method abstract.
return {}
class NoOpAnalytics:
def is_user_feature_enabled(
self, user: User, feature_name: UserFeatureFlag
) -> bool:
"""Check if a feature is enabled at the user level."""
return self.get_user_feature_flags(user).get(feature_name, False) is True
class NoOpAnalytics(AnalyticsBackend):
"""Default backend: silently discards everything."""
def __init__(self, **kwargs: Any) -> None:
"""No-op: accepts and ignores any backend settings kwargs."""
def identify(self, user: User, properties=None) -> None:
"""No-op: discards identify calls."""
+3
View File
@@ -8,3 +8,6 @@ class AnalyticsEvent(StrEnum):
# Rooms
ROOM_CREATED = "room_created"
# Roomkit (meeting-room SIP devices)
ROOMKIT_JOINED = "roomkit_joined"
+44 -2
View File
@@ -1,17 +1,21 @@
"""PostHog implementation of the analytics backend protocol."""
import logging
from typing import Any
from typing import Any, Mapping
from django.core.cache import cache
from posthog import Posthog
from ..models import User
from .base import AnalyticsBackend
from .events import AnalyticsEvent
from .user_feature_flags import UserFeatureFlag
logger = logging.getLogger(__name__)
class PostHogAnalytics:
class PostHogAnalytics(AnalyticsBackend):
"""Send events to PostHog, keyed on the user's primary key (UUID)."""
def __init__(
@@ -19,6 +23,8 @@ class PostHogAnalytics:
*,
api_key: str,
host: str = "https://eu.i.posthog.com",
feature_flags_cache_ttl: int = 60,
feature_flags_cache_prefix: str = "user_feature_flags:",
**kwargs: Any,
) -> None:
@@ -29,6 +35,8 @@ class PostHogAnalytics:
host=host,
**kwargs,
)
self._feature_flags_cache_ttl = feature_flags_cache_ttl
self._feature_flags_cache_prefix = feature_flags_cache_prefix
@staticmethod
def _distinct_id(user: User) -> str | None:
@@ -72,3 +80,37 @@ class PostHogAnalytics:
def shutdown(self) -> None:
"""Flush pending events. Called on process exit."""
self._client.shutdown()
def _fetch_user_feature_flags(
self, user: User
) -> Mapping[UserFeatureFlag, bool | str | None]:
"""Compute feature flags for a user."""
distinct_id = self._distinct_id(user)
if distinct_id is None:
return {}
flags = self._client.evaluate_flags(distinct_id)
out: dict[UserFeatureFlag, bool | str | None] = {}
for flag_key in UserFeatureFlag:
out[flag_key] = flags.get_flag(flag_key.value)
return out
def get_user_feature_flags(
self, user: User
) -> Mapping[UserFeatureFlag, bool | str | None]:
"""Get feature flags for a user. Caches the result for a short time."""
distinct_id = self._distinct_id(user)
if distinct_id is None:
return {}
try:
return cache.get_or_set(
f"{self._feature_flags_cache_prefix}{distinct_id}",
default=lambda: self._fetch_user_feature_flags(user),
timeout=self._feature_flags_cache_ttl,
)
except Exception: # pylint: disable=broad-exception-caught
logger.exception("Failed to get feature flags for user %s", user.pk)
return {}
@@ -0,0 +1,9 @@
"""Catalog of all analytics feature flags used by the backend."""
from enum import StrEnum
class UserFeatureFlag(StrEnum):
"""All feature flags configured in the app."""
TRANSCRIPT_SUMMARY_ENABLED = "summary-enabled"
+7
View File
@@ -61,13 +61,20 @@ def get_frontend_configuration(request):
],
},
"telephony": build_telephony_config(),
"resource": {
"default_access_level": settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
},
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
"diagnostics": {"connection_test_enabled": settings.CONNECTION_TEST_ENABLED},
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
},
"authenticated_users_can_edit_display_name": (
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
),
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration)
+2
View File
@@ -16,6 +16,8 @@ class FeatureFlag:
"file_upload": "FILE_UPLOAD_ENABLED",
"addons": "ADDONS_ENABLED",
"application": "APPLICATION_ENABLED",
"roomkit": "ROOMKIT_ENABLED",
"connection_test": "CONNECTION_TEST_ENABLED",
}
@classmethod
+32
View File
@@ -6,6 +6,11 @@ from django.http import Http404
from rest_framework import permissions
from ..models import RoleChoices
from ..services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagement,
ParticipantsManagementException,
)
ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
@@ -166,3 +171,30 @@ class CanMuteParticipant(permissions.BasePermission):
# LiveKit token scoped to this room
return request.auth.video.room == str(obj.id)
class IsPresentInMeeting(permissions.BasePermission):
"""Check that the requesting user is currently connected to the meeting.
The requester must be session-authenticated (their DB identity is needed
to check privileges); presence is verified against LiveKit using their
`sub` as participant identity. Fails closed on LiveKit errors.
"""
message = "You must be connected to the meeting to perform this action."
def has_object_permission(self, request, view, obj):
"""Verify the requester's identity is a participant of the room."""
user = request.user
if not user or not user.is_authenticated:
return False
try:
return ParticipantsManagement().check_if_in_meeting(
room_name=str(obj.pk), identity=str(user.sub)
)
except ParticipantNotFoundException:
return False
except ParticipantsManagementException:
return False
+42 -6
View File
@@ -31,9 +31,28 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
fields = [
"id",
"email",
"full_name",
"short_name",
"timezone",
"language",
"default_room_access_level",
"default_room_configuration",
]
read_only_fields = ["id", "email", "full_name", "short_name"]
def validate_default_room_configuration(self, value):
"""Validate the default room configuration against the RoomConfiguration schema."""
if value is None or value == {}:
return value
try:
RoomConfiguration.model_validate(value)
except PydanticValidationError as e:
raise serializers.ValidationError(e.errors()) from e
return value
class UserLightSerializer(serializers.ModelSerializer):
"""Serialize users with limited fields."""
@@ -183,13 +202,11 @@ class RoomSerializer(serializers.ModelSerializer):
user=request.user,
username=username,
configuration=output["configuration"],
is_admin_or_owner=is_admin_or_owner,
role=role,
)
else:
del output["pin_code"]
output["is_administrable"] = is_admin_or_owner
return output
@@ -299,8 +316,8 @@ class RoomInviteSerializer(serializers.Serializer):
class BaseParticipantsManagementSerializer(BaseValidationOnlySerializer):
"""Base serializer for participant management operations."""
participant_identity = serializers.UUIDField(
help_text="LiveKit participant identity (UUID format)"
participant_identity = serializers.CharField(
help_text="LiveKit participant identity (matching the user's sub format)"
)
@@ -312,6 +329,15 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
)
class ParticipantRoleSerializer(BaseParticipantsManagementSerializer):
"""Validate an in-meeting role change (promotion/demotion) request."""
role = serializers.ChoiceField(
choices=[models.RoleChoices.MEMBER, models.RoleChoices.ADMIN],
help_text="Target role. Ownership cannot be granted this way.",
)
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
@@ -563,3 +589,13 @@ class RenameParticipantSerializer(BaseValidationOnlySerializer):
"""Serializer for renaming a participant in a room."""
name = serializers.CharField(min_length=1, max_length=255, allow_blank=False)
class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
"""Validate external process event data."""
job_id = serializers.CharField(required=True)
# We are not strict on purpose on those fields to avoid
# useless bad requests
type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
status = serializers.CharField(required=False, allow_null=True, allow_blank=True)
+24
View File
@@ -73,3 +73,27 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback"""
scope = "creation_callback"
class RoomKitJoinRateThrottle(MonitoredUserRateThrottle):
"""Throttle the LiveKit SIP module requesting roomkit joins.
The roomkit endpoints are authenticated as a machine user, so all requests
share a single throttle bucket. This is not a security measure against
brute-force attacks but a guard against accidental hammering from a buggy
SIP module.
"""
scope = "roomkit_join"
class ConnectionTestUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle authenticated users requesting connection test tokens."""
scope = "connection_test"
class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle anonymous users requesting connection test tokens."""
scope = "connection_test"
+207 -3
View File
@@ -2,8 +2,10 @@
# pylint: disable=too-many-lines
import uuid
from datetime import timedelta
from logging import getLogger
from urllib.parse import unquote, urlparse
from uuid import uuid4
from django.conf import settings
from django.core.exceptions import ValidationError as DjangoValidationError
@@ -27,6 +29,9 @@ from rest_framework import (
from rest_framework import (
exceptions as drf_exceptions,
)
from rest_framework import (
permissions as drf_permissions,
)
from rest_framework import (
response as drf_response,
)
@@ -36,10 +41,14 @@ from rest_framework import (
from rest_framework.settings import api_settings
from core import analytics, enums, models, utils
from core.api import throttling
from core.api.filters import ListFileFilter
from core.enums import MEDIA_STORAGE_URL_PATTERN
from core.recording.enums import FileExtension
from core.recording.event.authentication import StorageEventAuthentication
from core.recording.event.authentication import (
RecordingProcessWebhookAuthentication,
StorageEventAuthentication,
)
from core.recording.event.exceptions import (
InvalidBucketError,
InvalidFilepathError,
@@ -85,10 +94,17 @@ from core.services.room_management import (
RoomManagementException,
RoomNotFoundException,
)
from core.services.room_roles import (
RoomRoleError,
RoomRoleService,
)
from core.services.subtitle import SubtitleException, SubtitleService
from core.tasks.connection_test import delete_connection_test_room
from core.tasks.file import process_file_deletion
from core.utils import generate_token
from ..authentication.livekit import LiveKitTokenAuthentication
from ..models import RoomAccessLevel
from . import permissions, serializers, throttling
from .feature_flag import FeatureFlag
@@ -264,6 +280,9 @@ class RoomViewSet(
username = request.query_params.get("username", None)
data = {
"id": None,
"slug": slug,
"is_administrable": False,
"access_level": RoomAccessLevel.PUBLIC,
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": slug,
@@ -297,8 +316,27 @@ class RoomViewSet(
return drf_response.Response(serializer.data)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created room."""
room = serializer.save()
"""Set the current user as owner of the newly created room.
Apply the user's default room preferences (access level and configuration)
unless the request explicitly provides its own values.
"""
user = self.request.user
save_kwargs = {}
if (
"access_level" not in serializer.validated_data
and user.default_room_access_level not in (None, "")
):
save_kwargs["access_level"] = user.default_room_access_level
user_default_configuration = user.default_room_configuration
if not serializer.validated_data.get(
"configuration"
) and user_default_configuration not in (None, {}):
save_kwargs["configuration"] = user.default_room_configuration
room = serializer.save(**save_kwargs)
models.ResourceAccess.objects.create(
resource=room,
user=self.request.user,
@@ -632,6 +670,53 @@ class RoomViewSet(
status=drf_status.HTTP_200_OK,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="update-participant-role",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.IsPresentInMeeting,
],
)
def update_participant_role(self, request, pk=None): # pylint: disable=unused-argument
"""Promote or demote a participant currently connected to the meeting.
Requires the requester to be session-authenticated, have privileges
(admin/owner) on the room, and be connected to the meeting.
If the target participant has a user account, the role is persisted
(`ResourceAccess`) then mirrored to their LiveKit attributes.
If the participant is anonymous, the promotion will fail.
"""
room = self.get_object()
serializer = serializers.ParticipantRoleSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
participant_identity = serializer.validated_data["participant_identity"]
role = serializer.validated_data["role"]
if str(request.user.sub) == str(participant_identity):
return drf_response.Response(
{"error": "You cannot change your own role."},
status=drf_status.HTTP_403_FORBIDDEN,
)
try:
result = RoomRoleService().set_participant_role(
room=room,
participant_identity=participant_identity,
role=role,
actor=request.user,
)
except RoomRoleError as e:
return drf_response.Response({"error": str(e)}, status=e.status_code)
return drf_response.Response(result, status=drf_status.HTTP_200_OK)
@decorators.action(
detail=True,
methods=["post"],
@@ -999,6 +1084,60 @@ class RecordingViewSet(
{"message": "Event processed."},
)
@decorators.action(
detail=False,
methods=["post"],
url_path="external-process-hook",
authentication_classes=[RecordingProcessWebhookAuthentication],
serializer_class=serializers.ExternalProcessEventSerializer,
)
def on_external_process_event_received(self, request, pk=None): # pylint: disable=unused-argument
"""Handle incoming external process events for recordings."""
logger.debug("Processing external process event %s", request.data)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
ok_response = drf_response.Response(
{"message": "Event processed."},
)
validated_data = serializer.validated_data
job_id = validated_data["job_id"]
try:
recording = models.Recording.objects.get(external_process_id=job_id)
except models.Recording.DoesNotExist as e:
logger.warning("No recording found for job_id %s: %s", job_id, e)
return ok_response
if validated_data.get("type") == "transcript":
if validated_data.get("status") == "success":
logger.info(
"External process transcript success received for recording %s",
job_id,
)
recording.status = (
models.RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL
)
recording.save()
return ok_response
if validated_data.get("status") == "failure":
logger.info(
"External process transcript failure received for recording %s",
job_id,
)
recording.status = models.RecordingStatusChoices.EXTERNAL_PROCESS_FAILED
recording.save()
return ok_response
logger.info(
"No changes to save for external process id %s and payload %s",
job_id,
validated_data,
)
return ok_response
def _auth_get_original_url(self, request):
"""
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
@@ -1432,3 +1571,68 @@ class FileViewSet(
request = utils.generate_s3_authorization_headers(f"{url_params.get('key'):s}")
return drf_response.Response("authorized", headers=request.headers, status=200)
class DiagnosticsViewSet(viewsets.ViewSet):
"""Endpoints helping users and support diagnose connectivity issues.
Diagnostics are grouped behind a single prefix so upcoming checks
(rtcstats collection, ICE candidate reports, etc.) can be added as new
actions rather than new top-level routes.
They are open to anonymous users: someone who cannot join a room is
exactly who needs to run a test, and they may well not be logged in.
Each action therefore carries its own throttle scope.
"""
permission_classes = [drf_permissions.AllowAny]
@decorators.action(
detail=False,
methods=["POST"],
url_path="connection",
url_name="connection",
throttle_classes=[
throttling.ConnectionTestUserRateThrottle,
throttling.ConnectionTestAnonRateThrottle,
],
)
@FeatureFlag.require("connection_test")
def connection(self, request):
"""Return a short-lived LiveKit token for an ephemeral test room.
Going through the room API is not an option here: it is tied to
registered meetings, lobby rules and longer-lived tokens. Each call
gets its own room so two people testing at the same time never meet.
"""
room = f"{settings.CONNECTION_TEST_ROOM_PREFIX}-{uuid4()}"
expires_in = settings.CONNECTION_TEST_TOKEN_TTL_SECONDS
# LiveKit refreshes tokens for connected clients, so JWT TTL alone does not
# eject someone who stays connected. Schedule a hard DeleteRoom when Celery
# is available.
if settings.CELERY_ENABLED:
max_age = (
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS
+ settings.CONNECTION_TEST_ROOM_EXTRA_AGE_SECONDS
)
delete_connection_test_room.apply_async(
args=[room],
countdown=max_age,
)
return drf_response.Response(
{
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": room,
"token": generate_token(
room=room,
user=request.user,
username="Connection Test",
ttl=timedelta(seconds=expires_in),
),
"expires_in": expires_in,
},
}
)
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
class BaseJWTAuthentication(authentication.BaseAuthentication):
"""Base JWT authentication class."""
def __init__(
def __init__( # noqa: PLR0917
self,
secret_key,
algorithm,
@@ -215,5 +215,6 @@ class RoomViewSet(
"client_id": client_id,
"external_api": True,
"auth_method": auth_method,
"$set": {"email": self.request.user.email},
},
)
@@ -6,6 +6,7 @@ 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 django.db.models.functions import Lower
from core.models import File, RecordingAccess, ResourceAccess, RoleChoices
@@ -20,11 +21,14 @@ ROLE_PRIORITY = {
class Command(BaseCommand):
"""
Merge duplicate users sharing the same email into the most recently created one.
Merge duplicate users sharing the same email (case-insensitive) 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.
Emails are compared case-insensitively, so 'John@Example.com' and
'john@example.com' are treated as duplicates. 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.
"""
@@ -56,13 +60,16 @@ class Command(BaseCommand):
users_qs = users_qs.filter(email__icontains=email_filter)
self.stdout.write(f"[INFO] Filtering emails containing '{email_filter}'.\n")
# Group emails case-insensitively so 'John@X.com' and 'john@x.com'
# are detected as duplicates of each other.
duplicate_emails = (
users_qs.exclude(email__isnull=True)
.exclude(email="")
.values("email")
.annotate(email_lower=Lower("email"))
.values("email_lower")
.annotate(cnt=Count("id"))
.filter(cnt__gt=1)
.values_list("email", flat=True)
.values_list("email_lower", flat=True)
)
if not duplicate_emails:
@@ -78,9 +85,12 @@ class Command(BaseCommand):
failed_emails = []
for email in duplicate_emails:
# Case-insensitive lookup to fetch every casing variant of the email.
# 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"))
users = list(
User.objects.filter(email__iexact=email).order_by("created_at", "id")
)
kept_user = users[-1]
stale_users = users[:-1]
@@ -120,6 +130,10 @@ class Command(BaseCommand):
failed_emails.append(email)
self.stderr.write(f"[ERROR] Failed to merge '{email}': {exc}")
if not kept_user.email.islower():
kept_user.email = kept_user.email.lower()
kept_user.save(update_fields=["email"])
if failed_emails:
raise CommandError(
f"Failed to merge {len(failed_emails)} email group(s): {', '.join(failed_emails)}"
@@ -0,0 +1,23 @@
# Generated by Django 5.2.14 on 2026-06-22 08:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0020_alter_file_upload_state'),
]
operations = [
migrations.AddField(
model_name='recording',
name='external_process_id',
field=models.CharField(blank=True, help_text='ID of the external process associated with the recording.', max_length=255, null=True, unique=True, verbose_name='External Process ID'),
),
migrations.AlterField(
model_name='recording',
name='status',
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded'), ('external_process_successful', 'External process successful'), ('external_process_failed', 'External process failed')], default='initiated', max_length=50),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.2.14 on 2026-08-03 13:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0021_recording_external_process_id_alter_recording_status'),
]
operations = [
migrations.AddField(
model_name='user',
name='default_room_access_level',
field=models.CharField(blank=True, choices=[('public', 'Public Access'), ('trusted', 'Trusted Access'), ('restricted', 'Restricted Access')], help_text='Access level applied by default to new rooms created by this user. When empty, the instance default is used.', max_length=50, null=True, verbose_name='default room access level'),
),
migrations.AddField(
model_name='user',
name='default_room_configuration',
field=models.JSONField(blank=True, default=dict, help_text='Configurations applied by default to new rooms created by this user.', verbose_name='default room configuration'),
),
]
+44 -1
View File
@@ -60,6 +60,11 @@ class RecordingStatusChoices(models.TextChoices):
FAILED_TO_START = "failed_to_start", _("Failed to Start")
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
EXTERNAL_PROCESS_SUCCESSFUL = (
"external_process_successful",
_("External process successful"),
)
EXTERNAL_PROCESS_FAILED = "external_process_failed", _("External process failed")
@classmethod
def is_final(cls, status):
@@ -73,6 +78,8 @@ class RecordingStatusChoices(models.TextChoices):
cls.STOPPED,
cls.SAVED,
cls.ABORTED,
cls.EXTERNAL_PROCESS_SUCCESSFUL,
cls.EXTERNAL_PROCESS_FAILED,
cls.FAILED_TO_START,
cls.FAILED_TO_STOP,
}
@@ -182,6 +189,25 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
default=settings.TIME_ZONE,
help_text=_("The timezone in which the user wants to see times."),
)
default_room_access_level = models.CharField(
max_length=50,
choices=RoomAccessLevel.choices,
blank=True,
null=True,
verbose_name=_("default room access level"),
help_text=_(
"Access level applied by default to new rooms created by this user. "
"When empty, the instance default is used."
),
)
default_room_configuration = models.JSONField(
blank=True,
default=dict,
verbose_name=_("default room configuration"),
help_text=_(
"Configurations applied by default to new rooms created by this user."
),
)
is_device = models.BooleanField(
_("device"),
default=False,
@@ -422,7 +448,14 @@ class Room(Resource):
def save(self, *args, **kwargs):
"""Generate a unique n-digit pin code for new rooms."""
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
# Roomkit devices also join by PIN, so a PIN is needed as soon as
# either integration is enabled.
if (
(settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED)
and not self.pk
and not self.pin_code
):
self.pin_code = self.generate_unique_pin_code(
length=settings.ROOM_TELEPHONY_PIN_LENGTH
)
@@ -598,6 +631,14 @@ class Recording(BaseModel):
verbose_name=_("Recording options"),
help_text=_("Recording options"),
)
external_process_id = models.CharField(
max_length=255,
null=True,
blank=True,
unique=True,
verbose_name=_("External Process ID"),
help_text=_("ID of the external process associated with the recording."),
)
class Meta:
db_table = "meet_recording"
@@ -653,6 +694,8 @@ class Recording(BaseModel):
return self.status in {
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
RecordingStatusChoices.SAVED,
RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL,
RecordingStatusChoices.EXTERNAL_PROCESS_FAILED,
}
@property
@@ -14,9 +14,9 @@ logger = logging.getLogger(__name__)
class MachineUser:
"""Represent a non-interactive system user for automated storage operations."""
def __init__(self) -> None:
def __init__(self, username: str = "storage_event_user") -> None:
self.pk = None
self.username = "storage_event_user"
self.username = username
self.is_active = True
@property
@@ -34,33 +34,33 @@ class MachineUser:
return self.username
class StorageEventAuthentication(BaseAuthentication):
"""Authenticate requests using a Bearer token for storage event integration.
This class validates Bearer tokens for storage events that don't map to database users.
It's designed for S3-compatible storage integrations and similar use cases.
Events are submitted when a webhook is configured on some bucket's events.
"""
class HeaderBasedAuthentication(BaseAuthentication):
"""Authenticate requests using a header with a secret key."""
AUTH_HEADER = "Authorization"
TOKEN_TYPE = "Bearer" # noqa S105
REALM = ""
IS_ENFORCED_SETTINGS_KEY = None
EXPECTED_TOKEN_SETTINGS_KEY = None
def authenticate(self, request):
"""Validate the Bearer token from the Authorization header."""
if not settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
return MachineUser(), None
if self.IS_ENFORCED_SETTINGS_KEY is not None:
if not getattr(settings, self.IS_ENFORCED_SETTINGS_KEY):
return MachineUser(), None
required_token = settings.RECORDING_STORAGE_EVENT_TOKEN
if not required_token:
if settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
raise AuthenticationFailed(
"Authentication is enabled but token is not configured."
)
return MachineUser(), None
if (
self.EXPECTED_TOKEN_SETTINGS_KEY is None
or (required_token := getattr(settings, self.EXPECTED_TOKEN_SETTINGS_KEY))
is None
):
raise AuthenticationFailed(
"Authentication is enabled but token is not configured."
)
auth_header = request.headers.get(self.AUTH_HEADER)
if not auth_header:
logger.warning(
"Authentication failed: Missing Authorization header (ip: %s)",
@@ -68,15 +68,10 @@ class StorageEventAuthentication(BaseAuthentication):
)
raise AuthenticationFailed("Authorization header is required")
auth_parts = auth_header.split(" ")
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
logger.warning(
"Authentication failed: Invalid authorization header (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed("Invalid authorization header.")
token = auth_parts[1]
scheme, _, token = auth_header.partition(" ")
if scheme.lower() != self.TOKEN_TYPE.lower() or not token.strip():
raise AuthenticationFailed("Invalid authorization header format.")
token = token.strip()
# Use constant-time comparison to prevent timing attacks
if not secrets.compare_digest(token.encode(), required_token.encode()):
@@ -90,4 +85,26 @@ class StorageEventAuthentication(BaseAuthentication):
def authenticate_header(self, request):
"""Return the WWW-Authenticate header value."""
return f"{self.TOKEN_TYPE} realm='Storage event API'"
return f"{self.TOKEN_TYPE} realm='{self.REALM}'"
class StorageEventAuthentication(HeaderBasedAuthentication):
"""Authenticate requests using a Bearer token for storage event integration.
This class validates Bearer tokens for storage events that don't map to database users.
It's designed for S3-compatible storage integrations and similar use cases.
Events are submitted when a webhook is configured on some bucket's events.
"""
REALM = "Storage event API"
IS_ENFORCED_SETTINGS_KEY = "RECORDING_ENABLE_STORAGE_EVENT_AUTH"
EXPECTED_TOKEN_SETTINGS_KEY = "RECORDING_STORAGE_EVENT_TOKEN" # noqa S105
class RecordingProcessWebhookAuthentication(HeaderBasedAuthentication):
"""
Custom authentication class for recording process webhook requests.
Validates the API key in the Authorization header.
"""
REALM = "External process webhook API"
EXPECTED_TOKEN_SETTINGS_KEY = "SUMMARY_SERVICE_WEBHOOK_API_TOKEN" # noqa S105
@@ -4,11 +4,12 @@ import asyncio
import logging
import smtplib
from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import get_language, override
from django.utils.translation import get_language, gettext, override
from django.utils.translation import gettext_lazy as _
import aiohttp
@@ -17,6 +18,8 @@ from asgiref.sync import async_to_sync
from livekit import api as livekit_api
from core import models, utils
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
from core.utils import generate_download_s3_url
logger = logging.getLogger(__name__)
@@ -118,7 +121,7 @@ class NotificationService:
msg_plain = render_to_string(
"mail/text/screen_recording.txt", personalized_context
)
subject = str(_("Your recording is ready")) # Force translation
subject = gettext("Your recording is ready") # Force translation
try:
send_mail(
@@ -178,8 +181,49 @@ class NotificationService:
return _ns_to_utc(file_result.started_at), _ns_to_utc(file_result.ended_at)
@staticmethod
def _generate_title(
*,
locale: str,
room: str,
recording_datetime: datetime | None,
owner_timezone: str | None,
) -> str:
"""Generate title from context or return default."""
if recording_datetime is None:
with override(locale):
return gettext("Transcription")
dt = recording_datetime
if owner_timezone:
try:
dt = recording_datetime.astimezone(ZoneInfo(owner_timezone))
except (KeyError, ZoneInfoNotFoundError):
pass # Keep the original UTC datetime
with override(locale):
translated_template = _(
'Meeting "{room}" on {room_recording_date} at {room_recording_time}'
)
return translated_template.format(
room=room,
room_recording_date=dt.strftime("%Y-%m-%d"),
room_recording_time=dt.strftime("%H:%M"),
)
@staticmethod
def _notify_summary_service(recording: models.Recording):
if settings.SUMMARY_SERVICE_VERSION == 1:
return NotificationService._notify_summary_service_v1(recording)
if settings.SUMMARY_SERVICE_VERSION == 2:
return NotificationService._notify_summary_service_v2(recording)
raise NotImplementedError(
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
)
@staticmethod
def _notify_summary_service_v1(recording: models.Recording):
"""Notify summary service about a new recording."""
if (
@@ -253,5 +297,120 @@ class NotificationService:
return True
@staticmethod
def _notify_summary_service_v2(recording: models.Recording):
"""Notify summary service about a new recording."""
if (
not settings.SUMMARY_SERVICE_ENDPOINT
or not settings.SUMMARY_SERVICE_API_TOKEN
):
logger.error("Summary service not configured")
return False
owner_access = (
models.RecordingAccess.objects.select_related("user")
.filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
)
.first()
)
metadata_filename: None | str = None
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
"collect_metadata", False
):
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
if not owner_access:
logger.error("No owner found for recording %s", recording.id)
return False
started_at, ended_at = async_to_sync(
NotificationService._get_recording_timestamps
)(recording.worker_id)
form_base_url = settings.TRANSCRIPTION_SATISFACTION_FORM_BASE_URL
form_link = (
f"{form_base_url}?room_id={recording.room.id}"
if (form_base_url and metadata_filename is not None)
else None
)
metadata_payload = None
if started_at and ended_at and metadata_filename:
metadata_payload = {
"cloud_storage_url": generate_download_s3_url(
metadata_filename,
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
override_domain=False,
),
"started_at": started_at.isoformat(),
"ended_at": ended_at.isoformat(),
}
payload = {
"user_sub": owner_access.user.sub,
"user_email": owner_access.user.email,
"cloud_storage_url": generate_download_s3_url(
recording.key,
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
override_domain=False,
),
"language": recording.options.get(
"language", get_language().split("-")[0].lower()
),
"context_language": owner_access.user.language,
"push_to_docs_config": {
"user_email": owner_access.user.email,
"title": NotificationService._generate_title(
locale=owner_access.user.language
or recording.options.get("language", get_language()),
room=recording.room.name,
recording_datetime=started_at,
owner_timezone=str(owner_access.user.timezone),
),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
"form_link": form_link,
"auto_create_summary": is_user_feature_flag_enabled(
owner_access.user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
),
},
"metadata": metadata_payload,
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {settings.SUMMARY_SERVICE_API_TOKEN}",
}
try:
response = requests.post(
settings.SUMMARY_SERVICE_ENDPOINT,
json=payload,
headers=headers,
timeout=30,
)
response.raise_for_status()
response_json = response.json()
# We do not require a job_id to avoid a breaking change
job_id = response_json.get("job_id")
if not isinstance(job_id, str):
raise ValueError("job_id is not a string")
recording.external_process_id = job_id
recording.save()
except requests.RequestException as exc:
logger.exception(
"Summary service error for recording %s. URL: %s. Exception: %s",
recording.id,
settings.SUMMARY_SERVICE_ENDPOINT,
exc,
)
return False
return True
notification_service = NotificationService()
@@ -9,6 +9,11 @@ from livekit import api
from core import models, utils
from core.models import Recording
from core.recording.event.notification import notification_service
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
logger = getLogger(__name__)
@@ -39,10 +44,15 @@ class RecordingEventsService:
recording_status = status_mapping.get(egress_status)
if recording_status:
try:
utils.update_room_metadata(
RoomManagement().update_metadata(
room_name, {"recording_status": recording_status}
)
except utils.MetadataUpdateException as e:
except RoomNotFoundException:
logger.info(
"LiveKit room %s no longer exists, skipping metadata update",
room_name,
)
except RoomManagementException as e:
logger.exception("Failed to update room's metadata: %s", e)
@staticmethod
+12 -3
View File
@@ -2,8 +2,12 @@
import logging
from core import utils
from core.models import Recording, RecordingStatusChoices
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from .exceptions import (
RecordingStartError,
@@ -64,10 +68,15 @@ class WorkerServiceMediator:
mode = recording.options.get("original_mode", None) or recording.mode
try:
utils.update_room_metadata(
RoomManagement().update_metadata(
room_name, {"recording_mode": mode, "recording_status": "starting"}
)
except utils.MetadataUpdateException as e:
except RoomNotFoundException:
logger.info(
"LiveKit room %s no longer exists, skipping metadata update",
room_name,
)
except RoomManagementException as e:
logger.exception("Failed to update room's metadata: %s", e)
logger.info(
+1
View File
@@ -0,0 +1 @@
"""Meet core roomkit API endpoints for meeting-room (SIP) device integration."""
@@ -0,0 +1,65 @@
"""Authentication for the roomkit API of the Meet core app."""
import logging
import secrets
from django.conf import settings
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
from core.recording.event.authentication import MachineUser
logger = logging.getLogger(__name__)
class ServerToServerAuthentication(BaseAuthentication):
"""Custom authentication class for roomkit server-to-server requests.
Validates the Authorization header against the roomkit server-to-server
token. A valid PIN code is intentionally not enough to authenticate: the
endpoints are restricted to the LiveKit SIP module's credentials.
"""
AUTH_HEADER = "Authorization"
TOKEN_TYPE = "Bearer" # noqa S105
def authenticate(self, request):
"""Validate the Bearer token from the Authorization header.
Returns a (MachineUser, token) pair on success, and raises
AuthenticationFailed if the header is missing, malformed, or contains
an invalid token.
"""
required_token = settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN
if not required_token:
raise AuthenticationFailed("Server-to-server token is not configured.")
auth_header = request.headers.get(self.AUTH_HEADER)
if not auth_header:
logger.warning(
"Roomkit authentication failed: missing Authorization header (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed("Authorization header is missing.")
# Validate token format and existence
auth_parts = auth_header.split(" ")
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
raise AuthenticationFailed("Invalid authorization header.")
token = auth_parts[1]
# Use constant-time comparison to prevent timing attacks
if not secrets.compare_digest(token.encode(), required_token.encode()):
logger.warning(
"Roomkit authentication failed: invalid token (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed("Invalid server-to-server token.")
return MachineUser(username="roomkit"), token
def authenticate_header(self, request):
"""Return the WWW-Authenticate header value."""
return f"{self.TOKEN_TYPE} realm='Roomkit server to server'"
+21
View File
@@ -0,0 +1,21 @@
"""Serializers for the roomkit API of the Meet core app."""
# pylint: disable=abstract-method
from django.conf import settings
from rest_framework import serializers
from core.api.serializers import BaseValidationOnlySerializer
class RoomKitJoinSerializer(BaseValidationOnlySerializer):
"""Validate roomkit join requests from the LiveKit SIP module."""
pin_code = serializers.CharField(required=True)
def validate_pin_code(self, value):
"""Ensure the PIN code matches the configured length."""
if len(value) != settings.ROOM_TELEPHONY_PIN_LENGTH:
raise serializers.ValidationError("PIN code length is invalid.")
return value
+89
View File
@@ -0,0 +1,89 @@
"""Roomkit API endpoints for meeting-room (SIP) device integration."""
from logging import getLogger
from rest_framework import decorators, viewsets
from rest_framework import (
exceptions as drf_exceptions,
)
from rest_framework import (
response as drf_response,
)
from rest_framework import (
status as drf_status,
)
from core import analytics, models
from core.api import permissions, throttling
from core.api.feature_flag import FeatureFlag
from core.services.sip_management import SIPException, SIPManagement
from . import authentication, serializers
logger = getLogger(__name__)
class RoomKitViewSet(viewsets.ViewSet):
"""Server-to-server API endpoints for the roomkit integration.
Groups all interactions between roomkit (SIP) devices and the backend,
brokered by the LiveKit SIP module. All endpoints are authenticated
with the roomkit server-to-server tokens.
"""
authentication_classes = [authentication.ServerToServerAuthentication]
permission_classes = [permissions.IsAuthenticated]
@decorators.action(
detail=False,
methods=["post"],
url_path="join",
throttle_classes=[throttling.RoomKitJoinRateThrottle],
)
@FeatureFlag.require("roomkit")
def join(self, request):
"""Prepare a room for a meeting-room (SIP) device joining by PIN code.
Called by the LiveKit SIP module when a meeting-room device dials in
with a PIN code before any WebRTC participant has joined. Resolves the
room by PIN and creates its SIP dispatch rule, so the device can enter
without waiting for a WebRTC user.
The webhook-based creation path is kept: both converge on the same rule
through the shared SIPManagement.
"""
serializer = serializers.RoomKitJoinSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
room = models.Room.objects.get(
pin_code=serializer.validated_data["pin_code"]
)
except models.Room.DoesNotExist as e:
raise drf_exceptions.NotFound("No room found for this PIN code.") from e
try:
created = SIPManagement().ensure_dispatch_rule(room)
except SIPException as e:
raise drf_exceptions.APIException("Could not create dispatch rule.") from e
analytics.capture(
request.user,
analytics.AnalyticsEvent.ROOMKIT_JOINED,
{
"room_id": str(room.pk),
"dispatch_rule_created": created,
},
)
logger.info(
"Roomkit join requested: room_id=%s, dispatch_rule_created=%s",
room.id,
created,
)
return drf_response.Response(
{"status": "success"},
status=drf_status.HTTP_200_OK,
)
+1 -1
View File
@@ -30,7 +30,7 @@ class TokenDecodeError(JWTError):
class JwtTokenService:
"""Generic JWT token service with configurable settings."""
def __init__(
def __init__( # noqa: PLR0917
self,
secret_key: str,
algorithm: str,
+36 -14
View File
@@ -11,7 +11,7 @@ from django.conf import settings
from livekit import api
from core import models, utils
from core import models
from core.recording.services.metadata_collector import (
MetadataCollectorException,
MetadataCollectorService,
@@ -23,7 +23,12 @@ from core.recording.services.recording_events import (
)
from .lobby import LobbyService
from .telephony import TelephonyException, TelephonyService
from .room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from .sip_management import SIPException, SIPManagement
logger = getLogger(__name__)
@@ -102,7 +107,7 @@ class LiveKitEventsService:
)
self.webhook_receiver = api.WebhookReceiver(token_verifier)
self.lobby_service = LobbyService()
self.telephony_service = TelephonyService()
self.sip_management = SIPManagement()
self.recording_events = RecordingEventsService()
self._filter_regex = None
@@ -132,6 +137,13 @@ class LiveKitEventsService:
room_name = data.room.name or data.egress_info.room_name
if self._is_connection_test_room(room_name):
logger.info(
"Ignoring webhook event for connection test room '%s'.",
room_name,
)
return
if self._filter_regex and not self._filter_regex.search(room_name):
logger.info("Filtered webhook event for room '%s'", room_name)
return
@@ -177,10 +189,15 @@ class LiveKitEventsService:
try:
room_name = str(recording.room.id)
utils.update_room_metadata(
room_name, {}, ["recording_mode", "recording_status"]
RoomManagement().update_metadata(
room_name, remove_keys=["recording_mode", "recording_status"]
)
except utils.MetadataUpdateException as e:
except RoomNotFoundException:
logger.info(
"LiveKit room %s no longer exists, skipping metadata update",
room_name,
)
except RoomManagementException as e:
logger.exception("Failed to update room's metadata: %s", e)
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
@@ -218,6 +235,11 @@ class LiveKitEventsService:
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
@staticmethod
def _is_connection_test_room(room_name: str) -> bool:
"""Return True for ephemeral rooms created by the connection test endpoint."""
return room_name.startswith(settings.CONNECTION_TEST_ROOM_PREFIX)
def _handle_room_started(self, data):
"""Handle 'room_started' event."""
@@ -235,12 +257,12 @@ class LiveKitEventsService:
except models.Room.DoesNotExist as err:
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
if settings.ROOM_TELEPHONY_ENABLED:
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
try:
self.telephony_service.create_dispatch_rule(room)
except TelephonyException as e:
self.sip_management.ensure_dispatch_rule(room)
except SIPException as e:
raise ActionFailedError(
f"Failed to create telephony dispatch rule for room {room_id}"
f"Failed to create sip dispatch rule for room {room_id}"
) from e
def _handle_room_finished(self, data):
@@ -255,12 +277,12 @@ class LiveKitEventsService:
)
raise ActionFailedError("Failed to process room finished event") from e
if settings.ROOM_TELEPHONY_ENABLED:
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
try:
self.telephony_service.delete_dispatch_rule(room_id)
except TelephonyException as e:
self.sip_management.delete_dispatch_rule(room_id)
except SIPException as e:
raise ActionFailedError(
f"Failed to delete telephony dispatch rule for room {room_id}"
f"Failed to delete sip dispatch rule for room {room_id}"
) from e
try:
+17 -7
View File
@@ -104,21 +104,30 @@ class LobbyService:
)
@staticmethod
def can_bypass_lobby(room, user) -> bool:
def can_bypass_lobby(room, user, role) -> bool:
"""Determines if a user can bypass the waiting lobby and join a room directly.
A user can bypass the lobby if:
1. The room is public (open to everyone)
2. The room has TRUSTED access level and the user is authenticated
2. The room has RESTRICTED access level and the user has any role
Note: Room access levels can change while participants are waiting in the lobby.
This function only checks the current state and should be called each time
a participant requests entry to ensure consistent access control, even for
participants who have already begun waiting.
"""
return room.is_public or (
room.access_level == models.RoomAccessLevel.TRUSTED
and user.is_authenticated
return (
room.is_public
or (
room.access_level == models.RoomAccessLevel.TRUSTED
and user.is_authenticated
)
or (
room.access_level == models.RoomAccessLevel.RESTRICTED
and user.is_authenticated
and role is not None
)
)
def request_entry(
@@ -144,8 +153,9 @@ class LobbyService:
participant = self._get_participant(room.id, participant_id)
room_id = str(room.id)
user_role = room.get_role(request.user)
if self.can_bypass_lobby(room=room, user=request.user):
if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
if participant is None:
participant = LobbyParticipant(
status=LobbyParticipantStatus.ACCEPTED,
@@ -162,8 +172,8 @@ class LobbyService:
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
role=user_role,
)
return participant, livekit_config
@@ -183,8 +193,8 @@ class LobbyService:
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
role=user_role,
)
return participant, livekit_config
@@ -112,7 +112,7 @@ class ParticipantsManagement:
await lkapi.aclose()
@async_to_sync
async def update(
async def update( # noqa: PLR0917
self,
room_name: str,
identity: str,
+57 -3
View File
@@ -8,6 +8,8 @@ from typing import Dict, Optional
from asgiref.sync import async_to_sync
from livekit.api import (
DeleteRoomRequest,
ListRoomsRequest,
TwirpError,
UpdateRoomMetadataRequest,
)
@@ -29,20 +31,45 @@ class RoomManagement:
"""Service for managing LiveKit rooms."""
@async_to_sync
async def update_metadata(self, room_name: str, metadata: Optional[Dict] = None):
"""Update a LiveKit room's metadata.
async def update_metadata(
self,
room_name: str,
metadata: Optional[Dict] = None,
remove_keys: Optional[list[str]] = None,
):
"""Merge values into a LiveKit room's metadata.
The `room_name` corresponds to the LiveKit room identifier
(i.e. the Room model's UUID as a string).
Raises:
RoomNotFoundException: the room does not exist in LiveKit.
RoomManagementException: the metadata update otherwise fails.
"""
lkapi = utils.create_livekit_client()
try:
response = await lkapi.room.list_rooms(ListRoomsRequest(names=[room_name]))
if not response.rooms:
logger.warning(
"Room %s not found in LiveKit, skipping metadata update",
room_name,
)
raise RoomNotFoundException("Room does not exist")
existing_metadata = json.loads(response.rooms[0].metadata or "{}")
for key in remove_keys or []:
existing_metadata.pop(key, None)
updated_metadata = {**existing_metadata, **(metadata or {})}
await lkapi.room.update_room_metadata(
UpdateRoomMetadataRequest(
room=room_name,
metadata=json.dumps(metadata) if metadata is not None else "",
metadata=json.dumps(updated_metadata),
)
)
@@ -62,3 +89,30 @@ class RoomManagement:
finally:
await lkapi.aclose()
@async_to_sync
async def delete_room(self, room_name: str):
"""Delete a LiveKit room and disconnect all participants.
Raises:
RoomNotFoundException: the room does not exist in LiveKit.
RoomManagementException: the deletion otherwise fails.
"""
lkapi = utils.create_livekit_client()
try:
await lkapi.room.delete_room(DeleteRoomRequest(room=room_name))
logger.info("Deleted LiveKit room %s", room_name)
except TwirpError as e:
if e.code == "not_found":
logger.warning(
"Room %s not found in LiveKit, skipping deletion",
room_name,
)
raise RoomNotFoundException("Room does not exist") from e
logger.exception("Unexpected error deleting room %s", room_name)
raise RoomManagementException("Could not delete room") from e
finally:
await lkapi.aclose()
+178
View File
@@ -0,0 +1,178 @@
"""Room role management service.
Single entry point for changing a user's role on a room, used by:
- the in-meeting endpoint (promote/demote a connected participant)
- (more to come soon)
`ResourceAccess` is the source of truth. The LiveKit `room_role`
participant attribute is only a projection of it, synced best-effort.
"""
from logging import getLogger
from uuid import UUID
from core import models
from core.services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagement,
ParticipantsManagementException,
)
logger = getLogger(__name__)
class RoomRoleError(Exception):
"""Base exception for room role management errors."""
status_code = 400
class SelfActionError(RoomRoleError):
"""Raised when a user tries to change their own role."""
status_code = 403
class OwnerRoleError(RoomRoleError):
"""Raised when trying to demote an owner or grant ownership."""
status_code = 403
class ParticipantNotInMeetingError(RoomRoleError):
"""Raised when the target participant is not connected to the meeting."""
status_code = 404
class UserNotFoundError(RoomRoleError):
"""Raised when the target participant has no user account in database."""
status_code = 404
ASSIGNABLE_ROLES = (models.RoleChoices.MEMBER, models.RoleChoices.ADMIN)
class RoomRoleService:
"""Manage promotion and demotion of room co-hosts."""
def set_role(
self, room: models.Room, user: models.User, role: str, actor: models.User
):
"""Persist `role` for `user` on `room`, idempotently and atomically.
Returns the up-to-date `ResourceAccess`. Never grants or removes
ownership: granting OWNER is refused, and an existing OWNER access
is never modified.
"""
if role not in ASSIGNABLE_ROLES:
raise OwnerRoleError("Ownership cannot be granted through this action.")
if actor is not None and user == actor:
raise SelfActionError("You cannot change your own role.")
access, created = models.ResourceAccess.objects.get_or_create(
resource=room,
user=user,
defaults={"role": role},
)
if created:
return access
if access.role == models.RoleChoices.OWNER:
raise OwnerRoleError("Room owners cannot be demoted.")
if access.role != role:
access.role = role
access.save(update_fields=["role", "updated_at"])
return access
def set_participant_role(
self,
room: models.Room,
participant_identity: UUID,
role: str,
actor: models.User,
):
"""Change the role of a participant currently connected to the meeting.
- The participant must be connected (checked against LiveKit).
- The participant must map to a user account.
- The role is persisted in DB then mirrored to LiveKit.
Returns a dict: {"role", "livekit_synced"}.
"""
room_name = str(room.pk)
participants_management = ParticipantsManagement()
try:
is_in_meeting = participants_management.check_if_in_meeting(
room_name=room_name, identity=str(participant_identity)
)
except ParticipantNotFoundException as e:
raise ParticipantNotInMeetingError(
"Participant is not connected to this meeting."
) from e
if not is_in_meeting:
raise ParticipantNotInMeetingError(
"Participant is not connected to this meeting."
)
user = models.User.objects.filter(sub=participant_identity).first()
if user is None:
raise UserNotFoundError(
"This participant has no user account and cannot be assigned a role."
)
# Source of truth first: even if the LiveKit sync below fails,
# the role is real and any fresh token will carry it.
self.set_role(room=room, user=user, role=role, actor=actor)
livekit_synced = self._sync_livekit_role(
room_name=room_name,
participant_identity=str(participant_identity),
role=str(role),
)
return {
"role": role,
"livekit_synced": livekit_synced,
}
@staticmethod
def _sync_livekit_role(room_name: str, participant_identity: str, role: str):
"""Mirror the role to the participant's LiveKit attributes.
Best-effort: returns False on failure instead of raising, so callers
can report a partial success. Re-running the action re-syncs.
"""
try:
ParticipantsManagement().update(
room_name=room_name,
identity=participant_identity,
attributes={"room_role": role},
)
except ParticipantNotFoundException:
# The participant left between the presence check and the update:
# harmless, the DB state (if any) remains authoritative.
logger.info(
"Participant %s left room %s before role sync",
participant_identity,
room_name,
)
return False
except ParticipantsManagementException:
logger.exception(
"Could not sync role to LiveKit for participant %s in room %s",
participant_identity,
room_name,
)
return False
return True
@@ -1,9 +1,9 @@
"""Telephony service for managing SIP dispatch rules for room access."""
"""SIP management service for managing SIP dispatch rules for room access."""
from logging import getLogger
from asgiref.sync import async_to_sync
from livekit.api import TwirpError
from livekit.api import TwirpError, TwirpErrorCode
from livekit.protocol.sip import (
CreateSIPDispatchRuleRequest,
DeleteSIPDispatchRuleRequest,
@@ -17,12 +17,16 @@ from core import utils
logger = getLogger(__name__)
class TelephonyException(Exception):
"""Exception raised when telephony operations fail."""
class SIPException(Exception):
"""Exception raised when SIP operations fail."""
class TelephonyService:
"""Service for managing participant access through the telephony system (SIP)."""
class DispatchRuleConflictError(SIPException):
"""Raised when a dispatch rule already exists for the same routing criteria."""
class SIPManagement:
"""Service for managing SIP access through the telephony or roomkit system (SIP)."""
def _rule_name(self, room_id):
"""Generate the rule name for a room based on its ID."""
@@ -32,7 +36,7 @@ class TelephonyService:
async def create_dispatch_rule(self, room):
"""Create a SIP inbound dispatch rule for direct room routing.
Configures telephony to route incoming SIP calls directly to the specified room
Configures livekit-sip to route incoming SIP calls directly to the specified room
using the room's ID and PIN code for authentication.
"""
@@ -51,10 +55,12 @@ class TelephonyService:
try:
await lkapi.sip.create_sip_dispatch_rule(create=request)
except TwirpError as e:
if e.code == TwirpErrorCode.ALREADY_EXISTS:
raise DispatchRuleConflictError("Dispatch rule already exists") from e
logger.exception(
"Unexpected error creating dispatch rule for room %s", room.id
)
raise TelephonyException("Could not create dispatch rule") from e
raise SIPException("Could not create dispatch rule") from e
finally:
await lkapi.aclose()
@@ -79,7 +85,7 @@ class TelephonyService:
)
except TwirpError as e:
logger.exception("Failed to list dispatch rules for room %s", room_id)
raise TelephonyException("Could not list dispatch rules") from e
raise SIPException("Could not list dispatch rules") from e
finally:
await lkapi.aclose()
@@ -94,6 +100,28 @@ class TelephonyService:
if existing_rule.name == rule_name
]
@async_to_sync
async def has_dispatch_rule(self, room_id):
"""Check whether at least one dispatch rule exists for a specific room."""
return bool(await self._list_dispatch_rules_ids(room_id))
def ensure_dispatch_rule(self, room):
"""Create the SIP dispatch rule for a room if it does not already exist.
Returns:
bool: True if a rule was created, False if it already existed.
"""
if self.has_dispatch_rule(room.pk):
return False
try:
self.create_dispatch_rule(room)
except DispatchRuleConflictError:
return False
return True
@async_to_sync
async def delete_dispatch_rule(self, room_id):
"""Delete all SIP inbound dispatch rules associated with a specific room."""
@@ -118,7 +146,7 @@ class TelephonyService:
except TwirpError as e:
logger.exception("Failed to delete dispatch rules for room %s", room_id)
raise TelephonyException("Could not delete dispatch rules") from e
raise SIPException("Could not delete dispatch rules") from e
finally:
await lkapi.aclose()
+9
View File
@@ -0,0 +1,9 @@
"""Celery tasks for the core app."""
from core.tasks.connection_test import delete_connection_test_room
from core.tasks.file import process_file_deletion
__all__ = (
"delete_connection_test_room",
"process_file_deletion",
)
+7
View File
@@ -1,4 +1,11 @@
"""
Celery task decorator that degrades to a synchronous call when Celery is off.
"""
# The Celery app is imported lazily so that importing this module does not pull
# in Celery when CELERY_ENABLED is false.
# ruff: noqa: PLC0415
# pylint: disable=import-outside-toplevel
from django.conf import settings
+39
View File
@@ -0,0 +1,39 @@
"""Tasks related to connection test rooms."""
import logging
from django.conf import settings
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from core.tasks._task import task
logger = logging.getLogger(__name__)
@task
def delete_connection_test_room(room_name: str):
"""Force-delete an ephemeral connection-test room.
Used as a hard cap so a participant cannot keep an auto-refreshed
LiveKit session open indefinitely after requesting a test token.
"""
prefix = settings.CONNECTION_TEST_ROOM_PREFIX
if not room_name.startswith(prefix):
logger.error(
"Refusing to delete room '%s': expected prefix '%s'.",
room_name,
prefix,
)
return
try:
RoomManagement().delete_room(room_name)
except RoomNotFoundException:
# Room may already be gone after empty/departure timeout.
logger.info("Connection test room '%s' already gone.", room_name)
except RoomManagementException:
logger.exception("Failed to delete connection test room '%s'.", room_name)
@@ -12,6 +12,7 @@ import pytest
from core.analytics.events import AnalyticsEvent
from core.analytics.posthog import PostHogAnalytics
from core.analytics.user_feature_flags import UserFeatureFlag
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
@@ -248,6 +249,60 @@ def test_capture_logs_the_failing_event_name_on_exception(mock_posthog_cls, capl
assert any("PostHog capture failed" in record.message for record in caplog.records)
# ==============================
# feature flags
# ==============================
@patch("core.analytics.posthog.Posthog")
def test_compute_feature_flags_returns_all_catalog_entries(mock_posthog_cls):
"""Should map every declared feature flag key to the SDK evaluated value."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
mock_posthog_cls.return_value.evaluate_flags.return_value.get_flag.return_value = (
True
)
flags = backend._fetch_user_feature_flags(user)
assert flags == {UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED: True}
mock_posthog_cls.return_value.evaluate_flags.assert_called_once_with(str(user.pk))
mock_posthog_cls.return_value.evaluate_flags.return_value.get_flag.assert_called_once_with(
UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED.value
)
@patch("core.analytics.posthog.cache.get_or_set")
@patch("core.analytics.posthog.Posthog")
def test_get_feature_flags_uses_cache_get_or_set(
mock_posthog_cls, mock_cache_get_or_set
):
"""Should cache feature flags by user distinct id with configured TTL."""
cached_flags = {UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED: False}
mock_cache_get_or_set.return_value = cached_flags
backend = PostHogAnalytics(api_key="test-api-key", feature_flags_cache_ttl=120)
user = UserFactory()
flags = backend.get_user_feature_flags(user)
assert flags == cached_flags
mock_cache_get_or_set.assert_called_once()
args, kwargs = mock_cache_get_or_set.call_args
assert kwargs["timeout"] == 120
assert args[0] == f"user_feature_flags:{user.pk}"
assert callable(kwargs["default"])
@patch("core.analytics.posthog.Posthog")
def test_get_feature_flags_returns_empty_dict_on_exception(mock_posthog_cls):
"""Should swallow failures and return an empty mapping."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
with patch("core.analytics.posthog.cache.get_or_set", side_effect=RuntimeError):
assert backend.get_user_feature_flags(user) == {}
# ==============================
# shutdown
# ==============================
@@ -36,6 +36,26 @@ def test_merge_keeps_most_recently_created_user():
assert User.objects.filter(id=user2.id).exists()
def test_merge_user_case_insensitive():
"""Emails differing only by case should be treated as duplicates and merged,
keeping the most recently created user."""
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()
user3 = UserFactory(email="joe@example.com")
user4 = UserFactory(email="Joe@example.com")
call_command("merge_duplicate_users")
assert not User.objects.filter(id=user3.id).exists()
assert User.objects.filter(id=user4.id).exists()
user4.refresh_from_db()
assert user4.email.islower()
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"
@@ -457,4 +477,4 @@ def test_merge_email_filter_is_case_insensitive():
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
assert User.objects.filter(email="user1@example.com").count() == 1
@@ -136,10 +136,10 @@ def test_authenticate_header():
def test_multiple_spaces_in_auth_header(settings):
"""Test failure when Authorization header contains multiple spaces."""
"""Test success when Authorization header contains multiple spaces."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/")
request.headers = {"Authorization": "Bearer extra-spaces-token"}
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
StorageEventAuthentication().authenticate(request)
header = StorageEventAuthentication().authenticate_header(request)
assert header == "Bearer realm='Storage event API'"
@@ -5,6 +5,7 @@ Test event notification.
# pylint: disable=assignment-from-no-return,redefined-outer-name,unused-argument,protected-access
import datetime
import json
import smtplib
from unittest import mock
@@ -13,6 +14,7 @@ from django.contrib.sites.models import Site
import pytest
from core import factories, models
from core.analytics import UserFeatureFlag
from core.recording.event.notification import NotificationService, notification_service
pytestmark = pytest.mark.django_db
@@ -243,3 +245,237 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
assert result is False
assert mock_send_mail.call_count == 2
assert "notification could not be sent:" in caplog.text
@mock.patch("core.recording.event.notification.requests.post")
@mock.patch("core.recording.event.notification.generate_download_s3_url")
@mock.patch.object(
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
)
def test_notify_summary_service_post_args_with_metadata(
mock_get_recording_timestamps,
mock_generate_download_s3_url,
mock_post,
settings,
):
"""Test summary notification computed request args when metadata is enabled."""
settings.SUMMARY_SERVICE_VERSION = 2
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings"
settings.SCREEN_RECORDING_BASE_URL = None
settings.METADATA_COLLECTOR_ENABLED = True
settings.METADATA_COLLECTOR_OUTPUT_FOLDER = "recordings-metadata"
recording = factories.RecordingFactory(
room__name="Engineering Sync",
worker_id="egress-1",
options={"collect_metadata": True, "language": "en-us"},
)
owner = factories.UserFactory(
email="owner@test.com",
sub="owner-sub",
language="fr-fr",
timezone="Europe/Paris",
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=owner
)
started_at = datetime.datetime(2026, 1, 2, 10, 30, tzinfo=datetime.timezone.utc)
ended_at = datetime.datetime(2026, 1, 2, 11, 45, tzinfo=datetime.timezone.utc)
mock_get_recording_timestamps.return_value = (started_at, ended_at)
mock_generate_download_s3_url.side_effect = [
"https://storage.test/metadata.json",
"https://storage.test/recording.ogg",
]
mock_response = mock.Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"job_id": "job-42"}
mock_post.return_value = mock_response
result = NotificationService._notify_summary_service(recording)
recording.refresh_from_db()
assert result is True
assert recording.external_process_id == "job-42"
metadata_filename = (
f"{settings.METADATA_COLLECTOR_OUTPUT_FOLDER}/{recording.id}-metadata.json"
)
expected_payload = {
"user_sub": owner.sub,
"user_email": owner.email,
"cloud_storage_url": "https://storage.test/recording.ogg",
"language": "en-us",
"context_language": owner.language,
"push_to_docs_config": {
"user_email": owner.email,
"title": 'Réunion "Engineering Sync" du 2026-01-02 à 11:30',
"download_link": f"{settings.RECORDING_DOWNLOAD_BASE_URL}/{recording.id}",
"auto_create_summary": False,
"form_link": None,
},
"metadata": {
"cloud_storage_url": "https://storage.test/metadata.json",
"started_at": started_at.isoformat(),
"ended_at": ended_at.isoformat(),
},
}
expected_headers = {
"Content-Type": "application/json",
"Authorization": "Bearer summary-token",
}
mock_post.assert_called_once_with(
"https://summary.test/api/v2/tasks",
json=expected_payload,
headers=expected_headers,
timeout=30,
)
assert mock_generate_download_s3_url.call_args_list == [
mock.call(metadata_filename, expires_in=60 * 60 * 24, override_domain=False),
mock.call(recording.key, expires_in=60 * 60 * 24, override_domain=False),
]
mock_get_recording_timestamps.assert_awaited_once_with("egress-1")
@mock.patch("core.recording.event.notification.requests.post")
@mock.patch("core.recording.event.notification.generate_download_s3_url")
@mock.patch.object(
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
)
@pytest.mark.parametrize("auto_create_summary_enabled", [False, True])
def test_notify_summary_service_post_args_without_metadata(
mock_get_recording_timestamps,
mock_generate_download_s3_url,
mock_post,
auto_create_summary_enabled,
settings,
):
"""Test summary notification computed request args when metadata is not available."""
settings.SUMMARY_SERVICE_VERSION = 2
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings"
settings.SCREEN_RECORDING_BASE_URL = None
settings.METADATA_COLLECTOR_ENABLED = False
recording = factories.RecordingFactory(room__name="Daily")
owner = factories.UserFactory(
email="owner@test.com",
sub="owner-sub",
language="en-us",
timezone="UTC",
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=owner
)
mock_get_recording_timestamps.return_value = (None, None)
mock_generate_download_s3_url.return_value = "https://storage.test/recording.mp4"
mock_response = mock.Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"job_id": "job-51"}
mock_post.return_value = mock_response
with mock.patch(
"core.recording.event.notification.is_user_feature_flag_enabled",
return_value=auto_create_summary_enabled,
) as mock_is_feature_flag_enabled:
result = NotificationService._notify_summary_service(recording)
assert result is True
expected_payload = {
"user_sub": owner.sub,
"user_email": owner.email,
"cloud_storage_url": "https://storage.test/recording.mp4",
"language": "en",
"context_language": owner.language,
"push_to_docs_config": {
"user_email": owner.email,
"title": "Transcription",
"download_link": f"{settings.RECORDING_DOWNLOAD_BASE_URL}/{recording.id}",
"auto_create_summary": auto_create_summary_enabled,
"form_link": None,
},
"metadata": None,
}
expected_headers = {
"Content-Type": "application/json",
"Authorization": "Bearer summary-token",
}
mock_post.assert_called_once_with(
"https://summary.test/api/v2/tasks",
json=expected_payload,
headers=expected_headers,
timeout=30,
)
mock_generate_download_s3_url.assert_called_once_with(
recording.key, expires_in=60 * 60 * 24, override_domain=False
)
mock_get_recording_timestamps.assert_awaited_once_with(recording.worker_id)
mock_is_feature_flag_enabled.assert_called_once_with(
owner, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
@mock.patch("core.recording.event.notification.requests.post")
@mock.patch("core.recording.event.notification.generate_download_s3_url")
@mock.patch.object(
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
)
def test_notify_summary_service_v2_payload_json_serializable_without_timestamps(
mock_get_recording_timestamps,
mock_generate_download_s3_url,
mock_post,
settings,
):
"""Regression test for a non-JSON-serializable payload when timestamps are missing.
When the LiveKit egress can no longer be found, ``_get_recording_timestamps``
returns ``(None, None)`` and ``_generate_title`` falls back to its default
title. That default must be a real ``str``: it used to return a lazy
``gettext_lazy`` proxy, which ``json.dumps`` cannot serialize, so the real
``requests.post(json=payload)`` call crashed in production with
``TypeError: Object of type __proxy__ is not JSON serializable``.
"""
settings.SUMMARY_SERVICE_VERSION = 2
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings"
settings.SCREEN_RECORDING_BASE_URL = None
settings.METADATA_COLLECTOR_ENABLED = False
recording = factories.RecordingFactory(room__name="Daily")
owner = factories.UserFactory(
email="owner@test.com",
sub="owner-sub",
language="fr-fr",
timezone="Europe/Paris",
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=owner
)
# Egress timestamps unavailable -> default-title branch in _generate_title.
mock_get_recording_timestamps.return_value = (None, None)
mock_generate_download_s3_url.return_value = "https://storage.test/recording.mp4"
mock_response = mock.Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"job_id": "job-77"}
mock_post.return_value = mock_response
result = NotificationService._notify_summary_service(recording)
assert result is True
payload = mock_post.call_args.kwargs["json"]
title = payload["push_to_docs_config"]["title"]
# The title must be a plain ``str``, not a lazy translation proxy...
assert isinstance(title, str)
# ...so the payload serializes exactly the way ``requests`` serializes it.
json.dumps(payload)
@@ -0,0 +1,134 @@
"""
Test recordings API endpoints: external process hook.
"""
# pylint: disable=redefined-outer-name,unused-argument
import pytest
from ...factories import RecordingFactory
from ...models import RecordingStatusChoices
pytestmark = pytest.mark.django_db
@pytest.fixture
def external_process_settings(settings):
"""Configure authentication token for the external process webhook."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "testWebhookToken"
return settings
def test_external_process_event_missing_authorization_header(
external_process_settings, client
):
"""Requests without authorization must be rejected."""
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "job-1", "type": "transcript", "status": "success"},
)
assert response.status_code == 401
def test_external_process_event_wrong_bearer_token(external_process_settings, client):
"""Requests with invalid bearer token must be rejected."""
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "job-1", "type": "transcript", "status": "success"},
HTTP_AUTHORIZATION="Bearer wrongToken",
)
assert response.status_code == 401
def test_external_process_event_missing_job_id(external_process_settings, client):
"""Payload without job_id must fail validation."""
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"type": "transcript", "status": "success"},
HTTP_AUTHORIZATION="Bearer testWebhookToken",
)
assert response.status_code == 400
assert response.json() == {"job_id": ["This field is required."]}
def test_external_process_event_success_updates_recording_status(
external_process_settings, client
):
"""A successful transcript process should update recording status."""
recording = RecordingFactory(
status=RecordingStatusChoices.SAVED,
external_process_id="job-123",
)
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "job-123", "type": "transcript", "status": "success"},
HTTP_AUTHORIZATION="Bearer testWebhookToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Event processed."}
recording.refresh_from_db()
assert recording.status == RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL
def test_external_process_event_failure_updates_recording_status(
external_process_settings, client
):
"""A failing transcript process should update recording status."""
recording = RecordingFactory(
status=RecordingStatusChoices.SAVED,
external_process_id="job-456",
)
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "job-456", "type": "transcript", "status": "failure"},
HTTP_AUTHORIZATION="Bearer testWebhookToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Event processed."}
recording.refresh_from_db()
assert recording.status == RecordingStatusChoices.EXTERNAL_PROCESS_FAILED
def test_external_process_event_unknown_recording_is_ignored(
external_process_settings, client
):
"""Unknown job_id should not fail the webhook processing."""
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "missing-job", "type": "transcript", "status": "success"},
HTTP_AUTHORIZATION="Bearer testWebhookToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Event processed."}
def test_external_process_event_non_transcript_event_does_not_change_status(
external_process_settings, client
):
"""Only transcript events should update recording status."""
recording = RecordingFactory(
status=RecordingStatusChoices.SAVED,
external_process_id="job-789",
)
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "job-789", "type": "thumbnail", "status": "success"},
HTTP_AUTHORIZATION="Bearer testWebhookToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Event processed."}
recording.refresh_from_db()
assert recording.status == RecordingStatusChoices.SAVED
@@ -34,10 +34,8 @@ def mediator(mock_worker_service):
return WorkerServiceMediator(mock_worker_service)
@mock.patch("core.utils.update_room_metadata")
def test_start_recording_success(
mock_update_room_metadata, mediator, mock_worker_service
):
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_start_recording_success(mock_update_metadata, mediator, mock_worker_service):
"""Test successful recording start"""
# Setup
worker_id = "test-worker-123"
@@ -60,7 +58,7 @@ def test_start_recording_success(
assert mock_recording.worker_id == worker_id
assert mock_recording.status == RecordingStatusChoices.ACTIVE
mock_update_room_metadata.assert_called_once_with(
mock_update_metadata.assert_called_once_with(
str(mock_recording.room.id),
{"recording_mode": mock_recording.mode, "recording_status": "starting"},
)
@@ -69,9 +67,9 @@ def test_start_recording_success(
@pytest.mark.parametrize(
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
)
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_mediator_start_recording_worker_errors(
mock_update_room_metadata, mediator, mock_worker_service, error_class
mock_update_metadata, mediator, mock_worker_service, error_class
):
"""Test handling of various worker errors during start"""
# Setup
@@ -89,7 +87,7 @@ def test_mediator_start_recording_worker_errors(
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
assert mock_recording.worker_id is None
mock_update_room_metadata.assert_not_called()
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize(
@@ -103,9 +101,9 @@ def test_mediator_start_recording_worker_errors(
RecordingStatusChoices.ABORTED,
],
)
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_mediator_start_recording_from_forbidden_status(
mock_update_room_metadata, mediator, mock_worker_service, status
mock_update_metadata, mediator, mock_worker_service, status
):
"""Test handling of various worker errors during start"""
# Setup
@@ -119,7 +117,7 @@ def test_mediator_start_recording_from_forbidden_status(
mock_recording.refresh_from_db()
assert mock_recording.status == status
mock_update_room_metadata.assert_not_called()
mock_update_metadata.assert_not_called()
def test_mediator_stop_recording_success(mediator, mock_worker_service):
@@ -0,0 +1 @@
"""Tests for the roomkit API of the Meet core app."""
@@ -0,0 +1,305 @@
"""
Test the roomkit join server-to-server API endpoint.
"""
# pylint: disable=redefined-outer-name,unused-argument
from unittest import mock
import pytest
from ...factories import RoomFactory
from ...services.sip_management import SIPException
pytestmark = pytest.mark.django_db
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_anonymous(mock_sip_management, settings, client):
"""Requests without an Authorization header should be rejected."""
mock_sip_instance = mock_sip_management.return_value
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
room = RoomFactory(pin_code="1234567890")
response = client.post("/api/v1.0/roomkit/join/", {"pin_code": room.pin_code})
assert response.status_code == 401
assert response.json() == {"detail": "Authorization header is missing."}
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_malformed_authorization_header(mock_sip_management, settings, client):
"""Requests with a malformed Authorization header should be rejected."""
mock_sip_instance = mock_sip_management.return_value
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
room = RoomFactory(pin_code="1234567890")
response = client.post(
"/api/v1.0/roomkit/join/",
{"pin_code": room.pin_code},
HTTP_AUTHORIZATION="testAuthToken",
)
assert response.status_code == 401
assert response.json() == {"detail": "Invalid authorization header."}
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_wrong_bearer(mock_sip_management, settings, client):
"""Requests with an incorrect bearer token should be rejected."""
mock_sip_instance = mock_sip_management.return_value
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
room = RoomFactory(pin_code="1234567890")
response = client.post(
"/api/v1.0/roomkit/join/",
{"pin_code": room.pin_code},
HTTP_AUTHORIZATION="Bearer wrongAuthToken",
)
assert response.status_code == 401
assert response.json() == {"detail": "Invalid server-to-server token."}
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_token_not_configured(mock_sip_management, settings, client):
"""Requests should be rejected when no server-to-server token is configured."""
mock_sip_instance = mock_sip_management.return_value
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = None
room = RoomFactory(pin_code="1234567890")
response = client.post(
"/api/v1.0/roomkit/join/",
{"pin_code": room.pin_code},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 401
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_roomkit_disabled(mock_sip_management, settings, client):
"""The endpoint should not be exposed when the roomkit integration is disabled."""
mock_sip_instance = mock_sip_management.return_value
settings.ROOMKIT_ENABLED = False
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
room = RoomFactory(pin_code="1234567890")
response = client.post(
"/api/v1.0/roomkit/join/",
{"pin_code": room.pin_code},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 404
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_missing_pin(mock_sip_management, settings, client):
"""Requests without a PIN code should be rejected."""
mock_sip_instance = mock_sip_management.return_value
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
response = client.post(
"/api/v1.0/roomkit/join/",
{},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 400
assert response.json() == {"pin_code": ["This field is required."]}
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_blank_pin(mock_sip_management, settings, client):
"""Requests with a blank PIN code should be rejected."""
mock_sip_instance = mock_sip_management.return_value
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
response = client.post(
"/api/v1.0/roomkit/join/",
{"pin_code": ""},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 400
assert response.json() == {"pin_code": ["This field may not be blank."]}
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_wrong_pin_length(mock_sip_management, settings, client):
"""Requests with a PIN code of unexpected length should be rejected."""
mock_sip_instance = mock_sip_management.return_value
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
settings.ROOM_TELEPHONY_PIN_LENGTH = 10
response = client.post(
"/api/v1.0/roomkit/join/",
{"pin_code": "123"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 400
assert response.json() == {"pin_code": ["PIN code length is invalid."]}
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_unknown_pin(mock_sip_management, settings, client):
"""Requests with a PIN matching no room should return 404 and create no rule."""
mock_sip_instance = mock_sip_management.return_value
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
RoomFactory(pin_code="1234567890")
response = client.post(
"/api/v1.0/roomkit/join/",
{"pin_code": "0987654321"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 404
assert response.json() == {"detail": "No room found for this PIN code."}
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_success(mock_sip_management, settings, client):
"""Requests with a valid PIN should create the dispatch rule."""
mock_sip_instance = mock_sip_management.return_value
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
room = RoomFactory(pin_code="1234567890")
mock_sip_instance.ensure_dispatch_rule.return_value = True
response = client.post(
"/api/v1.0/roomkit/join/",
{"pin_code": room.pin_code},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 200
assert response.json() == {"status": "success"}
mock_sip_instance.ensure_dispatch_rule.assert_called_once_with(room)
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_dispatch_rule_already_exists(mock_sip_management, settings, client):
"""Requests should succeed when the dispatch rule already exists (idempotency)."""
mock_sip_instance = mock_sip_management.return_value
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
room = RoomFactory(pin_code="1234567890")
mock_sip_instance.ensure_dispatch_rule.return_value = False
response = client.post(
"/api/v1.0/roomkit/join/",
{"pin_code": room.pin_code},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 200
assert response.json() == {"status": "success"}
mock_sip_instance.ensure_dispatch_rule.assert_called_once_with(room)
@mock.patch("core.roomkit.viewsets.analytics.capture")
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_tracks_analytics_event(
mock_sip_management, mock_capture, settings, client
):
"""Successful joins should be tracked with an analytics event."""
mock_sip_instance = mock_sip_management.return_value
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
room = RoomFactory(pin_code="1234567890")
mock_sip_instance.ensure_dispatch_rule.return_value = True
response = client.post(
"/api/v1.0/roomkit/join/",
{"pin_code": room.pin_code},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 200
mock_capture.assert_called_once()
_user, event, properties = mock_capture.call_args[0]
assert str(event) == "roomkit_joined"
assert properties == {
"room_id": str(room.pk),
"dispatch_rule_created": True,
}
@mock.patch("core.roomkit.viewsets.analytics.capture")
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_sip_failure(mock_sip_management, mock_capture, settings, client):
"""Requests should fail with a server error when the sip management service fails."""
mock_sip_instance = mock_sip_management.return_value
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
room = RoomFactory(pin_code="1234567890")
mock_sip_instance.ensure_dispatch_rule.side_effect = SIPException(
"Could not create dispatch rule"
)
response = client.post(
"/api/v1.0/roomkit/join/",
{"pin_code": room.pin_code},
HTTP_AUTHORIZATION="Bearer testAuthToken",
raise_request_exception=False,
)
assert response.status_code == 500
mock_sip_instance.ensure_dispatch_rule.assert_called_once_with(room)
mock_capture.assert_not_called()
@@ -3,13 +3,14 @@ Test rooms API endpoints in the Meet core app: create.
"""
# pylint: disable=redefined-outer-name,unused-argument
from django.conf import settings
from django.core.cache import cache
import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...models import Room
from ...models import Room, RoomAccessLevel
pytestmark = pytest.mark.django_db
@@ -109,3 +110,205 @@ def test_api_rooms_create_authenticated_existing_slug():
assert response.status_code == 400
assert response.json() == {"slug": ["Room with this Slug already exists."]}
def test_api_rooms_create_authenticated_user_default_access_level():
"""
The user's default room access level should be applied to the new room
when the request does not provide one.
"""
user = UserFactory(default_room_access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "my room",
},
)
assert response.status_code == 201
room = Room.objects.get()
assert room.access_level == RoomAccessLevel.RESTRICTED
def test_api_rooms_create_authenticated_explicit_access_level_overrides_default():
"""
An access level explicitly provided in the request should take precedence
over the user's default room access level.
"""
user = UserFactory(default_room_access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "my room",
"access_level": RoomAccessLevel.TRUSTED,
},
)
assert response.status_code == 201
room = Room.objects.get()
assert room.access_level == RoomAccessLevel.TRUSTED
def test_api_rooms_create_authenticated_no_user_default_access_level():
"""
When the user has no default room access level, the instance default
should be applied to the new room.
"""
user = UserFactory(default_room_access_level=None)
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "my room",
},
)
assert response.status_code == 201
room = Room.objects.get()
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
def test_api_rooms_create_authenticated_user_default_configuration():
"""
The user's default room configuration should be applied to the new room
when the request does not provide one.
"""
user = UserFactory(default_room_configuration={"everyone_can_mute": False})
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "my room",
},
)
assert response.status_code == 201
room = Room.objects.get()
assert room.configuration == {"everyone_can_mute": False}
def test_api_rooms_create_authenticated_explicit_configuration_overrides_default():
"""
A configuration explicitly provided in the request should take precedence
over the user's default room configuration.
"""
user = UserFactory(default_room_configuration={"everyone_can_mute": False})
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "my room",
"configuration": {"can_publish_sources": ["camera", "microphone"]},
},
format="json",
)
assert response.status_code == 201
room = Room.objects.get()
assert room.configuration == {"can_publish_sources": ["camera", "microphone"]}
def test_api_rooms_create_authenticated_empty_configuration_falls_back_to_default():
"""
An empty configuration in the request should not be considered an explicit
value: the user's default room configuration should still be applied.
"""
user = UserFactory(default_room_configuration={"everyone_can_mute": True})
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "my room",
"configuration": {},
},
format="json",
)
assert response.status_code == 201
room = Room.objects.get()
assert room.configuration == {"everyone_can_mute": True}
def test_api_rooms_create_authenticated_empty_user_default_configuration():
"""
When the user's default room configuration is empty, the new room should
keep its default empty configuration.
"""
user = UserFactory(default_room_configuration={})
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "my room",
},
)
assert response.status_code == 201
room = Room.objects.get()
assert room.configuration == {}
def test_api_rooms_create_authenticated_request_precedence_over_user_empty():
"""
When the user's default room configuration is empty, the request should take precedence.
"""
user = UserFactory(default_room_configuration={})
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{"name": "my room", "configuration": {"everyone_can_mute": True}},
format="json",
)
assert response.status_code == 201
room = Room.objects.get()
assert room.configuration == {"everyone_can_mute": True}
def test_api_rooms_create_authenticated_blank_user_default_access_level():
"""
A blank default room access level (stored as an empty string) should be
treated as unset: the instance default should be applied to the new room
instead of persisting an invalid empty access level.
"""
user = UserFactory(default_room_access_level="")
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "my room",
},
)
assert response.status_code == 201
room = Room.objects.get()
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
@@ -80,7 +80,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
room = RoomFactory()
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -106,7 +106,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
other_room = RoomFactory()
user = AnonymousUser()
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=False)
token = utils.generate_token(str(other_room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
response = client.post(
@@ -146,7 +146,7 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
room = RoomFactory(configuration={"everyone_can_mute": False})
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -293,7 +293,7 @@ def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
)
# Token identity matches the admin user so LiveKitTokenAuthentication
# resolves request.user back to the admin.
token = utils.generate_token(str(room.id), user, is_admin_or_owner=True)
token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -323,7 +323,7 @@ def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client)
# Token is scoped to a DIFFERENT room, and admin status must only be
# honored when established via session, never via a LiveKit
# token, which can be replayed off-host.
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=True)
token = utils.generate_token(str(other_room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
response = client.post(
@@ -354,7 +354,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
role=random.choice(["administrator", "owner"]),
)
# The token is the only credential.
token = utils.generate_token(str(room.id), admin_user, is_admin_or_owner=True)
token = utils.generate_token(str(room.id), admin_user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -374,7 +374,7 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli
room = RoomFactory()
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -405,7 +405,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
)
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -433,7 +433,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
)
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -462,7 +462,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
)
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -719,13 +719,13 @@ def test_update_participant_invalid_payload():
)
client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid"}
payload = {"participant_identity": ["test"]}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "Must be a valid UUID." in str(response.data)
assert "Not a valid string." in str(response.data)
def test_update_participant_no_update_fields():
@@ -918,7 +918,7 @@ def test_remove_participant_invalid_payload():
)
client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid"}
payload = {"participant_identity": ["invalid-uuid"]}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
@@ -12,7 +12,7 @@ import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
from ...models import RoomAccessLevel
from ...models import RoleChoices, RoomAccessLevel
pytestmark = pytest.mark.django_db
@@ -31,7 +31,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"slug": room.slug,
}
@@ -51,7 +50,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
"configuration": {},
"access_level": "trusted",
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"slug": room.slug,
}
@@ -70,7 +68,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"slug": room.slug,
}
@@ -87,7 +84,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"slug": room.slug,
}
@@ -104,7 +100,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"slug": room.slug,
}
@@ -130,6 +125,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed(mock_token):
assert response.status_code == 200
assert response.json() == {
"id": None,
"slug": "unregistered-room",
"access_level": "public",
"is_administrable": False,
"livekit": {
"url": "test_url_value",
"room": "unregistered-room",
@@ -162,6 +160,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed_not_normalized(mock_t
assert response.status_code == 200
assert response.json() == {
"id": None,
"slug": "reunion",
"access_level": "public",
"is_administrable": False,
"livekit": {
"url": "test_url_value",
"room": "reunion",
@@ -208,7 +209,6 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
"configuration": {},
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
"livekit": {
"url": "test_url_value",
"room": expected_name,
@@ -255,7 +255,6 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
"configuration": {"can_publish_sources": ["camera"]},
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
"livekit": {
"url": "test_url_value",
"room": expected_name,
@@ -272,7 +271,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
username=None,
color=None,
sources=["camera"],
is_admin_or_owner=False,
role=None,
participant_id=None,
)
@@ -307,7 +306,6 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
"configuration": {},
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
"livekit": {
"url": "test_url_value",
"room": expected_name,
@@ -324,7 +322,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
username=None,
color=None,
sources=None,
is_admin_or_owner=False,
role=None,
participant_id=None,
)
@@ -349,7 +347,6 @@ def test_api_rooms_retrieve_authenticated():
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"slug": room.slug,
}
@@ -395,7 +392,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
"configuration": {"can_publish_sources": ["camera"]},
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
"livekit": {
"url": "test_url_value",
"room": expected_name,
@@ -412,7 +408,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
username=None,
color=None,
sources=["camera"],
is_admin_or_owner=False,
role=str(RoleChoices.MEMBER),
participant_id=None,
)
@@ -457,6 +453,8 @@ def test_api_rooms_retrieve_administrators(
{
"id": str(other_user_access.id),
"user": {
"default_room_access_level": None,
"default_room_configuration": {},
"id": str(other_user_access.user.id),
"email": other_user_access.user.email,
"full_name": other_user_access.user.full_name,
@@ -470,6 +468,8 @@ def test_api_rooms_retrieve_administrators(
{
"id": str(user_access.id),
"user": {
"default_room_access_level": None,
"default_room_configuration": {},
"id": str(user_access.user.id),
"email": user_access.user.email,
"full_name": user_access.user.full_name,
@@ -487,7 +487,6 @@ def test_api_rooms_retrieve_administrators(
assert content_dict == {
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": True,
"configuration": {},
"livekit": {
"url": "test_url_value",
@@ -505,6 +504,6 @@ def test_api_rooms_retrieve_administrators(
username=None,
color=None,
sources=None,
is_admin_or_owner=True,
role=str(user_access.role),
participant_id=None,
)
@@ -0,0 +1,319 @@
"""
Test rooms API endpoints in the Meet core app: update-participant-role.
"""
# pylint: disable=redefined-outer-name,unused-argument
import uuid
from unittest import mock
import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...models import ResourceAccess, RoleChoices
from ...services.participants_management import ParticipantNotFoundException
pytestmark = pytest.mark.django_db
def test_update_participant_role_anonymous():
"""Anonymous requesters are rejected."""
client = APIClient()
room = RoomFactory()
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": "some-identity", "role": "administrator"},
format="json",
)
assert response.status_code == 401
def test_update_participant_role_requires_privileges():
"""A simple member cannot promote other participants."""
client = APIClient()
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.MEMBER)])
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": "some-identity", "role": "administrator"},
format="json",
)
assert response.status_code == 403
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_requester_not_in_meeting(mock_perm_pm):
"""An admin who is not connected to the meeting is rejected."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = False
client = APIClient()
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": "some-identity", "role": "administrator"},
format="json",
)
assert response.status_code == 403
mock_perm_pm.return_value.check_if_in_meeting.assert_called_once_with(
room_name=str(room.pk), identity=str(user.sub)
)
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_cannot_target_self(mock_perm_pm):
"""Requesters cannot change their own role."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
client = APIClient()
user = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": user.sub, "role": "member"},
format="json",
)
assert response.status_code == 403
assert response.json() == {"error": "You cannot change your own role."}
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_promotes_authenticated_target(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Promoting a connected, authenticated participant persists the role."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
mock_sync.return_value = True
client = APIClient()
admin = UserFactory()
target = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.OWNER)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(target.sub), "role": "administrator"},
format="json",
)
assert response.status_code == 200
assert response.json() == {
"role": "administrator",
"livekit_synced": True,
}
access = ResourceAccess.objects.get(resource=room, user=target)
assert access.role == RoleChoices.ADMIN
mock_sync.assert_called_once_with(
room_name=str(room.pk),
participant_identity=str(target.sub),
role="administrator",
)
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_demotes_authenticated_target(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Demoting a connected admin back to member updates the access row."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
mock_sync.return_value = True
client = APIClient()
admin = UserFactory()
target = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.OWNER), (target, RoleChoices.ADMIN)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(target.sub), "role": "member"},
format="json",
)
assert response.status_code == 200
access = ResourceAccess.objects.get(resource=room, user=target)
assert access.role == RoleChoices.MEMBER
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_cannot_demote_owner(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Room owners can never be demoted."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
client = APIClient()
admin = UserFactory()
owner = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.ADMIN), (owner, RoleChoices.OWNER)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(owner.sub), "role": "member"},
format="json",
)
assert response.status_code == 403
assert response.json() == {"error": "Room owners cannot be demoted."}
assert (
ResourceAccess.objects.get(resource=room, user=owner).role == RoleChoices.OWNER
)
mock_sync.assert_not_called()
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_anonymous_target_is_ephemeral(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Promoting an anonymous participant should not be possible."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
mock_sync.return_value = True
client = APIClient()
admin = UserFactory()
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
client.force_login(admin)
anonymous_identity = uuid.uuid4()
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": anonymous_identity, "role": "administrator"},
format="json",
)
assert response.status_code == 404
assert response.json() == {
"error": "This participant has no user account and cannot be assigned a role."
}
assert not ResourceAccess.objects.filter(resource=room).exclude(user=admin).exists()
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_target_not_in_meeting(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Only connected participants can be promoted or demoted."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.side_effect = (
ParticipantNotFoundException("Participant does not exist")
)
client = APIClient()
admin = UserFactory()
target = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(target.sub), "role": "administrator"},
format="json",
)
assert response.status_code == 404
assert not ResourceAccess.objects.filter(resource=room, user=target).exists()
mock_sync.assert_not_called()
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_is_idempotent_and_resyncs(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Promoting an existing admin succeeds and still re-syncs LiveKit."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
mock_sync.return_value = True
client = APIClient()
admin = UserFactory()
target = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.OWNER), (target, RoleChoices.ADMIN)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(target.sub), "role": "administrator"},
format="json",
)
assert response.status_code == 200
mock_sync.assert_called_once()
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_livekit_failure_reports_partial_success(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""A LiveKit sync failure does not lose the persisted role."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
mock_sync.return_value = False
client = APIClient()
admin = UserFactory()
target = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.OWNER)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(target.sub), "role": "administrator"},
format="json",
)
assert response.status_code == 200
assert response.json() == {
"role": "administrator",
"livekit_synced": False,
}
assert (
ResourceAccess.objects.get(resource=room, user=target).role == RoleChoices.ADMIN
)
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_rejects_owner_role(mock_perm_pm):
"""The owner role can never be granted through this endpoint."""
client = APIClient()
admin = UserFactory()
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
client.force_login(admin)
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": "some-identity", "role": "owner"},
format="json",
)
assert response.status_code == 400
@@ -20,8 +20,12 @@ from core.services.livekit_events import (
api,
)
from core.services.lobby import LobbyService
from core.services.telephony import TelephonyException, TelephonyService
from core.utils import MetadataUpdateException, NotificationError
from core.services.room_management import RoomManagementException
from core.services.sip_management import (
SIPException,
SIPManagement,
)
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@@ -58,7 +62,7 @@ def test_initialization(
mock_token_verifier.assert_called_once_with(api_key, api_secret)
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
assert isinstance(service.lobby_service, LobbyService)
assert isinstance(service.telephony_service, TelephonyService)
assert isinstance(service.sip_management, SIPManagement)
assert isinstance(service.recording_events, RecordingEventsService)
@@ -70,12 +74,13 @@ def test_initialization(
),
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_success(
mock_update_room_metadata, mock_notify, mode, notification_type, service
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_success( # noqa: PLR0913, PLR0917 # pylint: disable=too-many-arguments, too-many-positional-arguments
mock_update_metadata, mock_notify, mode, notification_type, service, settings
):
"""Should successfully stop recording and notifies all participant."""
settings.RECORDING_STORAGE_EVENT_ENABLE = False
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
@@ -86,8 +91,8 @@ def test_handle_egress_ended_success(
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
mock_update_metadata.assert_called_once_with(
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
)
recording.refresh_from_db()
@@ -104,9 +109,9 @@ def test_handle_egress_ended_success(
(EgressStatus.EGRESS_ABORTED, "aborted"),
),
)
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_updated_success(
mock_update_room_metadata, egress_status, status, service
mock_update_metadata, egress_status, status, service
):
"""Should successfully update room's metadata."""
@@ -117,7 +122,7 @@ def test_handle_egress_updated_success(
service._handle_egress_updated(mock_data)
mock_update_room_metadata.assert_called_once_with(
mock_update_metadata.assert_called_once_with(
str(recording.room.id), {"recording_status": status}
)
@@ -129,9 +134,9 @@ def test_handle_egress_updated_success(
EgressStatus.EGRESS_LIMIT_REACHED,
),
)
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_updated_non_handled(
mock_update_room_metadata, egress_status, service
mock_update_metadata, egress_status, service
):
"""Should ignore certain egress status and don't trigger metadata updates."""
@@ -142,7 +147,7 @@ def test_handle_egress_updated_non_handled(
service._handle_egress_updated(mock_data)
mock_update_room_metadata.assert_not_called()
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize(
@@ -153,18 +158,19 @@ def test_handle_egress_updated_non_handled(
),
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_metadata_update_fails(
mock_update_room_metadata, mock_notify, mode, notification_type, service
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_metadata_update_fails( # noqa: PLR0913, PLR0917 # pylint: disable=too-many-arguments, too-many-positional-arguments
mock_update_metadata, mock_notify, mode, notification_type, service, settings
):
"""Should successfully stop and save recording when metadata's update fails."""
settings.RECORDING_STORAGE_EVENT_ENABLE = False
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
mock_update_room_metadata.side_effect = MetadataUpdateException("Error notifying")
mock_update_metadata.side_effect = RoomManagementException("Error notifying")
service._handle_egress_ended(mock_data)
@@ -178,9 +184,9 @@ def test_handle_egress_ended_metadata_update_fails(
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_notification_fails(
mock_update_room_metadata, mock_notify, service
mock_update_metadata, mock_notify, service
):
"""Should raise ActionFailedError when notification fails but still stop recording."""
@@ -200,15 +206,15 @@ def test_handle_egress_ended_notification_fails(
recording.refresh_from_db()
assert recording.status == "stopped"
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
mock_update_metadata.assert_called_once_with(
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_recording_not_found(
mock_update_room_metadata, mock_notify, service
mock_update_metadata, mock_notify, service
):
"""Should raise ActionFailedError when recording doesn't exist."""
@@ -223,16 +229,16 @@ def test_handle_egress_ended_recording_not_found(
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
mock_update_room_metadata.assert_not_called()
mock_update_metadata.assert_not_called()
recording.refresh_from_db()
assert recording.status == "active"
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_recording_not_active(
mock_update_room_metadata, mock_notify, service
mock_update_metadata, mock_notify, service
):
"""Should ignore non-active recordings."""
@@ -244,8 +250,8 @@ def test_handle_egress_ended_recording_not_active(
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
mock_update_metadata.assert_called_once_with(
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
)
recording.refresh_from_db()
@@ -253,9 +259,9 @@ def test_handle_egress_ended_recording_not_active(
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_recording_not_limit_reached(
mock_update_room_metadata, mock_notify, service
mock_update_metadata, mock_notify, service
):
"""Should ignore egress non-limit-reached statuses."""
@@ -267,16 +273,16 @@ def test_handle_egress_ended_recording_not_limit_reached(
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
mock_update_metadata.assert_called_once_with(
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
)
assert recording.status == "stopped"
@mock.patch("core.services.livekit_events.MetadataCollectorService")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_met(
mock_update_room_metadata, mock_collector_class, service, settings
mock_update_metadata, mock_collector_class, service, settings
):
"""Should call MetadataCollectorService.stop when it exists."""
settings.METADATA_COLLECTOR_ENABLED = True
@@ -306,7 +312,7 @@ def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_m
],
)
@mock.patch("core.services.livekit_events.MetadataCollectorService")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met(
_, mock_collector_class, metadata_enabled, options, service, settings
): # pylint: disable=too-many-arguments,too-many-positional-arguments
@@ -335,7 +341,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
@pytest.mark.parametrize(
"egress_status",
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
@@ -344,8 +350,8 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
"notify_return_value, recording_status",
[(True, "notification_succeeded"), (False, "saved")],
)
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
mock_update_room_metadata,
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
mock_update_metadata,
mock_notify,
mock_notify_external_services,
notify_return_value,
@@ -378,7 +384,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
@pytest.mark.parametrize(
"egress_status, expected_status",
[
@@ -386,8 +392,8 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
(EgressStatus.EGRESS_LIMIT_REACHED, "stopped"),
],
)
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
mock_update_room_metadata,
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913, PLR0917
mock_update_metadata,
mock_notify,
mock_notify_external_services,
egress_status,
@@ -424,9 +430,9 @@ def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: P
EgressStatus.EGRESS_ABORTED,
],
)
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_does_not_save_on_wrong_status(
mock_update_room_metadata, egress_status, service, settings
mock_update_metadata, egress_status, service, settings
):
"""Shouldn't save on invalid status."""
settings.RECORDING_STORAGE_EVENT_ENABLE = False
@@ -445,9 +451,9 @@ def test_handle_egress_ended_does_not_save_on_wrong_status(
@pytest.mark.parametrize(
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
)
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_ignores_non_savable_recording(
mock_update_room_metadata, status, service, settings
mock_update_metadata, status, service, settings
):
"""Should handle non-savable recordings idempotently without raising.
@@ -468,11 +474,11 @@ def test_handle_egress_ended_ignores_non_savable_recording(
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should clear lobby cache and delete telephony dispatch rule when room finishes."""
"""Should clear lobby cache and delete SIP dispatch rule when room finishes."""
settings.ROOM_TELEPHONY_ENABLED = True
mock_room_name = uuid.uuid4()
mock_data = mock.MagicMock()
@@ -485,12 +491,31 @@ def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
def test_handle_room_finished_deletes_dispatch_rule_when_only_roomkit_enabled(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should delete dispatch rule when only roomkit is enabled when room finishes."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = True
mock_room_name = uuid.uuid4()
mock_data = mock.MagicMock()
mock_data.room.name = str(mock_room_name)
service._handle_room_finished(mock_data)
mock_delete_dispatch_rule.assert_called_once_with(mock_room_name)
mock_clear_cache.assert_called_once_with(mock_room_name)
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
def test_handle_room_finished_skips_telephony_when_disabled(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should clear lobby cache but skip dispatch rule deletion when telephony is disabled."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = False
mock_room_name = uuid.uuid4()
mock_data = mock.MagicMock()
mock_data.room.name = str(mock_room_name)
@@ -504,7 +529,7 @@ def test_handle_room_finished_skips_telephony_when_disabled(
@mock.patch.object(
LobbyService, "clear_room_cache", side_effect=Exception("Test error")
)
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
def test_handle_room_finished_raises_error_when_cache_clearing_fails(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
@@ -527,9 +552,9 @@ def test_handle_room_finished_raises_error_when_cache_clearing_fails(
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(
TelephonyService,
SIPManagement,
"delete_dispatch_rule",
side_effect=TelephonyException("Test error"),
side_effect=SIPException("Test error"),
)
def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
@@ -540,7 +565,7 @@ def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
expected_error = (
"Failed to delete telephony dispatch rule for room "
"Failed to delete sip dispatch rule for room "
"00000000-0000-0000-0000-000000000000"
)
@@ -561,11 +586,11 @@ def test_handle_room_finished_raises_error_for_invalid_room_name(service):
service._handle_room_finished(mock_data)
@mock.patch.object(TelephonyService, "create_dispatch_rule")
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
def test_handle_room_started_creates_dispatch_rule_successfully(
mock_create_dispatch_rule, service, settings
mock_ensure_dispatch_rule, service, settings
):
"""Should create telephony dispatch rule when room starts successfully."""
"""Should ensure the SIP dispatch rule exists when room starts successfully."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
mock_data = mock.MagicMock()
@@ -573,22 +598,75 @@ def test_handle_room_started_creates_dispatch_rule_successfully(
service._handle_room_started(mock_data)
mock_create_dispatch_rule.assert_called_once_with(room)
mock_ensure_dispatch_rule.assert_called_once_with(room)
@mock.patch.object(TelephonyService, "create_dispatch_rule")
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
mock_create_dispatch_rule, service, settings
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
def test_handle_room_started_creates_dispatch_rule_when_only_roomkit_enabled(
mock_ensure_dispatch_rule, service, settings
):
"""Should skip creating telephony dispatch rule when telephony is disabled during room start."""
"""Should ensure the dispatch rule exists when only roomkit is enabled during room start."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = True
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
mock_create_dispatch_rule.assert_not_called()
mock_ensure_dispatch_rule.assert_called_once_with(room)
@mock.patch.object(SIPManagement, "ensure_dispatch_rule", return_value=False)
def test_handle_room_started_ignores_existing_dispatch_rule(
mock_ensure_dispatch_rule, service, settings
):
"""Should proceed silently when the dispatch rule already exists when room starts."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
# ensure_dispatch_rule reports the rule as pre-existing: nothing to raise
service._handle_room_started(mock_data)
mock_ensure_dispatch_rule.assert_called_once_with(room)
@mock.patch.object(
SIPManagement,
"ensure_dispatch_rule",
side_effect=SIPException("Test error"),
)
def test_handle_room_started_raises_error_when_dispatch_rule_creation_fails(
mock_ensure_dispatch_rule, service, settings
):
"""Should raise ActionFailedError when ensuring the dispatch rule fails when room starts."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
expected_error = f"Failed to create sip dispatch rule for room {room.id}"
with pytest.raises(ActionFailedError, match=expected_error):
service._handle_room_started(mock_data)
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
mock_ensure_dispatch_rule, service, settings
):
"""Should skip ensuring the SIP dispatch rule when telephony is disabled during room start."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = False
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
mock_ensure_dispatch_rule.assert_not_called()
def test_handle_room_started_raises_error_for_invalid_room_name(service):
@@ -642,6 +720,7 @@ def test_receive_unsupported_event(mock_receive, service):
# Mock returned data with unsupported event type
mock_data = mock.MagicMock()
mock_data.room.name = str(uuid.uuid4())
mock_data.event = "unsupported_event"
mock_receive.return_value = mock_data
@@ -745,3 +824,33 @@ def test_receive_filter_processes_matching_events(
service.receive(mock_request)
mock_handle_room_started.assert_called_once()
@mock.patch.object(api.WebhookReceiver, "receive")
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
@mock.patch.object(LiveKitEventsService, "_handle_room_started")
def test_receive_ignores_connection_test_room(
mock_handle_room_started,
mock_handle_room_finished,
mock_receive,
mock_livekit_config,
settings,
):
"""Should ignore all webhook events for connection test rooms in receive()."""
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}"
mock_data = mock.MagicMock()
mock_data.room.name = f"{settings.CONNECTION_TEST_ROOM_PREFIX}-{uuid.uuid4()}"
mock_data.event = "room_started"
mock_receive.return_value = mock_data
service = LiveKitEventsService()
service.receive(mock_request)
mock_handle_room_started.assert_not_called()
mock_handle_room_finished.assert_not_called()
+73 -18
View File
@@ -3,19 +3,20 @@ Test lobby service.
"""
# pylint: disable=W0621,W0613, W0212, R0913
# ruff: noqa: PLR0913
# ruff: noqa: PLR0913, PLR0917
import uuid
from unittest import mock
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache
from django.http import HttpResponse
import pytest
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
from core.models import RoleChoices, RoomAccessLevel
from core.services.lobby import (
LobbyParticipant,
LobbyParticipantNotFound,
@@ -188,17 +189,17 @@ def test_prepare_response_new_cookie(lobby_service, participant_id):
def test_can_bypass_lobby_public_room(lobby_service):
"""Should return True for public rooms regardless of user auth."""
"""Should return True for public rooms regardless of user auth and role."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
# Anonymous user
user = mock.Mock()
user.is_authenticated = False
assert lobby_service.can_bypass_lobby(room, user) is True
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
# Authenticated user
user.is_authenticated = True
assert lobby_service.can_bypass_lobby(room, user) is True
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
@@ -208,7 +209,7 @@ def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
# Authenticated user
user = mock.Mock()
user.is_authenticated = True
assert lobby_service.can_bypass_lobby(room, user) is True
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
@@ -218,21 +219,34 @@ def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
# Anonymous user
user = mock.Mock()
user.is_authenticated = False
assert lobby_service.can_bypass_lobby(room, user) is False
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
def test_can_bypass_lobby_private_room(lobby_service):
"""Should return False for private rooms regardless of user auth."""
"""Should return False for private rooms regardless of user auth if role is not."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Anonymous user
user = mock.Mock()
user.is_authenticated = False
assert lobby_service.can_bypass_lobby(room, user) is False
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
# Authenticated user
user.is_authenticated = True
assert lobby_service.can_bypass_lobby(room, user) is False
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
@pytest.mark.parametrize(
"role",
[RoleChoices.MEMBER, RoleChoices.ADMIN, RoleChoices.OWNER],
)
def test_can_bypass_lobby_private_room_with_any_role(role, lobby_service):
"""Should return True for private rooms if the user is authenticated and has any role."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = mock.Mock()
user.is_authenticated = True
assert lobby_service.can_bypass_lobby(room, user, role=role) is True
@mock.patch("core.utils.generate_livekit_config")
@@ -241,7 +255,7 @@ def test_request_entry_public_room(
):
"""Test requesting entry to a public room."""
request = mock.Mock()
request.user = mock.Mock()
request.user = AnonymousUser()
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
@@ -266,8 +280,8 @@ def test_request_entry_public_room(
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
role=None,
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -279,8 +293,7 @@ def test_request_entry_trusted_room(
):
"""Test requesting entry to a trusted room when the user is authenticated."""
request = mock.Mock()
request.user = mock.Mock()
request.user.is_authenticated = True
request.user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
@@ -305,8 +318,8 @@ def test_request_entry_trusted_room(
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
role=None,
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -319,6 +332,7 @@ def test_request_entry_new_participant(
"""Test requesting entry for a new participant."""
request = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
request.user = AnonymousUser()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -348,6 +362,7 @@ def test_request_entry_waiting_participant(
"""Test requesting entry for a waiting participant."""
request = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
request.user = AnonymousUser()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -374,7 +389,7 @@ def test_request_entry_accepted_participant(
):
"""Test requesting entry for an accepted participant."""
request = mock.Mock()
request.user = mock.Mock()
request.user = AnonymousUser()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -400,8 +415,48 @@ def test_request_entry_accepted_participant(
username=username,
color="#123456",
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
role=None,
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.utils.generate_livekit_config")
def test_request_entry_participant_with_role(
mock_generate_config, lobby_service, participant_id, username
):
"""Test requesting entry for a participant with a role on the room."""
request = mock.Mock()
request.user = UserFactory()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
UserResourceAccessFactory(resource=room, user=request.user, role="administrator")
mocked_participant = LobbyParticipant(
status=LobbyParticipantStatus.ACCEPTED,
username=username,
id=participant_id,
color="#123456",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
mock_generate_config.return_value = {"token": "test-token"}
participant, livekit_config = lobby_service.request_entry(room, request, username)
assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"}
mock_generate_config.assert_called_once_with(
room_id=str(room.id),
user=request.user,
username=username,
color="#123456",
configuration=room.configuration,
participant_id="test-participant-id",
role="administrator",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -0,0 +1,60 @@
"""Tests for the RoomManagement service."""
from unittest import mock
import pytest
from livekit.api import TwirpError
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
@mock.patch("core.services.room_management.utils.create_livekit_client")
def test_delete_room_calls_livekit(mock_create_livekit_client):
"""DeleteRoom is forwarded to the LiveKit API."""
mock_api = mock.MagicMock()
mock_api.room.delete_room = mock.AsyncMock()
mock_api.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api
RoomManagement().delete_room("room-abc")
mock_api.room.delete_room.assert_awaited_once()
request = mock_api.room.delete_room.await_args.args[0]
assert request.room == "room-abc"
mock_api.aclose.assert_awaited_once()
@mock.patch("core.services.room_management.utils.create_livekit_client")
def test_delete_room_raises_not_found(mock_create_livekit_client):
"""Missing rooms raise RoomNotFoundException."""
mock_api = mock.MagicMock()
mock_api.room.delete_room = mock.AsyncMock(
side_effect=TwirpError("not_found", "room not found", status=404)
)
mock_api.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api
with pytest.raises(RoomNotFoundException):
RoomManagement().delete_room("missing-room")
mock_api.aclose.assert_awaited_once()
@mock.patch("core.services.room_management.utils.create_livekit_client")
def test_delete_room_raises_management_exception(mock_create_livekit_client):
"""Unexpected Twirp errors raise RoomManagementException."""
mock_api = mock.MagicMock()
mock_api.room.delete_room = mock.AsyncMock(
side_effect=TwirpError("internal", "boom", status=500)
)
mock_api.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api
with pytest.raises(RoomManagementException):
RoomManagement().delete_room("room-abc")
mock_api.aclose.assert_awaited_once()
@@ -1,5 +1,5 @@
"""
Test telephony service.
Test SIP mamagement service.
"""
# pylint: disable=W0212
@@ -20,7 +20,11 @@ from livekit.protocol.sip import (
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.services.telephony import TelephonyException, TelephonyService
from core.services.sip_management import (
DispatchRuleConflictError,
SIPException,
SIPManagement,
)
pytestmark = pytest.mark.django_db
@@ -35,9 +39,9 @@ def create_mock_livekit_client():
def test_rule_name():
"""Test rule name generation."""
telephony_service = TelephonyService()
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
rule_name = telephony_service._rule_name(room.id)
rule_name = sip_management._rule_name(room.id)
assert rule_name == f"SIP_{str(room.id)}"
@@ -45,14 +49,14 @@ def test_rule_name():
@mock.patch("core.utils.create_livekit_client")
def test_create_dispatch_rule_success(mock_client_factory):
"""Test successful dispatch rule creation."""
telephony_service = TelephonyService()
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
telephony_service.create_dispatch_rule(room)
sip_management.create_dispatch_rule(room)
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
@@ -67,7 +71,7 @@ def test_create_dispatch_rule_success(mock_client_factory):
@mock.patch("core.utils.create_livekit_client")
def test_create_dispatch_rule_api_failure(mock_client_factory):
"""Test dispatch rule creation when API fails."""
telephony_service = TelephonyService()
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
@@ -76,8 +80,8 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
)
mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not create dispatch rule"):
telephony_service.create_dispatch_rule(room)
with pytest.raises(SIPException, match="Could not create dispatch rule"):
sip_management.create_dispatch_rule(room)
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once()
@@ -86,7 +90,7 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_success(mock_client_factory):
"""Test successful listing of dispatch rule IDs."""
telephony_service = TelephonyService()
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_rules = [
@@ -111,7 +115,7 @@ def test_list_dispatch_rules_ids_success(mock_client_factory):
)
mock_client_factory.return_value = mock_api
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
assert len(result) == 2
assert "rule-1" in result
@@ -127,7 +131,7 @@ def test_list_dispatch_rules_ids_success(mock_client_factory):
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
"""Test listing dispatch rule IDs when no rules exist."""
telephony_service = TelephonyService()
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
@@ -136,7 +140,7 @@ def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
)
mock_client_factory.return_value = mock_api
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
assert result == []
mock_api.aclose.assert_called_once()
@@ -145,7 +149,7 @@ def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
"""Test listing dispatch rule IDs when no rules match the room."""
telephony_service = TelephonyService()
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_rules = [
@@ -163,7 +167,7 @@ def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
)
mock_client_factory.return_value = mock_api
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
assert result == []
mock_api.aclose.assert_called_once()
@@ -172,7 +176,7 @@ def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
"""Test listing dispatch rule IDs when API fails."""
telephony_service = TelephonyService()
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
@@ -181,34 +185,34 @@ def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
)
mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not list dispatch rules"):
async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
with pytest.raises(SIPException, match="Could not list dispatch rules"):
async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules):
"""Test deleting dispatch rules when no rules exist."""
telephony_service = TelephonyService()
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = []
result = telephony_service.delete_dispatch_rule(room.id)
result = sip_management.delete_dispatch_rule(room.id)
assert result is False
mock_list_rules.assert_called_once_with(room.id)
mock_client_factory.assert_not_called()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
"""Test deleting a single dispatch rule."""
telephony_service = TelephonyService()
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1"]
@@ -216,7 +220,7 @@ def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
result = telephony_service.delete_dispatch_rule(room.id)
result = sip_management.delete_dispatch_rule(room.id)
assert result is True
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
@@ -226,11 +230,11 @@ def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules):
"""Test deleting multiple dispatch rules."""
telephony_service = TelephonyService()
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
@@ -238,7 +242,7 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
result = telephony_service.delete_dispatch_rule(room.id)
result = sip_management.delete_dispatch_rule(room.id)
assert result is True
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3
@@ -253,11 +257,11 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
"""Test deleting multiple dispatch rules when one deletion fails."""
telephony_service = TelephonyService()
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
@@ -277,18 +281,18 @@ def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rul
)
mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
telephony_service.delete_dispatch_rule(room.id)
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
sip_management.delete_dispatch_rule(room.id)
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
"""Test deleting dispatch rules when API fails immediately."""
telephony_service = TelephonyService()
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1"]
@@ -298,8 +302,131 @@ def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
)
mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
telephony_service.delete_dispatch_rule(room.id)
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
sip_management.delete_dispatch_rule(room.id)
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_create_dispatch_rule_conflict_raises_dedicated_error(mock_client_factory):
"""Test that a LiveKit conflict error raises DispatchRuleConflictError."""
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(
msg=(
"Dispatch rule for the same trunk, inbound number, number, and "
"PIN combination already exists in dispatch rule"
),
code="already_exists",
status=409,
)
)
mock_client_factory.return_value = mock_api
with pytest.raises(DispatchRuleConflictError):
sip_management.create_dispatch_rule(room)
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_ensure_dispatch_rule_creates_when_missing(mock_client_factory):
"""Test that ensure_dispatch_rule creates the rule when none exists."""
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
return_value=ListSIPDispatchRuleResponse(items=[])
)
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
created = sip_management.ensure_dispatch_rule(room)
assert created is True
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
assert isinstance(create_request, CreateSIPDispatchRuleRequest)
assert create_request.name == f"SIP_{str(room.id)}"
assert create_request.rule.dispatch_rule_direct.room_name == str(room.id)
assert create_request.rule.dispatch_rule_direct.pin == str(room.pin_code)
@mock.patch("core.utils.create_livekit_client")
def test_ensure_dispatch_rule_skips_when_existing(mock_client_factory):
"""Test that ensure_dispatch_rule is idempotent when the rule already exists."""
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
existing_rule = SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-1", name=f"SIP_{str(room.id)}"
)
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
return_value=ListSIPDispatchRuleResponse(items=[existing_rule])
)
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
created = sip_management.ensure_dispatch_rule(room)
assert created is False
mock_api.sip.create_sip_dispatch_rule.assert_not_called()
@mock.patch("core.utils.create_livekit_client")
def test_ensure_dispatch_rule_returns_false_on_conflict(mock_client_factory):
"""Test that ensure_dispatch_rule tolerates a concurrent rule creation.
If the rule is created by a concurrent caller (e.g. the LiveKit webhook)
between the existence check and the creation, LiveKit rejects the
duplicate and ensure_dispatch_rule reports the rule as already existing.
"""
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
return_value=ListSIPDispatchRuleResponse(items=[])
)
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(
msg=(
"Dispatch rule for the same trunk, inbound number, number, and "
"PIN combination already exists in dispatch rule"
),
code="already_exists",
status=409,
)
)
mock_client_factory.return_value = mock_api
created = sip_management.ensure_dispatch_rule(room)
assert created is False
@mock.patch("core.utils.create_livekit_client")
def test_ensure_dispatch_rule_raises_on_other_failures(mock_client_factory):
"""Test that ensure_dispatch_rule propagates unexpected LiveKit failures."""
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
return_value=ListSIPDispatchRuleResponse(items=[])
)
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(msg="Internal server error", code="unknown", status=500)
)
mock_client_factory.return_value = mock_api
with pytest.raises(SIPException, match="Could not create dispatch rule"):
sip_management.ensure_dispatch_rule(room)
@@ -0,0 +1,51 @@
"""Tests for connection test Celery tasks."""
from unittest import mock
from django.test.utils import override_settings
from core.services.room_management import (
RoomManagementException,
RoomNotFoundException,
)
from core.tasks.connection_test import delete_connection_test_room
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
def test_delete_connection_test_room_calls_room_management(mock_delete_room, settings):
"""RoomManagement.delete_room is called for connection-test rooms."""
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
delete_connection_test_room("connection-test-abc")
mock_delete_room.assert_called_once_with("connection-test-abc")
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
def test_delete_connection_test_room_refuses_other_rooms(mock_delete_room, settings):
"""Refuse to delete rooms outside the connection-test namespace."""
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
delete_connection_test_room("production-room")
mock_delete_room.assert_not_called()
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
def test_delete_connection_test_room_ignores_missing_room(mock_delete_room, settings):
"""Missing rooms are treated as already cleaned up."""
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
mock_delete_room.side_effect = RoomNotFoundException("Room does not exist")
delete_connection_test_room("connection-test-gone")
mock_delete_room.assert_called_once_with("connection-test-gone")
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
def test_delete_connection_test_room_logs_other_failures(mock_delete_room, settings):
"""Unexpected LiveKit failures are swallowed after logging."""
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
mock_delete_room.side_effect = RoomManagementException("Could not delete room")
delete_connection_test_room("connection-test-fail")
mock_delete_room.assert_called_once_with("connection-test-fail")
@@ -0,0 +1,166 @@
"""Test diagnostics API endpoints."""
import uuid
from unittest import mock
from django.test.utils import override_settings
from django.urls import reverse
import jwt
import pytest
from rest_framework.test import APIClient
from core.api.throttling import (
ConnectionTestAnonRateThrottle,
ConnectionTestUserRateThrottle,
)
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
def test_api_diagnostics_connection_url():
"""The connection check is exposed under the diagnostics namespace."""
assert reverse("diagnostics-connection") == "/api/v1.0/diagnostics/connection/"
def test_api_diagnostics_connection_rejects_get():
"""Only POST is exposed, the endpoint has no side effect to trigger."""
client = APIClient()
response = client.get("/api/v1.0/diagnostics/connection/")
assert response.status_code == 405
def test_api_diagnostics_connection_returns_ephemeral_livekit_config(settings, client):
"""Each request gets a dedicated room and a short-lived token."""
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 600
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
response_a = client.post("/api/v1.0/diagnostics/connection/")
response_b = client.post("/api/v1.0/diagnostics/connection/")
assert response_a.status_code == 200
assert response_b.status_code == 200
data_a = response_a.json()
data_b = response_b.json()
room_a = data_a["livekit"]["room"]
room_b = data_b["livekit"]["room"]
assert room_a.startswith("connection-test-")
assert room_b.startswith("connection-test-")
uuid.UUID(room_a.removeprefix("connection-test-"))
uuid.UUID(room_b.removeprefix("connection-test-"))
assert room_a != room_b
assert data_a["livekit"]["url"]
assert data_a["livekit"]["token"]
assert data_a["livekit"]["expires_in"] == 600
assert data_a["livekit"]["token"] != data_b["livekit"]["token"]
def test_api_diagnostics_connection_token_is_short_lived_for_user(settings, client):
"""Connection test tokens expire quickly for users."""
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 300
client = APIClient()
response = client.post("/api/v1.0/diagnostics/connection/")
assert response.status_code == 200
config = response.json()["livekit"]
payload = jwt.decode(
config["token"],
settings.LIVEKIT_CONFIGURATION["api_secret"],
algorithms=["HS256"],
options={"verify_exp": False},
)
assert config["expires_in"] == 300
assert payload["video"]["room"] == config["room"]
assert payload["name"] == "Connection Test"
assert payload["video"]["roomAdmin"] is False
assert payload["exp"] - payload["nbf"] == 300
@override_settings()
def test_api_diagnostics_connection_token_for_authenticated_user(settings, client):
"""Logged-in users get a token bound to their own identity."""
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 300
user = UserFactory()
client.force_login(user)
response = client.post("/api/v1.0/diagnostics/connection/")
assert response.status_code == 200
payload = jwt.decode(
response.json()["livekit"]["token"],
settings.LIVEKIT_CONFIGURATION["api_secret"],
algorithms=["HS256"],
options={"verify_exp": False},
)
assert payload["sub"] == str(user.sub)
assert payload["video"]["roomAdmin"] is False
assert payload["exp"] - payload["nbf"] == 300
@mock.patch("core.api.viewsets.delete_connection_test_room.apply_async")
def test_api_diagnostics_connection_schedules_room_deletion(
mock_apply_async, settings, client
):
"""When Celery is enabled, schedule a hard room delete after max age."""
settings.CELERY_ENABLED = True
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 300
settings.CONNECTION_TEST_ROOM_EXTRA_AGE_SECONDS = 10
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
response = client.post("/api/v1.0/diagnostics/connection/")
assert response.status_code == 200
room = response.json()["livekit"]["room"]
mock_apply_async.assert_called_once_with(args=[room], countdown=310)
@mock.patch("core.api.viewsets.delete_connection_test_room.apply_async")
def test_api_diagnostics_connection_skips_room_deletion_without_celery(
mock_apply_async, settings, client
):
"""Without Celery, do not schedule deletion (apply_async would run immediately)."""
settings.CELERY_ENABLED = False
response = client.post("/api/v1.0/diagnostics/connection/")
assert response.status_code == 200
mock_apply_async.assert_not_called()
@pytest.mark.parametrize(
"throttle_class",
[ConnectionTestAnonRateThrottle, ConnectionTestUserRateThrottle],
)
def test_api_diagnostics_connection_is_throttled(throttle_class, client):
"""Both throttles stay wired to the action once routed through the viewset."""
with (
mock.patch.object(throttle_class, "allow_request", return_value=False),
mock.patch.object(throttle_class, "wait", return_value=42),
):
response = client.post("/api/v1.0/diagnostics/connection/")
assert response.status_code == 429
def test_api_diagnostics_connection_feature_flag(client, settings):
"""Should return a not found error when the connection diagnostics feature is disabled."""
settings.CONNECTION_TEST_ENABLED = False
response = client.post("/api/v1.0/diagnostics/connection/")
assert response.status_code == 404
+2
View File
@@ -119,6 +119,8 @@ def test_api_users_retrieve_me_authenticated(settings):
assert response.status_code == 200
assert response.json() == {
"default_room_access_level": None,
"default_room_configuration": {},
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
@@ -0,0 +1,111 @@
"""
Test the default room preferences exposed on the users API.
"""
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_users_me_includes_default_room_preferences():
"""The "me" endpoint should expose the user's default room preferences."""
user = factories.UserFactory(
default_room_access_level="restricted",
default_room_configuration={"everyone_can_mute": False},
)
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 200
content = response.json()
assert content["default_room_access_level"] == "restricted"
assert content["default_room_configuration"] == {"everyone_can_mute": False}
def test_api_users_update_default_room_preferences():
"""Users should be able to update their own default room preferences."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/users/{user.id!s}/",
{
"default_room_access_level": "trusted",
"default_room_configuration": {
"can_publish_sources": ["microphone", "camera"],
"everyone_can_mute": False,
},
},
format="json",
)
assert response.status_code == 200
user.refresh_from_db()
assert user.default_room_access_level == "trusted"
assert user.default_room_configuration == {
"can_publish_sources": ["microphone", "camera"],
"everyone_can_mute": False,
}
def test_api_users_update_default_room_access_level_invalid():
"""An invalid access level should be rejected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/users/{user.id!s}/",
{"default_room_access_level": "invalid"},
format="json",
)
assert response.status_code == 400
user.refresh_from_db()
assert user.default_room_access_level is None
def test_api_users_update_default_room_configuration_invalid():
"""An invalid room configuration should be rejected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/users/{user.id!s}/",
{"default_room_configuration": {"unknown_field": True}},
format="json",
)
assert response.status_code == 400
user.refresh_from_db()
assert user.default_room_configuration == {}
def test_api_users_update_other_user_default_room_preferences_forbidden():
"""Users should not be able to update someone else's preferences."""
user = factories.UserFactory()
other_user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/users/{other_user.id!s}/",
{"default_room_access_level": "restricted"},
format="json",
)
assert response.status_code == 403
other_user.refresh_from_db()
assert other_user.default_room_access_level is None
+15 -2
View File
@@ -184,12 +184,13 @@ def test_models_rooms_is_public_property():
@mock.patch.object(Room, "generate_unique_pin_code")
def test_telephony_disabled_skips_pin_generation(
def test_telephony_and_roomkit_disabled_skips_pin_generation(
mock_generate_unique_pin_code, settings
):
"""Telephony disabled should not generate pin codes."""
"""Telephony and roomkit both disabled should not generate pin codes."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = False
room = RoomFactory()
@@ -197,6 +198,18 @@ def test_telephony_disabled_skips_pin_generation(
assert room.pin_code is None
def test_roomkit_enabled_generates_pin_code(settings):
"""Roomkit enabled alone should generate pin codes, even without telephony."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = True
room = RoomFactory()
assert room.pin_code is not None
assert len(room.pin_code) == settings.ROOM_TELEPHONY_PIN_LENGTH
def test_default_and_custom_pin_length(settings):
"""Pin codes should be created with correct configured length."""
+97 -1
View File
@@ -2,13 +2,109 @@
Test utils functions
"""
# pylint: disable=W0621
import json
from unittest import mock
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
import jwt
import pytest
from livekit.api import TwirpError
from core.utils import NotificationError, create_livekit_client, notify_participants
from core.factories import UserFactory
from core.utils import (
NotificationError,
create_livekit_client,
generate_token,
notify_participants,
)
pytestmark = pytest.mark.django_db
def decode_token(token: str) -> dict:
"""Decode a LiveKit JWT access token for inspection."""
return jwt.decode(
token,
settings.LIVEKIT_CONFIGURATION["api_secret"],
algorithms=["HS256"],
)
def test_generate_token_authenticated_uses_full_name():
"""The token's display name should default to the user's full name."""
user = UserFactory(full_name="Jane Doe")
token = generate_token(room="my-room", user=user)
claims = decode_token(token)
assert claims["name"] == "Jane Doe"
assert claims["sub"] == str(user.sub)
def test_generate_token_authenticated_fallback_user_representation():
"""
When the user has no full name, the token's display name should fall back
to the user's string representation.
"""
user = UserFactory(full_name=None)
token = generate_token(room="my-room", user=user)
claims = decode_token(token)
assert claims["name"] == str(user)
def test_generate_token_explicit_username_overrides_default():
"""An explicitly provided username should take precedence over the full name."""
user = UserFactory(full_name="Jane Doe")
token = generate_token(room="my-room", user=user, username="Custom Name")
claims = decode_token(token)
assert claims["name"] == "Custom Name"
def test_authenticated_username_ignored_when_editing_disabled(settings):
"""With editing disabled, an authenticated user's username is ignored."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
user = UserFactory(full_name="Jane Doe")
token = generate_token(room="my-room", user=user, username="Custom Name")
claims = decode_token(token)
assert claims["name"] == "Jane Doe"
def test_authenticated_default_name_unaffected_when_editing_disabled(settings):
"""Disabling editing doesn't disturb the default full-name path."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
user = UserFactory(full_name="Jane Doe")
token = generate_token(room="my-room", user=user)
claims = decode_token(token)
assert claims["name"] == "Jane Doe"
def test_anonymous_uses_username_when_provided():
"""An anonymous user's provided username is used as the display name."""
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
claims = decode_token(token)
assert claims["name"] == "Guest42"
def test_anonymous_username_used_even_when_editing_disabled(settings):
"""The setting governs authenticated users only; anonymous can still set a name."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
claims = decode_token(token)
assert claims["name"] == "Guest42"
def test_anonymous_falls_back_to_anonymous_label():
"""With no username, an anonymous user is labelled 'Anonymous'."""
token = generate_token(room="my-room", user=AnonymousUser())
claims = decode_token(token)
assert claims["name"] == "Anonymous"
@mock.patch("asyncio.get_running_loop")
+11
View File
@@ -9,6 +9,7 @@ from rest_framework.routers import DefaultRouter, SimpleRouter
from core.addons import viewsets as addons_viewsets
from core.api import get_frontend_configuration, viewsets
from core.external_api import viewsets as external_viewsets
from core.roomkit import viewsets as roomkit_viewsets
# - Main endpoints
router = DefaultRouter()
@@ -19,11 +20,21 @@ router.register("files", viewsets.FileViewSet, basename="files")
router.register(
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
)
router.register(
"roomkit",
roomkit_viewsets.RoomKitViewSet,
basename="roomkit",
)
router.register(
"addons/sessions",
addons_viewsets.SessionViewSet,
basename="addons_sessions",
)
router.register(
"diagnostics",
viewsets.DiagnosticsViewSet,
basename="diagnostics",
)
# - External API
external_router = SimpleRouter()
+31 -66
View File
@@ -12,6 +12,7 @@ import mimetypes
import random
import secrets
import string
from datetime import timedelta
from functools import lru_cache
from typing import List, Optional
from uuid import uuid4
@@ -31,7 +32,6 @@ from livekit.api import ( # pylint: disable=E0611
LiveKitAPI,
SendDataRequest,
TwirpError,
UpdateRoomMetadataRequest,
VideoGrants,
)
@@ -60,14 +60,15 @@ def generate_color(identity: str) -> str:
return f"hsl({hue}, {saturation}%, {lightness}%)"
def generate_token(
def generate_token( # noqa: PLR0917
room: str,
user,
username: Optional[str] = None,
color: Optional[str] = None,
sources: Optional[List[str]] = None,
is_admin_or_owner: bool = False,
role: Optional[str] = None,
participant_id: Optional[str] = None,
ttl: Optional[timedelta] = None,
) -> str:
"""Generate a LiveKit access token for a user in a specific room.
@@ -80,14 +81,16 @@ def generate_token(
If none, a value will be generated
sources: (Optional[List[str]]): List of media sources the user can publish
If none, defaults to LIVEKIT_DEFAULT_SOURCES.
is_admin_or_owner (bool): Whether user has admin privileges
role (Optional[str]): Room's access role if any
participant_id (Optional[str]): Stable identifier for anonymous users;
used as identity when user.is_anonymous.
ttl (Optional[timedelta]): Token validity duration. Defaults to LiveKit SDK default.
Returns:
str: The LiveKit JWT access token.
"""
is_admin_or_owner = role in ("owner", "administrator")
if is_admin_or_owner:
sources = settings.LIVEKIT_DEFAULT_SOURCES
@@ -109,11 +112,16 @@ def generate_token(
default_username = "Anonymous"
else:
identity = str(user.sub)
default_username = str(user)
default_username = user.full_name or str(user)
if color is None:
color = generate_color(identity)
can_edit = (
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME or user.is_anonymous
)
display_name = (username or default_username) if can_edit else default_username
token = (
AccessToken(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
@@ -121,20 +129,26 @@ def generate_token(
)
.with_grants(video_grants)
.with_identity(identity)
.with_name(username or default_username)
.with_name(display_name)
.with_attributes(
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
{
"color": color,
"room_role": role,
"is_authenticated": "true" if user.is_authenticated else "false",
}
)
)
if ttl is not None:
token = token.with_ttl(ttl)
return token.to_jwt()
def generate_livekit_config(
def generate_livekit_config( # noqa: PLR0917
room_id: str,
user,
username: str,
is_admin_or_owner: bool,
role: Optional[str] = None,
color: Optional[str] = None,
configuration: Optional[dict] = None,
participant_id: Optional[str] = None,
@@ -145,7 +159,7 @@ def generate_livekit_config(
room_id: Room identifier
user: User instance requesting access
username: Display name in room
is_admin_or_owner (bool): Whether the user has admin/owner privileges for this room.
role (str): Room's access role if any
color (Optional[str]): Optional color to associate with the participant.
configuration (Optional[dict]): Room configuration dict that can override default settings.
participant_id (Optional[str]): Stable identifier for anonymous users;
@@ -168,7 +182,7 @@ def generate_livekit_config(
username=username,
color=color,
sources=sources,
is_admin_or_owner=is_admin_or_owner,
role=role,
participant_id=participant_id,
),
}
@@ -253,57 +267,6 @@ async def notify_participants(room_name: str, notification_data: dict):
await lkapi.aclose()
class MetadataUpdateException(Exception):
"""Room's metadata update fails."""
@async_to_sync
async def update_room_metadata(
room_name: str, metadata: dict, remove_keys: Optional[list[str]] = None
):
"""Update LiveKit room metadata by merging new values with existing metadata.
Args:
room_name: Name of the room to update
metadata: Dictionary of metadata key-values to add/update
remove_keys: Optional list of keys to remove from existing metadata.
"""
lkapi = create_livekit_client()
try:
response = await lkapi.room.list_rooms(
ListRoomsRequest(
names=[room_name],
)
)
if not response.rooms:
return
room = response.rooms[0]
existing_metadata = json.loads(room.metadata) if room.metadata else {}
if remove_keys:
for key in remove_keys:
existing_metadata.pop(key, None)
updated_metadata = {**existing_metadata, **metadata}
await lkapi.room.update_room_metadata(
UpdateRoomMetadataRequest(
room=room_name, metadata=json.dumps(updated_metadata).encode("utf-8")
)
)
except TwirpError as e:
raise MetadataUpdateException(
f"Failed to update metadata for room {room_name}: {e}"
) from e
finally:
await lkapi.aclose()
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
@@ -459,12 +422,14 @@ def generate_upload_policy(file):
return policy
def generate_download_file_url(file, *, expires_in: int, override_domain: bool = True):
def generate_download_s3_url(
key: str, *, expires_in: int, override_domain: bool = True
):
"""
Generate a S3 signed download url for a given file.
Generate a S3 signed download url for a given key.
"""
key = file.file_key
if not key:
raise ValueError("key cannot be empty")
# This setting 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
+159 -101
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,70 +17,92 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:29
#: core/admin.py:67
msgid "Personal info"
msgstr "Persönliche Informationen"
#: core/admin.py:42
#: core/admin.py:80
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:54
#: core/admin.py:92
msgid "Important dates"
msgstr "Wichtige Daten"
#: core/admin.py:132 core/admin.py:275
#: core/admin.py:206
msgid "Content"
msgstr ""
#: core/admin.py:217
#, fuzzy
#| msgid "Delete rooms"
msgid "Deletion"
msgstr "Räume löschen"
#: core/admin.py:226
msgid "Derived info"
msgstr ""
#: core/admin.py:237
msgid "Timestamps"
msgstr ""
#: core/admin.py:240
msgid "File preview"
msgstr ""
#: core/admin.py:300 core/admin.py:443
msgid "No owner"
msgstr "Kein Eigentümer"
#: core/admin.py:135 core/admin.py:278
#: core/admin.py:303 core/admin.py:446
msgid "Multiple owners"
msgstr "Mehrere Eigentümer"
#: core/admin.py:148
#: core/admin.py:316
msgid "Resend notification to external service"
msgstr "Benachrichtigung erneut an externen Dienst senden"
#: core/admin.py:171
#: core/admin.py:339
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen"
#: core/admin.py:179
#: core/admin.py:347
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s"
#: core/admin.py:187
#: core/admin.py:355
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
#: core/admin.py:195
#: core/admin.py:363
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
#: core/admin.py:200
#: core/admin.py:368
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Ausgewählte Aufnahmen als Fehler beim Stoppen markieren"
#: core/admin.py:218
#: core/admin.py:386
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s Aufnahme(n) erfolgreich als Fehler beim Stoppen markiert."
#: core/admin.py:226
#: core/admin.py:394
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
#: core/admin.py:342
#: core/admin.py:510
msgid "No scopes"
msgstr "Keine Scopes"
#: core/admin.py:344
#: core/admin.py:512
msgid "Scopes"
msgstr "Scopes"
@@ -88,17 +110,17 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Ersteller bin ich"
#: core/api/serializers.py:88
#: core/api/serializers.py:89
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
"hinzuzufügen."
#: core/api/serializers.py:509
#: core/api/serializers.py:534
msgid "This file extension is not allowed."
msgstr "Diese Dateiendung ist nicht erlaubt."
#: core/api/viewsets.py:1090
#: core/api/viewsets.py:1222
msgid "You have reached the maximum number of files for this type."
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
@@ -146,51 +168,59 @@ msgstr "Stopp fehlgeschlagen"
msgid "Notification succeeded"
msgstr "Benachrichtigung erfolgreich"
#: core/models.py:89
#: core/models.py:65
msgid "External process successful"
msgstr "Externer Prozess erfolgreich"
#: core/models.py:67
msgid "External process failed"
msgstr "Externer Prozess fehlgeschlagen"
#: core/models.py:96
msgid "SCREEN_RECORDING"
msgstr "BILDSCHIRMAUFZEICHNUNG"
#: core/models.py:90
#: core/models.py:97
msgid "TRANSCRIPT"
msgstr "TRANSKRIPT"
#: core/models.py:96
#: core/models.py:103
msgid "Public Access"
msgstr "Öffentlicher Zugriff"
#: core/models.py:97
#: core/models.py:104
msgid "Trusted Access"
msgstr "Vertrauenswürdiger Zugriff"
#: core/models.py:98
#: core/models.py:105
msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff"
#: core/models.py:110
#: core/models.py:117
msgid "id"
msgstr "ID"
#: core/models.py:111
#: core/models.py:118
msgid "primary key for the record as UUID"
msgstr "Primärschlüssel des Eintrags als UUID"
#: core/models.py:117
#: core/models.py:124
msgid "created on"
msgstr "erstellt am"
#: core/models.py:118
#: core/models.py:125
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
#: core/models.py:123
#: core/models.py:130
msgid "updated on"
msgstr "aktualisiert am"
#: core/models.py:124
#: core/models.py:131
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
#: core/models.py:144
#: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -198,11 +228,11 @@ msgstr ""
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
"und die Zeichen @/./+/-/_ enthalten."
#: core/models.py:150
#: core/models.py:157
msgid "sub"
msgstr "Sub"
#: core/models.py:152
#: core/models.py:159
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -210,55 +240,55 @@ msgstr ""
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
#: core/models.py:161
#: core/models.py:168
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:166
#: core/models.py:173
msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:168
#: core/models.py:175
msgid "full name"
msgstr "Vollständiger Name"
#: core/models.py:170
#: core/models.py:177
msgid "short name"
msgstr "Kurzname"
#: core/models.py:176
#: core/models.py:183
msgid "language"
msgstr "Sprache"
#: core/models.py:177
#: core/models.py:184
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:183
#: core/models.py:190
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:186
#: core/models.py:193
msgid "device"
msgstr "Gerät"
#: core/models.py:188
#: core/models.py:195
msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:191
#: core/models.py:198
msgid "staff status"
msgstr "Mitarbeiterstatus"
#: core/models.py:193
#: core/models.py:200
msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:196
#: core/models.py:203
msgid "active"
msgstr "aktiv"
#: core/models.py:199
#: core/models.py:206
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -266,66 +296,66 @@ msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos."
#: core/models.py:212
#: core/models.py:219
msgid "user"
msgstr "Benutzer"
#: core/models.py:213
#: core/models.py:220
msgid "users"
msgstr "Benutzer"
#: core/models.py:272
#: core/models.py:286
msgid "Resource"
msgstr "Ressource"
#: core/models.py:273
#: core/models.py:287
msgid "Resources"
msgstr "Ressourcen"
#: core/models.py:331
#: core/models.py:345
msgid "Resource access"
msgstr "Ressourcenzugriff"
#: core/models.py:332
#: core/models.py:346
msgid "Resource accesses"
msgstr "Ressourcenzugriffe"
#: core/models.py:338
#: core/models.py:352
msgid "Resource access with this User and Resource already exists."
msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits."
#: core/models.py:394
#: core/models.py:409
msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:395
#: core/models.py:410
msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:402
#: core/models.py:417
msgid "Room PIN code"
msgstr "PIN-Code für den Raum"
#: core/models.py:403
#: core/models.py:418
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:409 core/models.py:563
#: core/models.py:424 core/models.py:578
msgid "Room"
msgstr "Raum"
#: core/models.py:410
#: core/models.py:425
msgid "Rooms"
msgstr "Räume"
#: core/models.py:574
#: core/models.py:589
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:576
#: core/models.py:591
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -334,141 +364,153 @@ msgstr ""
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht."
#: core/models.py:584
#: core/models.py:599
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:585
#: core/models.py:600
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:590 core/models.py:591
#: core/models.py:605 core/models.py:606
msgid "Recording options"
msgstr "Aufnahmeoptionen"
#: core/models.py:597
#: core/models.py:613
msgid "External Process ID"
msgstr "External Process ID"
#: core/models.py:614
msgid "ID of the external process associated with the recording."
msgstr "ID des externen Prozesses, der mit der Aufzeichnung verknüpft ist"
#: core/models.py:620
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:598
#: core/models.py:621
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:706
#: core/models.py:731
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:707
#: core/models.py:732
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:713
#: core/models.py:738
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:719
#: core/models.py:744
msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:725
#: core/models.py:750
msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/models.py:742
#: core/models.py:767
msgid "Create rooms"
msgstr "Räume erstellen"
#: core/models.py:743
#: core/models.py:768
msgid "List rooms"
msgstr "Räume auflisten"
#: core/models.py:744
#: core/models.py:769
msgid "Retrieve room details"
msgstr "Raumdetails abrufen"
#: core/models.py:745
#: core/models.py:770
msgid "Update rooms"
msgstr "Räume aktualisieren"
#: core/models.py:746
#: core/models.py:771
msgid "Delete rooms"
msgstr "Räume löschen"
#: core/models.py:759
#: core/models.py:784
msgid "Application name"
msgstr "Anwendungsname"
#: core/models.py:760
#: core/models.py:785
msgid "Descriptive name for this application."
msgstr "Beschreibender Name für diese Anwendung."
#: core/models.py:770
#: core/models.py:795
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
#: core/models.py:781
#: core/models.py:806
msgid "Application"
msgstr "Anwendung"
#: core/models.py:782
#: core/models.py:807
msgid "Applications"
msgstr "Anwendungen"
#: core/models.py:805
#: core/models.py:830
msgid "Enter a valid domain"
msgstr "Geben Sie eine gültige Domain ein"
#: core/models.py:808
#: core/models.py:833
msgid "Domain"
msgstr "Domain"
#: core/models.py:809
#: core/models.py:834
msgid "Email domain this application can act on behalf of."
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
#: core/models.py:821
#: core/models.py:846
msgid "Application domain"
msgstr "Anwendungsdomain"
#: core/models.py:822
#: core/models.py:847
msgid "Application domains"
msgstr "Anwendungsdomains"
#: core/models.py:840
#: core/models.py:865
msgid "Pending"
msgstr "Ausstehend"
#: core/models.py:848
#: core/models.py:866
msgid "Analyzing"
msgstr ""
#: core/models.py:873
msgid "Ready"
msgstr "Bereit"
#: core/models.py:854
#: core/models.py:879
msgid "Background image"
msgstr "Hintergrundbild"
#: core/models.py:866
#: core/models.py:891
msgid "title"
msgstr "Titel"
#: core/models.py:890
#: core/models.py:915
msgid "Malware detection info when the analysis status is unsafe."
msgstr ""
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
#: core/models.py:895
#: core/models.py:920
msgid "File"
msgstr "Datei"
#: core/models.py:896
#: core/models.py:921
msgid "Files"
msgstr "Dateien"
#: core/models.py:1000
#: core/models.py:1041
msgid "This file is already hard deleted."
msgstr "Diese Datei wurde bereits endgültig gelöscht."
#: core/models.py:1010
#: core/models.py:1051
#, fuzzy
#| msgid "To hard delete a file, it must first be soft deleted."
msgid "To hard delete a file, it must first be soft deleted."
@@ -476,10 +518,20 @@ msgstr ""
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
"sein."
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:123
msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit"
#: core/recording/event/notification.py:194
msgid "Transcription"
msgstr "Transkription"
#: core/recording/event/notification.py:204
#, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr ""
"Besprechung \"{room}\" am {room_recording_date} um {room_recording_time}"
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
@@ -575,7 +627,7 @@ msgid "To keep this recording permanently:"
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
#, python-format
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
@@ -604,18 +656,24 @@ msgstr ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. "
#: meet/settings.py:223
#: core/templates/mail/text/screen_recording.txt:13
#, fuzzy, python-format
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open [%(link)s]\" link below "
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
#: meet/settings.py:228
msgid "English"
msgstr "Englisch"
#: meet/settings.py:224
#: meet/settings.py:229
msgid "French"
msgstr "Französisch"
#: meet/settings.py:225
#: meet/settings.py:230
msgid "Dutch"
msgstr "Niederländisch"
#: meet/settings.py:226
#: meet/settings.py:231
msgid "German"
msgstr "Deutsch"
+158 -101
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,70 +17,92 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:29
#: core/admin.py:67
msgid "Personal info"
msgstr "Personal info"
#: core/admin.py:42
#: core/admin.py:80
msgid "Permissions"
msgstr "Permissions"
#: core/admin.py:54
#: core/admin.py:92
msgid "Important dates"
msgstr "Important dates"
#: core/admin.py:132 core/admin.py:275
#: core/admin.py:206
msgid "Content"
msgstr ""
#: core/admin.py:217
#, fuzzy
#| msgid "Delete rooms"
msgid "Deletion"
msgstr "Delete rooms"
#: core/admin.py:226
msgid "Derived info"
msgstr ""
#: core/admin.py:237
msgid "Timestamps"
msgstr ""
#: core/admin.py:240
msgid "File preview"
msgstr ""
#: core/admin.py:300 core/admin.py:443
msgid "No owner"
msgstr "No owner"
#: core/admin.py:135 core/admin.py:278
#: core/admin.py:303 core/admin.py:446
msgid "Multiple owners"
msgstr "Multiple owners"
#: core/admin.py:148
#: core/admin.py:316
msgid "Resend notification to external service"
msgstr "Resend notification to external service"
#: core/admin.py:171
#: core/admin.py:339
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Failed to notify for recording %(id)s"
#: core/admin.py:179
#: core/admin.py:347
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Failed to notify for recording %(id)s: %(error)s"
#: core/admin.py:187
#: core/admin.py:355
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Successfully sent notifications for %(count)s recording(s)."
#: core/admin.py:195
#: core/admin.py:363
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "Skipped %(count)s expired recording(s)."
#: core/admin.py:200
#: core/admin.py:368
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Mark selected recordings as 'Failed to Stop'"
#: core/admin.py:218
#: core/admin.py:386
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s recording(s) successfully marked as 'Failed to Stop'."
#: core/admin.py:226
#: core/admin.py:394
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "Skipped %(count)s expired recording(s)."
#: core/admin.py:342
#: core/admin.py:510
msgid "No scopes"
msgstr "No scopes"
#: core/admin.py:344
#: core/admin.py:512
msgid "Scopes"
msgstr "Scopes"
@@ -88,15 +110,15 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Creator is me"
#: core/api/serializers.py:88
#: core/api/serializers.py:89
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "You must be administrator or owner of a room to add accesses to it."
#: core/api/serializers.py:509
#: core/api/serializers.py:534
msgid "This file extension is not allowed."
msgstr "This file extension is not allowed."
#: core/api/viewsets.py:1090
#: core/api/viewsets.py:1222
msgid "You have reached the maximum number of files for this type."
msgstr "You have reached the maximum number of files for this type."
@@ -144,51 +166,59 @@ msgstr "Failed to Stop"
msgid "Notification succeeded"
msgstr "Notification succeeded"
#: core/models.py:89
#: core/models.py:65
msgid "External process successful"
msgstr "External process successful"
#: core/models.py:67
msgid "External process failed"
msgstr "External process failed"
#: core/models.py:96
msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING"
#: core/models.py:90
#: core/models.py:97
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:96
#: core/models.py:103
msgid "Public Access"
msgstr "Public Access"
#: core/models.py:97
#: core/models.py:104
msgid "Trusted Access"
msgstr "Trusted Access"
#: core/models.py:98
#: core/models.py:105
msgid "Restricted Access"
msgstr "Restricted Access"
#: core/models.py:110
#: core/models.py:117
msgid "id"
msgstr "id"
#: core/models.py:111
#: core/models.py:118
msgid "primary key for the record as UUID"
msgstr "primary key for the record as UUID"
#: core/models.py:117
#: core/models.py:124
msgid "created on"
msgstr "created on"
#: core/models.py:118
#: core/models.py:125
msgid "date and time at which a record was created"
msgstr "date and time at which a record was created"
#: core/models.py:123
#: core/models.py:130
msgid "updated on"
msgstr "updated on"
#: core/models.py:124
#: core/models.py:131
msgid "date and time at which a record was last updated"
msgstr "date and time at which a record was last updated"
#: core/models.py:144
#: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -196,11 +226,11 @@ msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: core/models.py:150
#: core/models.py:157
msgid "sub"
msgstr "sub"
#: core/models.py:152
#: core/models.py:159
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -208,55 +238,55 @@ msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:161
#: core/models.py:168
msgid "identity email address"
msgstr "identity email address"
#: core/models.py:166
#: core/models.py:173
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:168
#: core/models.py:175
msgid "full name"
msgstr "full name"
#: core/models.py:170
#: core/models.py:177
msgid "short name"
msgstr "short name"
#: core/models.py:176
#: core/models.py:183
msgid "language"
msgstr "language"
#: core/models.py:177
#: core/models.py:184
msgid "The language in which the user wants to see the interface."
msgstr "The language in which the user wants to see the interface."
#: core/models.py:183
#: core/models.py:190
msgid "The timezone in which the user wants to see times."
msgstr "The timezone in which the user wants to see times."
#: core/models.py:186
#: core/models.py:193
msgid "device"
msgstr "device"
#: core/models.py:188
#: core/models.py:195
msgid "Whether the user is a device or a real user."
msgstr "Whether the user is a device or a real user."
#: core/models.py:191
#: core/models.py:198
msgid "staff status"
msgstr "staff status"
#: core/models.py:193
#: core/models.py:200
msgid "Whether the user can log into this admin site."
msgstr "Whether the user can log into this admin site."
#: core/models.py:196
#: core/models.py:203
msgid "active"
msgstr "active"
#: core/models.py:199
#: core/models.py:206
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -264,63 +294,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:212
#: core/models.py:219
msgid "user"
msgstr "user"
#: core/models.py:213
#: core/models.py:220
msgid "users"
msgstr "users"
#: core/models.py:272
#: core/models.py:286
msgid "Resource"
msgstr "Resource"
#: core/models.py:273
#: core/models.py:287
msgid "Resources"
msgstr "Resources"
#: core/models.py:331
#: core/models.py:345
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:332
#: core/models.py:346
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:338
#: core/models.py:352
msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:394
#: core/models.py:409
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:395
#: core/models.py:410
msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:402
#: core/models.py:417
msgid "Room PIN code"
msgstr "Room PIN code"
#: core/models.py:403
#: core/models.py:418
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unique n-digit code that identifies this room in telephony mode."
#: core/models.py:409 core/models.py:563
#: core/models.py:424 core/models.py:578
msgid "Room"
msgstr "Room"
#: core/models.py:410
#: core/models.py:425
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:574
#: core/models.py:589
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:576
#: core/models.py:591
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -328,154 +358,175 @@ msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:584
#: core/models.py:599
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:585
#: core/models.py:600
msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called."
#: core/models.py:590 core/models.py:591
#: core/models.py:605 core/models.py:606
msgid "Recording options"
msgstr "Recording options"
#: core/models.py:597
#: core/models.py:613
msgid "External Process ID"
msgstr "External Process ID"
#: core/models.py:614
msgid "ID of the external process associated with the recording."
msgstr "ID of the external process associated with the recording."
#: core/models.py:620
msgid "Recording"
msgstr "Recording"
#: core/models.py:598
#: core/models.py:621
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:706
#: core/models.py:731
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:707
#: core/models.py:732
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:713
#: core/models.py:738
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:719
#: core/models.py:744
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:725
#: core/models.py:750
msgid "Either user or team must be set, not both."
msgstr "Either user or team must be set, not both."
#: core/models.py:742
#: core/models.py:767
#, fuzzy
#| msgid "created on"
msgid "Create rooms"
msgstr "Create rooms"
#: core/models.py:743
#: core/models.py:768
msgid "List rooms"
msgstr "List rooms"
#: core/models.py:744
#: core/models.py:769
msgid "Retrieve room details"
msgstr "Retrieve room details"
#: core/models.py:745
#: core/models.py:770
#, fuzzy
#| msgid "updated on"
msgid "Update rooms"
msgstr "Update rooms"
#: core/models.py:746
#: core/models.py:771
msgid "Delete rooms"
msgstr "Delete rooms"
#: core/models.py:759
#: core/models.py:784
msgid "Application name"
msgstr "Application name"
#: core/models.py:760
#: core/models.py:785
msgid "Descriptive name for this application."
msgstr "Descriptive name for this application."
#: core/models.py:770
#: core/models.py:795
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Hashed on Save. Copy it now if this is a new secret."
#: core/models.py:781
#: core/models.py:806
msgid "Application"
msgstr "Application"
#: core/models.py:782
#: core/models.py:807
msgid "Applications"
msgstr "Applications"
#: core/models.py:805
#: core/models.py:830
msgid "Enter a valid domain"
msgstr "Enter a valid domain"
#: core/models.py:808
#: core/models.py:833
msgid "Domain"
msgstr "Domain"
#: core/models.py:809
#: core/models.py:834
msgid "Email domain this application can act on behalf of."
msgstr "Email domain this application can act on behalf of."
#: core/models.py:821
#: core/models.py:846
msgid "Application domain"
msgstr "Application domain"
#: core/models.py:822
#: core/models.py:847
msgid "Application domains"
msgstr "Application domains"
#: core/models.py:840
#: core/models.py:865
#, fuzzy
#| msgid "Recording"
msgid "Pending"
msgstr "Pending"
#: core/models.py:848
#: core/models.py:866
msgid "Analyzing"
msgstr ""
#: core/models.py:873
msgid "Ready"
msgstr "Ready"
#: core/models.py:854
#: core/models.py:879
msgid "Background image"
msgstr "Background image"
#: core/models.py:866
#: core/models.py:891
msgid "title"
msgstr "title"
#: core/models.py:890
#: core/models.py:915
msgid "Malware detection info when the analysis status is unsafe."
msgstr "Malware detection info when the analysis status is unsafe."
#: core/models.py:895
#: core/models.py:920
msgid "File"
msgstr "File"
#: core/models.py:896
#: core/models.py:921
msgid "Files"
msgstr "Files"
#: core/models.py:1000
#: core/models.py:1041
#, fuzzy
#| msgid "This user is already in this recording."
msgid "This file is already hard deleted."
msgstr "This file is already hard deleted."
#: core/models.py:1010
#: core/models.py:1051
msgid "To hard delete a file, it must first be soft deleted."
msgstr "To hard delete a file, it must first be soft deleted."
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:123
msgid "Your recording is ready"
msgstr "Your recording is ready"
#: core/recording/event/notification.py:194
msgid "Transcription"
msgstr "Transcription"
#: core/recording/event/notification.py:204
#, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
@@ -571,7 +622,7 @@ msgid "To keep this recording permanently:"
msgstr "To keep this recording permanently:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
#, python-format
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
@@ -600,18 +651,24 @@ msgstr ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
#: meet/settings.py:223
#: core/templates/mail/text/screen_recording.txt:13
#, fuzzy, python-format
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open [%(link)s]\" link below "
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
#: meet/settings.py:228
msgid "English"
msgstr "English"
#: meet/settings.py:224
#: meet/settings.py:229
msgid "French"
msgstr "French"
#: meet/settings.py:225
#: meet/settings.py:230
msgid "Dutch"
msgstr "Dutch"
#: meet/settings.py:226
#: meet/settings.py:231
msgid "German"
msgstr "German"
+158 -101
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,71 +17,93 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:29
#: core/admin.py:67
msgid "Personal info"
msgstr "Informations personnelles"
#: core/admin.py:42
#: core/admin.py:80
msgid "Permissions"
msgstr "Permissions"
#: core/admin.py:54
#: core/admin.py:92
msgid "Important dates"
msgstr "Dates importantes"
#: core/admin.py:132 core/admin.py:275
#: core/admin.py:206
msgid "Content"
msgstr ""
#: core/admin.py:217
#, fuzzy
#| msgid "Delete rooms"
msgid "Deletion"
msgstr "Supprimer les salles"
#: core/admin.py:226
msgid "Derived info"
msgstr ""
#: core/admin.py:237
msgid "Timestamps"
msgstr ""
#: core/admin.py:240
msgid "File preview"
msgstr ""
#: core/admin.py:300 core/admin.py:443
msgid "No owner"
msgstr "Pas de propriétaire"
#: core/admin.py:135 core/admin.py:278
#: core/admin.py:303 core/admin.py:446
msgid "Multiple owners"
msgstr "Plusieurs propriétaires"
#: core/admin.py:148
#: core/admin.py:316
msgid "Resend notification to external service"
msgstr "Renvoyer la notification au service externe"
#: core/admin.py:171
#: core/admin.py:339
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Échec de la notification pour lenregistrement %(id)s"
#: core/admin.py:179
#: core/admin.py:347
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Échec de la notification pour lenregistrement %(id)s : %(error)s"
#: core/admin.py:187
#: core/admin.py:355
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
#: core/admin.py:195
#: core/admin.py:363
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)."
#: core/admin.py:200
#: core/admin.py:368
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Marquer les enregistrements sélectionnés comme « Échec darrêt »"
#: core/admin.py:218
#: core/admin.py:386
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr ""
"%(count)s enregistrement(s) marqué(s) avec succès comme « Échec darrêt »."
#: core/admin.py:226
#: core/admin.py:394
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)."
#: core/admin.py:342
#: core/admin.py:510
msgid "No scopes"
msgstr "Aucun scopes"
#: core/admin.py:344
#: core/admin.py:512
msgid "Scopes"
msgstr "Scopes"
@@ -89,17 +111,17 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Je suis le créateur"
#: core/api/serializers.py:88
#: core/api/serializers.py:89
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
"des accès."
#: core/api/serializers.py:509
#: core/api/serializers.py:534
msgid "This file extension is not allowed."
msgstr "Cette extension n'est pas autorisée"
#: core/api/viewsets.py:1090
#: core/api/viewsets.py:1222
msgid "You have reached the maximum number of files for this type."
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
@@ -147,53 +169,61 @@ msgstr "Échec à l'arrêt"
msgid "Notification succeeded"
msgstr "Notification réussie"
#: core/models.py:89
#: core/models.py:65
msgid "External process successful"
msgstr "Traitement externe : succès"
#: core/models.py:67
msgid "External process failed"
msgstr "Traitement externe : erreur"
#: core/models.py:96
msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:90
#: core/models.py:97
msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION"
#: core/models.py:96
#: core/models.py:103
msgid "Public Access"
msgstr "Accès public"
#: core/models.py:97
#: core/models.py:104
msgid "Trusted Access"
msgstr "Accès de confiance"
#: core/models.py:98
#: core/models.py:105
msgid "Restricted Access"
msgstr "Accès restreint"
#: core/models.py:110
#: core/models.py:117
msgid "id"
msgstr "id"
#: core/models.py:111
#: core/models.py:118
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:117
#: core/models.py:124
msgid "created on"
msgstr "créé le"
#: core/models.py:118
#: core/models.py:125
msgid "date and time at which a record was created"
msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:123
#: core/models.py:130
msgid "updated on"
msgstr "mis à jour le"
#: core/models.py:124
#: core/models.py:131
msgid "date and time at which a record was last updated"
msgstr ""
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
"fois"
#: core/models.py:144
#: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -201,11 +231,11 @@ msgstr ""
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
"chiffres et les caractères @/./+/-/_."
#: core/models.py:150
#: core/models.py:157
msgid "sub"
msgstr "sub"
#: core/models.py:152
#: core/models.py:159
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -213,55 +243,55 @@ msgstr ""
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
#: core/models.py:161
#: core/models.py:168
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: core/models.py:166
#: core/models.py:173
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:168
#: core/models.py:175
msgid "full name"
msgstr "nom complet"
#: core/models.py:170
#: core/models.py:177
msgid "short name"
msgstr "nom court"
#: core/models.py:176
#: core/models.py:183
msgid "language"
msgstr "langue"
#: core/models.py:177
#: core/models.py:184
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:183
#: core/models.py:190
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:186
#: core/models.py:193
msgid "device"
msgstr "appareil"
#: core/models.py:188
#: core/models.py:195
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:191
#: core/models.py:198
msgid "staff status"
msgstr "statut du personnel"
#: core/models.py:193
#: core/models.py:200
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:196
#: core/models.py:203
msgid "active"
msgstr "actif"
#: core/models.py:199
#: core/models.py:206
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -269,65 +299,65 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:212
#: core/models.py:219
msgid "user"
msgstr "utilisateur"
#: core/models.py:213
#: core/models.py:220
msgid "users"
msgstr "utilisateurs"
#: core/models.py:272
#: core/models.py:286
msgid "Resource"
msgstr "Ressource"
#: core/models.py:273
#: core/models.py:287
msgid "Resources"
msgstr "Ressources"
#: core/models.py:331
#: core/models.py:345
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:332
#: core/models.py:346
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:338
#: core/models.py:352
msgid "Resource access with this User and Resource already exists."
msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:394
#: core/models.py:409
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:395
#: core/models.py:410
msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:402
#: core/models.py:417
msgid "Room PIN code"
msgstr "Code PIN de la salle"
#: core/models.py:403
#: core/models.py:418
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:409 core/models.py:563
#: core/models.py:424 core/models.py:578
msgid "Room"
msgstr "Salle"
#: core/models.py:410
#: core/models.py:425
msgid "Rooms"
msgstr "Salles"
#: core/models.py:574
#: core/models.py:589
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:576
#: core/models.py:591
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -335,153 +365,174 @@ msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
#: core/models.py:584
#: core/models.py:599
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:585
#: core/models.py:600
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:590 core/models.py:591
#: core/models.py:605 core/models.py:606
msgid "Recording options"
msgstr "Options d'enregistrement"
#: core/models.py:597
#: core/models.py:613
msgid "External Process ID"
msgstr "ID Traitement externe"
#: core/models.py:614
msgid "ID of the external process associated with the recording."
msgstr "ID du traitement externe associé avec l'enregistrement."
#: core/models.py:620
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:598
#: core/models.py:621
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:706
#: core/models.py:731
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:707
#: core/models.py:732
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:713
#: core/models.py:738
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:719
#: core/models.py:744
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:725
#: core/models.py:750
msgid "Either user or team must be set, not both."
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/models.py:742
#: core/models.py:767
msgid "Create rooms"
msgstr "Créer des salles"
#: core/models.py:743
#: core/models.py:768
msgid "List rooms"
msgstr "Lister les salles"
#: core/models.py:744
#: core/models.py:769
msgid "Retrieve room details"
msgstr "Afficher les détails dune salle"
#: core/models.py:745
#: core/models.py:770
msgid "Update rooms"
msgstr "Mettre à jour les salles"
#: core/models.py:746
#: core/models.py:771
msgid "Delete rooms"
msgstr "Supprimer les salles"
#: core/models.py:759
#: core/models.py:784
msgid "Application name"
msgstr "Nom de lapplication"
#: core/models.py:760
#: core/models.py:785
msgid "Descriptive name for this application."
msgstr "Nom descriptif de cette application."
#: core/models.py:770
#: core/models.py:795
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun "
"nouveau secret."
#: core/models.py:781
#: core/models.py:806
msgid "Application"
msgstr "Application"
#: core/models.py:782
#: core/models.py:807
msgid "Applications"
msgstr "Applications"
#: core/models.py:805
#: core/models.py:830
msgid "Enter a valid domain"
msgstr "Saisissez un domaine valide"
#: core/models.py:808
#: core/models.py:833
msgid "Domain"
msgstr "Domaine"
#: core/models.py:809
#: core/models.py:834
msgid "Email domain this application can act on behalf of."
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
#: core/models.py:821
#: core/models.py:846
msgid "Application domain"
msgstr "Domaine dapplication"
#: core/models.py:822
#: core/models.py:847
msgid "Application domains"
msgstr "Domaines dapplication"
#: core/models.py:840
#: core/models.py:865
msgid "Pending"
msgstr "En attente"
#: core/models.py:848
#: core/models.py:866
msgid "Analyzing"
msgstr ""
#: core/models.py:873
msgid "Ready"
msgstr "Prêt"
#: core/models.py:854
#: core/models.py:879
msgid "Background image"
msgstr "Image de fond"
#: core/models.py:866
#: core/models.py:891
msgid "title"
msgstr "Titre"
#: core/models.py:890
#: core/models.py:915
msgid "Malware detection info when the analysis status is unsafe."
msgstr ""
"Information concernant la détection de Malware cand le statut n'est pas sain"
#: core/models.py:895
#: core/models.py:920
msgid "File"
msgstr "Fichier"
#: core/models.py:896
#: core/models.py:921
msgid "Files"
msgstr "Fichiers"
#: core/models.py:1000
#: core/models.py:1041
#, fuzzy
#| msgid "This user is already in this recording."
msgid "This file is already hard deleted."
msgstr "Ce fichier a été supprimé."
#: core/models.py:1010
#: core/models.py:1051
msgid "To hard delete a file, it must first be soft deleted."
msgstr ""
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
"comme supprimé (soft delete)"
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:123
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
#: core/recording/event/notification.py:194
msgid "Transcription"
msgstr "Transcription"
#: core/recording/event/notification.py:204
#, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr "Réunion \"{room}\" du {room_recording_date} à {room_recording_time}"
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
@@ -577,7 +628,7 @@ msgid "To keep this recording permanently:"
msgstr "Pour conserver cet enregistrement de façon permanente :"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
#, python-format
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
@@ -606,18 +657,24 @@ msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. "
#: meet/settings.py:223
#: core/templates/mail/text/screen_recording.txt:13
#, fuzzy, python-format
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open [%(link)s]\" link below "
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
#: meet/settings.py:228
msgid "English"
msgstr "Anglais"
#: meet/settings.py:224
#: meet/settings.py:229
msgid "French"
msgstr "Français"
#: meet/settings.py:225
#: meet/settings.py:230
msgid "Dutch"
msgstr "Néerlandais"
#: meet/settings.py:226
#: meet/settings.py:231
msgid "German"
msgstr "Allemand"
+159 -101
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,70 +17,92 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:29
#: core/admin.py:67
msgid "Personal info"
msgstr "Persoonlijke informatie"
#: core/admin.py:42
#: core/admin.py:80
msgid "Permissions"
msgstr "Rechten"
#: core/admin.py:54
#: core/admin.py:92
msgid "Important dates"
msgstr "Belangrijke datums"
#: core/admin.py:132 core/admin.py:275
#: core/admin.py:206
msgid "Content"
msgstr ""
#: core/admin.py:217
#, fuzzy
#| msgid "Delete rooms"
msgid "Deletion"
msgstr "Ruimtes verwijderen"
#: core/admin.py:226
msgid "Derived info"
msgstr ""
#: core/admin.py:237
msgid "Timestamps"
msgstr ""
#: core/admin.py:240
msgid "File preview"
msgstr ""
#: core/admin.py:300 core/admin.py:443
msgid "No owner"
msgstr "Geen eigenaar"
#: core/admin.py:135 core/admin.py:278
#: core/admin.py:303 core/admin.py:446
msgid "Multiple owners"
msgstr "Meerdere eigenaren"
#: core/admin.py:148
#: core/admin.py:316
msgid "Resend notification to external service"
msgstr "Melding opnieuw verzenden naar externe dienst"
#: core/admin.py:171
#: core/admin.py:339
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Melding voor opname %(id)s mislukt"
#: core/admin.py:179
#: core/admin.py:347
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Melding voor opname %(id)s mislukt: %(error)s"
#: core/admin.py:187
#: core/admin.py:355
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
#: core/admin.py:195
#: core/admin.py:363
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s verlopen opname(n) overgeslagen."
#: core/admin.py:200
#: core/admin.py:368
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Geselecteerde opnames markeren als 'Mislukt bij stoppen'"
#: core/admin.py:218
#: core/admin.py:386
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'."
#: core/admin.py:226
#: core/admin.py:394
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen."
#: core/admin.py:342
#: core/admin.py:510
msgid "No scopes"
msgstr "Geen scopes"
#: core/admin.py:344
#: core/admin.py:512
msgid "Scopes"
msgstr "Scopes"
@@ -88,16 +110,16 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Maker ben ik"
#: core/api/serializers.py:88
#: core/api/serializers.py:89
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
#: core/api/serializers.py:509
#: core/api/serializers.py:534
msgid "This file extension is not allowed."
msgstr "Deze bestandsextensie is niet toegestaan."
#: core/api/viewsets.py:1090
#: core/api/viewsets.py:1222
msgid "You have reached the maximum number of files for this type."
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
@@ -145,51 +167,59 @@ msgstr "Stoppen mislukt"
msgid "Notification succeeded"
msgstr "Notificatie geslaagd"
#: core/models.py:89
#: core/models.py:65
msgid "External process successful"
msgstr "Externe procedure succesvol"
#: core/models.py:67
msgid "External process failed"
msgstr "Het externe proces is mislukt."
#: core/models.py:96
msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME"
#: core/models.py:90
#: core/models.py:97
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:96
#: core/models.py:103
msgid "Public Access"
msgstr "Openbare toegang"
#: core/models.py:97
#: core/models.py:104
msgid "Trusted Access"
msgstr "Vertrouwde toegang"
#: core/models.py:98
#: core/models.py:105
msgid "Restricted Access"
msgstr "Beperkte toegang"
#: core/models.py:110
#: core/models.py:117
msgid "id"
msgstr "id"
#: core/models.py:111
#: core/models.py:118
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:117
#: core/models.py:124
msgid "created on"
msgstr "aangemaakt op"
#: core/models.py:118
#: core/models.py:125
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record werd aangemaakt"
#: core/models.py:123
#: core/models.py:130
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:124
#: core/models.py:131
msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
#: core/models.py:144
#: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -197,11 +227,11 @@ msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten."
#: core/models.py:150
#: core/models.py:157
msgid "sub"
msgstr "sub"
#: core/models.py:152
#: core/models.py:159
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -209,55 +239,55 @@ msgstr ""
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
#: core/models.py:161
#: core/models.py:168
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:166
#: core/models.py:173
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:168
#: core/models.py:175
msgid "full name"
msgstr "volledige naam"
#: core/models.py:170
#: core/models.py:177
msgid "short name"
msgstr "korte naam"
#: core/models.py:176
#: core/models.py:183
msgid "language"
msgstr "taal"
#: core/models.py:177
#: core/models.py:184
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:183
#: core/models.py:190
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:186
#: core/models.py:193
msgid "device"
msgstr "apparaat"
#: core/models.py:188
#: core/models.py:195
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:191
#: core/models.py:198
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:193
#: core/models.py:200
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:196
#: core/models.py:203
msgid "active"
msgstr "actief"
#: core/models.py:199
#: core/models.py:206
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -265,64 +295,64 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:212
#: core/models.py:219
msgid "user"
msgstr "gebruiker"
#: core/models.py:213
#: core/models.py:220
msgid "users"
msgstr "gebruikers"
#: core/models.py:272
#: core/models.py:286
msgid "Resource"
msgstr "Bron"
#: core/models.py:273
#: core/models.py:287
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:331
#: core/models.py:345
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:332
#: core/models.py:346
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:338
#: core/models.py:352
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:394
#: core/models.py:409
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:395
#: core/models.py:410
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:402
#: core/models.py:417
msgid "Room PIN code"
msgstr "Pincode van de kamer"
#: core/models.py:403
#: core/models.py:418
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:409 core/models.py:563
#: core/models.py:424 core/models.py:578
msgid "Room"
msgstr "Ruimte"
#: core/models.py:410
#: core/models.py:425
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:574
#: core/models.py:589
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:576
#: core/models.py:591
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -330,140 +360,152 @@ msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:584
#: core/models.py:599
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:585
#: core/models.py:600
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:590 core/models.py:591
#: core/models.py:605 core/models.py:606
msgid "Recording options"
msgstr "Opnameopties"
#: core/models.py:597
#: core/models.py:613
msgid "External Process ID"
msgstr "Externe proces-ID"
#: core/models.py:614
msgid "ID of the external process associated with the recording."
msgstr "ID van het externe proces dat aan de opname is gekoppeld."
#: core/models.py:620
msgid "Recording"
msgstr "Opname"
#: core/models.py:598
#: core/models.py:621
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:706
#: core/models.py:731
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:707
#: core/models.py:732
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:713
#: core/models.py:738
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:719
#: core/models.py:744
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:725
#: core/models.py:750
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:742
#: core/models.py:767
msgid "Create rooms"
msgstr "Ruimtes aanmaken"
#: core/models.py:743
#: core/models.py:768
msgid "List rooms"
msgstr "Ruimtes weergeven"
#: core/models.py:744
#: core/models.py:769
msgid "Retrieve room details"
msgstr "Details van een ruimte ophalen"
#: core/models.py:745
#: core/models.py:770
msgid "Update rooms"
msgstr "Ruimtes bijwerken"
#: core/models.py:746
#: core/models.py:771
msgid "Delete rooms"
msgstr "Ruimtes verwijderen"
#: core/models.py:759
#: core/models.py:784
msgid "Application name"
msgstr "Naam van de applicatie"
#: core/models.py:760
#: core/models.py:785
msgid "Descriptive name for this application."
msgstr "Beschrijvende naam voor deze applicatie."
#: core/models.py:770
#: core/models.py:795
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
#: core/models.py:781
#: core/models.py:806
msgid "Application"
msgstr "Applicatie"
#: core/models.py:782
#: core/models.py:807
msgid "Applications"
msgstr "Applicaties"
#: core/models.py:805
#: core/models.py:830
msgid "Enter a valid domain"
msgstr "Voer een geldig domein in"
#: core/models.py:808
#: core/models.py:833
msgid "Domain"
msgstr "Domein"
#: core/models.py:809
#: core/models.py:834
msgid "Email domain this application can act on behalf of."
msgstr "E-maildomein namens welke deze applicatie kan handelen."
#: core/models.py:821
#: core/models.py:846
msgid "Application domain"
msgstr "Applicatiedomein"
#: core/models.py:822
#: core/models.py:847
msgid "Application domains"
msgstr "Applicatiedomeinen"
#: core/models.py:840
#: core/models.py:865
msgid "Pending"
msgstr "In afwachting"
#: core/models.py:848
#: core/models.py:866
msgid "Analyzing"
msgstr ""
#: core/models.py:873
msgid "Ready"
msgstr "Klaar"
#: core/models.py:854
#: core/models.py:879
msgid "Background image"
msgstr "Achtergrondafbeelding"
#: core/models.py:866
#: core/models.py:891
msgid "title"
msgstr "Titel"
#: core/models.py:890
#: core/models.py:915
msgid "Malware detection info when the analysis status is unsafe."
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
#: core/models.py:895
#: core/models.py:920
msgid "File"
msgstr "Bestand"
#: core/models.py:896
#: core/models.py:921
msgid "Files"
msgstr "Bestanden"
#: core/models.py:1000
#: core/models.py:1041
msgid "This file is already hard deleted."
msgstr "Dit bestand is al definitief verwijderd."
#: core/models.py:1010
#: core/models.py:1051
#, fuzzy
#| msgid "To hard delete a file, it must first be soft deleted."
msgid "To hard delete a file, it must first be soft deleted."
@@ -471,10 +513,20 @@ msgstr ""
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
"zijn."
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:123
msgid "Your recording is ready"
msgstr "Je opname is klaar"
#: core/recording/event/notification.py:194
msgid "Transcription"
msgstr "Transcriptie"
#: core/recording/event/notification.py:204
#, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr ""
"Vergadering \"{room}\" op {room_recording_date} om {room_recording_time}"
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
@@ -570,7 +622,7 @@ msgid "To keep this recording permanently:"
msgstr "Om deze opname permanent te bewaren:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
#, python-format
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
@@ -599,18 +651,24 @@ msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. "
#: meet/settings.py:223
#: core/templates/mail/text/screen_recording.txt:13
#, fuzzy, python-format
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open [%(link)s]\" link below "
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
#: meet/settings.py:228
msgid "English"
msgstr "Engels"
#: meet/settings.py:224
#: meet/settings.py:229
msgid "French"
msgstr "Frans"
#: meet/settings.py:225
#: meet/settings.py:230
msgid "Dutch"
msgstr "Nederlands"
#: meet/settings.py:226
#: meet/settings.py:231
msgid "German"
msgstr "Duits"
+93 -1
View File
@@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
# pylint: disable=too-many-lines
import json
import warnings
from os import path
from socket import gethostbyname, gethostname
@@ -348,6 +349,16 @@ class Base(Configuration):
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
environ_prefix=None,
),
"roomkit_join": values.Value(
default="300/minute",
environ_name="ROOMKIT_JOIN_THROTTLE_RATES",
environ_prefix=None,
),
"connection_test": values.Value(
default="30/minute",
environ_name="CONNECTION_TEST_THROTTLE_RATES",
environ_prefix=None,
),
},
}
MONITORED_THROTTLE_FAILURE_CALLBACK = (
@@ -397,6 +408,9 @@ class Base(Configuration):
"feedback": values.DictValue(
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
),
"documentation_url": values.Value(
None, environ_name="FRONTEND_DOCUMENTATION_URL", environ_prefix=None
),
"external_home_url": values.Value(
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
),
@@ -651,6 +665,30 @@ class Base(Configuration):
environ_prefix=None,
default=False,
)
CONNECTION_TEST_ENABLED = values.BooleanValue(
environ_name="CONNECTION_TEST_ENABLED",
environ_prefix=None,
default=False,
)
CONNECTION_TEST_TOKEN_TTL_SECONDS = values.PositiveIntegerValue(
300,
environ_name="CONNECTION_TEST_TOKEN_TTL_SECONDS",
environ_prefix=None,
)
# The effective room max age is always computed as
# CONNECTION_TEST_TOKEN_TTL_SECONDS + this value. Token expiration does
# not automatically delete rooms, so once that age is reached, the
# cleanup worker will explicitly delete the room if it still exists.
CONNECTION_TEST_ROOM_EXTRA_AGE_SECONDS = values.PositiveIntegerValue(
10,
environ_name="CONNECTION_TEST_ROOM_EXTRA_AGE_SECONDS",
environ_prefix=None,
)
CONNECTION_TEST_ROOM_PREFIX = values.Value(
"connection-test",
environ_name="CONNECTION_TEST_ROOM_PREFIX",
environ_prefix=None,
)
LIVEKIT_VERIFY_SSL = values.BooleanValue(
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
)
@@ -670,6 +708,11 @@ class Base(Configuration):
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
environ_prefix=None,
)
AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = values.BooleanValue(
True,
environ_name="AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME",
environ_prefix=None,
)
# Recording settings
RECORDING_ENABLE = values.BooleanValue(
@@ -748,12 +791,30 @@ class Base(Configuration):
environ_prefix=None,
)
SUMMARY_SERVICE_VERSION = values.PositiveIntegerValue(
1, environ_name="SUMMARY_SERVICE_VERSION", environ_prefix=None
)
SUMMARY_SERVICE_ENDPOINT = values.Value(
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
)
SUMMARY_SERVICE_API_TOKEN = SecretFileValue(
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
)
SUMMARY_SERVICE_WEBHOOK_API_TOKEN = SecretFileValue(
None, environ_name="SUMMARY_SERVICE_WEBHOOK_API_TOKEN", environ_prefix=None
)
SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS = (
values.PositiveIntegerValue(
60 * 60 * 24,
environ_name="SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS",
environ_prefix=None,
)
)
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL = values.Value(
None,
environ_name="TRANSCRIPTION_SATISFACTION_FORM_BASE_URL",
environ_prefix=None,
)
SCREEN_RECORDING_BASE_URL = values.Value(
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
)
@@ -854,6 +915,21 @@ class Base(Configuration):
environ_prefix=None,
)
# Roomkit (meeting-room SIP devices) integration
ROOMKIT_ENABLED = values.BooleanValue(
False,
environ_name="ROOMKIT_ENABLED",
environ_prefix=None,
)
# Server-to-server API token allowing the LiveKit SIP module to call the
# roomkit endpoints (e.g. join a room on behalf of a meeting-room device
# dialing in before any WebRTC participant).
ROOMKIT_SERVER_TO_SERVER_API_TOKEN = SecretFileValue(
None,
environ_name="ROOMKIT_SERVER_TO_SERVER_API_TOKEN",
environ_prefix=None,
)
# Subtitles settings
ROOM_SUBTITLE_ENABLED = values.BooleanValue(
False, environ_name="ROOM_SUBTITLE_ENABLED", environ_prefix=None
@@ -1110,11 +1186,25 @@ class Base(Configuration):
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
)
if (
cls.SUMMARY_SERVICE_VERSION == 1
and cls.SUMMARY_SERVICE_ENDPOINT is not None
):
warnings.warn(
"SUMMARY_SERVICE_VERSION=1 is deprecated. "
"The legacy v1 API has been removed from the experimental "
"Summary service. Please update your Summary service deployment to "
"the v2 API and set SUMMARY_SERVICE_VERSION=2.",
# We use UserWarning to make sure it shows up in production deployment
UserWarning,
stacklevel=2,
)
# The SENTRY_DSN setting should be available to activate sentry for an environment
if cls.SENTRY_DSN is not None:
sentry_sdk.init(
dsn=cls.SENTRY_DSN,
environment=cls.__name__.lower(),
environment=cls.__name__.lower(), # build, test, development, production
release=get_release(),
integrations=[DjangoIntegration()],
)
@@ -1209,6 +1299,8 @@ class Test(Base):
ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
ADDONS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
CONNECTION_TEST_ENABLED = True
def __init__(self):
# pylint: disable=invalid-name
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
+21 -20
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.22.0"
version = "1.25.1"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -24,25 +24,25 @@ keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = "MIT"
requires-python = ">=3.13"
dependencies = [
"boto3==1.43.14",
"boto3==1.43.56",
"Brotli==1.2.0",
"brevo-python==1.2.0",
"celery[redis]==5.6.3",
"dj-database-url==3.1.2",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.2.0",
"django-filter==25.2",
"django-lasuite[all]==0.0.26",
"django-countries==9.0.0",
"django-filter==26.1",
"django-lasuite[all]==0.0.27",
"django-parler==2.4",
"redis==5.2.1",
"django-redis==6.0.0",
"django-redis==7.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django-pydantic-field==0.5.4",
"django==5.2.14",
"django==5.2.16",
"djangorestframework==3.17.1",
"drf_spectacular==0.29.0",
"drf_spectacular==0.30.0",
"dockerflow==2026.3.4",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
@@ -50,20 +50,21 @@ dependencies = [
"jsonschema==4.26.0",
"markdown==3.10.2",
"nested-multipart-parser==1.6.0",
"posthog==7.16.1",
"posthog==7.29.0",
"psycopg[binary]==3.3.4",
"pydantic==2.13.4",
"PyJWT==2.13.0",
"python-frontmatter==1.3.0",
"python-magic==0.4.27",
"requests==2.34.2",
"sentry-sdk==2.60.0",
"sentry-sdk==2.66.1",
"whitenoise==6.12.0",
"mozilla-django-oidc==5.0.2",
"livekit-api==1.1.0",
"aiohttp==3.14.0",
"livekit-api==1.2.0",
"aiohttp==3.14.3",
"urllib3==2.7.0",
"phonenumbers==9.0.31",
"phonenumbers==9.0.34",
"cryptography==50.0.0", # CVE-2026-69247
]
[project.urls]
@@ -75,21 +76,21 @@ dependencies = [
[dependency-groups]
dev = [
"django-extensions==4.1",
"drf-spectacular-sidecar==2026.5.1",
"drf-spectacular-sidecar==2026.7.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.13.0",
"ipython==9.15.0",
"pyfakefs==6.2.0",
"pylint-django==2.7.0",
"pylint-django==2.8.0",
"pylint<4.0.0",
"pytest-cov==7.1.0",
"pytest-django==4.12.0",
"pytest==9.0.3",
"pytest==9.1.1",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.26.1",
"ruff==0.15.14",
"types-requests==2.33.0.20260518",
"responses==0.26.2",
"ruff==0.16.0",
"types-requests==2.33.0.20260712",
]
[tool.uv.build-backend]
+579 -585
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine AS frontend-deps
FROM node:22-alpine AS frontend-deps
USER node
@@ -36,10 +36,13 @@ WORKDIR /home/frontend
ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
ARG VITE_APP_TITLE
ENV VITE_APP_TITLE=${VITE_APP_TITLE}
RUN npm run build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
USER root
+5
View File
@@ -4,6 +4,11 @@ server {
server_tokens off;
root /usr/share/nginx/html;
location ^~ /assets/mediapipe/wasm/ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
+228 -1704
View File
File diff suppressed because it is too large Load Diff
+12 -8
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.22.0",
"version": "1.25.1",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -17,31 +17,33 @@
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11",
"@libreaudio/la-call": "0.1.4",
"@fontsource/opendyslexic": "5.2.5",
"@libreaudio/la-call": "0.1.4",
"@livekit/components-react": "2.9.21",
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2",
"@mediapipe/tasks-vision": "0.10.14",
"@pandacss/preset-panda": "1.11.3",
"@react-aria/toast": "3.0.10",
"@react-types/overlays": "3.10.0",
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "5.100.14",
"@tanstack/react-query": "5.101.1",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.2",
"i18next": "26.2.0",
"i18next": "26.3.1",
"i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.19.0",
"posthog-js": "1.382.0",
"livekit-client": "2.20.0",
"posthog-js": "1.395.0",
"react": "18.3.1",
"react-aria-components": "1.14.0",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
"react-dom": "18.3.1",
"react-i18next": "17.0.8",
"react-stately": "3.48.0",
"use-sound": "5.0.0",
"valtio": "2.3.2",
"wouter": "3.10.0"
@@ -52,6 +54,7 @@
"@tanstack/eslint-plugin-query": "5.100.14",
"@tanstack/react-query-devtools": "5.100.14",
"@types/humanize-duration": "3.27.4",
"@types/node": "24.12.4",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.2",
@@ -68,6 +71,7 @@
"typescript": "6.0.3",
"typescript-eslint": "8.60.1",
"vite": "8.0.14",
"vite-plugin-static-copy": "4.1.1",
"vite-plugin-svgr": "5.2.0"
}
}
+4
View File
@@ -114,6 +114,10 @@ const config: Config = {
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
},
},
overlayIn: {
from: { opacity: 0 },
to: { opacity: 0.6 },
},
},
tokens: defineTokens({
/* we take a few things from the panda preset but for now we clear out some stuff.

Some files were not shown because too many files have changed in this diff Show More