Compare commits

..

38 Commits

Author SHA1 Message Date
lebaudantoine 932f400a2e wip adapt lobby to be functional in an iframe 2026-08-03 14:20:56 +02:00
lebaudantoine a3f22e0a4f fixup! wip use another scheme for LiveKit token auth 2026-08-03 11:15:46 +02:00
lebaudantoine 0a879a96fd wip handle virtual background loading in an iframe context 2026-08-02 23:18:51 +02:00
lebaudantoine ed7fa7312a (frontend) alternative auth without relying on sameSite cookie 2026-08-02 19:03:16 +02:00
lebaudantoine 2f948fd53a wip use another scheme for LiveKit token auth 2026-08-02 18:59:02 +02:00
lebaudantoine e701a89036 (backend) wip introduce a token exchange endpoint 2026-08-01 15:52:37 +02:00
lebaudantoine 7fbcbc89ed 🧑‍💻(devx) prototype OrbStack as local Kubernetes provider on macOS
Add a prototype setup that uses OrbStack as the local Kubernetes
provider on macOS, aiming to save a few GB of RAM compared to the
current stack.

The script has been tested locally but was mostly built through vibe
coding, so it has not been thoroughly reviewed yet.

Follow-ups to look into:

* Simplify the ingress and CoreDNS setup if possible.
* Wire up a way to run tests and lint against the Tilt stack.
2026-07-31 14:18:37 +02:00
lebaudantoine 89f8480e0b 🚸(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-07-29 00:00:35 +02:00
lebaudantoine d9bf6efa2a 💄(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-07-29 00:00:35 +02:00
lebaudantoine 4cb7412194 (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-07-29 00:00:35 +02:00
lebaudantoine e8df597055 (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-07-29 00:00:35 +02:00
lebaudantoine 05dfcd11ca 🐛(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-07-29 00:00:35 +02:00
lebaudantoine b018832fcf (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-07-29 00:00:35 +02:00
lebaudantoine a269160f6f (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-07-29 00:00:35 +02:00
lebaudantoine c3afa84d9b ♻️(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-07-29 00:00:35 +02:00
lebaudantoine 8c6752a29f 🐛(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-07-29 00:00:35 +02:00
lebaudantoine 4c57432a03 💄(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-07-29 00:00:35 +02:00
lebaudantoine 3b7bfd999c 🔥(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-07-29 00:00:35 +02:00
lebaudantoine 7f386b2e2f ♻️(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-07-29 00:00:35 +02:00
lebaudantoine c55d8235fd (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-07-29 00:00:35 +02:00
lebaudantoine c7b23abd68 (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-07-29 00:00:35 +02:00
lebaudantoine 5a641a4366 ♻️(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-07-29 00:00:35 +02:00
lebaudantoine f043ad6f98 (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-07-29 00:00:35 +02:00
lebaudantoine 1141c1cecd ️(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-07-29 00:00:35 +02:00
Arnaud Robin 33792b050a 📝(legal) update terms of service
Update terms of service content after legal review
2026-07-28 13:52:50 +02:00
leo f4569c64e5 🐛(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-07-28 10:54:03 +02:00
snyk-bot 6a00d3d087 ⬆️(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-07-27 18:21:10 +02:00
Camille Moulin 2e975e2643 📝(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-07-27 18:20:34 +02:00
lebaudantoine 4c63aa827f 📝(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-07-24 18:31:47 +02:00
lebaudantoine 67e9bf2fef 🐛(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-07-24 18:31:47 +02:00
lebaudantoine 7124167947 🐛(frontend) fix pinnedTrackRef always evaluating to true
pinnedTrackRef was always truthy when evaluated in this
code path.
2026-07-24 18:31:47 +02:00
lebaudantoine 759388c72f ️(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-07-24 18:31:47 +02:00
lebaudantoine a663b4dc76 ️(frontend) memoize the EffectsButton
Memoize the EffectsButton 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 73aa162dc8 ️(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-07-24 18:31:47 +02:00
lebaudantoine a3851842e9 🚚(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-07-24 18:31:47 +02:00
lebaudantoine 77964c6a74 ♻️(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-07-24 18:31:47 +02:00
lebaudantoine 72b863e794 ️(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-07-24 18:31:47 +02:00
lebaudantoine 0e3c978af2 ️(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
317 changed files with 6545 additions and 14002 deletions
+29
View File
@@ -0,0 +1,29 @@
# /!\
# 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 }}
+2 -147
View File
@@ -10,151 +10,15 @@ and this project adheres to
### Added
- ✨(agent) support Voxtral realtime as inference engine
- 🌐(i18n) add Spanish language support
### Changed
- 🔥(backend) remove the S3 storage-event webhook for recordings
- ♻️(backend) always finalize recordings using the LiveKit egress_ended webhook
- ⬆️(frontend) upgrade posthog-js from 1.409.5 to 1.414.0
- ⬆️(frontend) upgrade @fontsource-variable/lexend from 5.2.11 to 5.3.0
- ⬆️(frontend) upgrade @fontsource/opendyslexic from 5.2.5 to 5.3.0
### Fixed
- 🐛(frontend) fix chat text-area bug
## [1.29.0] - 2026-08-25
### Added
- ✨(any) let any authenticated user manage the lobby on trusted rooms
### Changed
- 📱(frontend) collapse mobile control bar items on narrow viewports
- 📱(frontend) stack idle modal buttons in a column on mobile
- 📱(frontend) improve feedback screen responsiveness on mobile
- ⬆️(frontend) upgrade @fontsource-variable/atkinson-hyperlegible-next
- ⬆️(frontend) upgrade i18next-resources-to-backend from 1.2.1 to 1.2.3
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.1 to 5.101.4
- ⬆️(frontend) upgrade @pandacss/preset-panda from 1.11.3 to 1.12.0
- ⬆️(frontend) upgrade posthog-js from 1.404.1 to 1.409.5
- ⚡️(frontend) apply frugal constraint to the active meeting audio track
- ⚡️(backend) replace blocking Redis KEYS with cursor-based SCAN
- ✨(summary) add hostname to analytics properties
## [1.28.0] - 2026-08-24
### Added
- 📈(frontend) track errors when starting or stopping a recording
- 🚸(frontend) explain camera-in-use failures on the join screen
### Changed
- ✨(backend) accept form-urlencoded on the user token endpoint
- ✨(summary) configurable s3 region
- ⬆️(frontend) upgrade i18next and react-i18next patch versions
- ⬆️(frontend) upgrade posthog-js from 1.395.0 to 1.404.1
- ⬆️(frontend) upgrade livekit-client and @livekit/components-react
- 💄(frontend) increase the blur intensity
### Fixed
- 📝(docs) fix minor typos in comments and docstrings
- ⬆️(backend) bump sqlparse from 0.5.5 to 0.6.0
- ⬆️(mail) bump @html-to/text-cli from 0.6.0 to 0.6.1
- 🐛(frontend) treat client-initiated connect aborts as events
- 🐛(frontend) use state instead of a ref for MoreControls container
- 🐛(frontend) stop init_virtual_background from firing on blur updates
- 🐛(frontend) hoist mute confirmation dialog to VideoConference level
- 🐛(frontend) fix joined notification tile no longer rendering properly
- 🐛(frontend) handle device-in-use errors on Chrome / Windows 10
- 🐛(frontend) handle Firefox/Windows AbortError on device start
- 🐛(frontend) treat "Timeout starting source" AbortError as device-in-use
- 🔇(frontend) suppress leaked WebSocket error events from livekit-client
## [1.27.0] - 2026-08-14
### Changed
- 🔥(frontend) drop unused vendored ConnectionObserver
- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
### Fixed
- 📈(frontend) downgrade unreachable external home URL from error to event
- 🐛(frontend) handle 401 responses when syncing user preferences
- 🐛(frontend) harden speaker test against missing sinks and play errors
- 🐛(frontend) implement hysteresis band for the control bar layout
- 🐛(frontend) fix toolbar ResizeObserver loop and alignment drift
- 🐛(analytics) filter benign ResizeObserver loop error in Sentry/PostHog
- 🐛(frontend) stop reporting screen-share denials as errors
- 🐛(frontend) generalize screen-share error modal beyond macOS
- 📈(frontend) stop double-reporting media device failures
## [1.26.0] - 2026-08-12
### Added
- 📈(frontend) capture media diagnostics on media errors
- ✨(frontend) add an audio gauge to the microphone select menu
- ✨(frontend) add a sound tester to the output select menu
- ✨(frontend) prompt for permissions when toggling a denied device
- ⚗️(frontend) capture console.error in PostHog
- 📈(frontend) snapshot media devices on the happy path
- 🚸(frontend) guide users when the OS blocks browser media access
- ✨(frontend) add a silent-microphone watcher on join and room screens
### Changed
- ♻️(frontend) encapsulate error tracking behind a telemetry module
- ♻️(frontend) encapsulate PostHog capture calls in the telemetry module
- 🔧(frontend) sync persisted device ids with the actual selected devices
- 💄(frontend) hide the ProConnect button on narrow viewports
- ♻️(frontend) prefer captureMediaEvent over reportError when no-op
### Fixed
- 🐛(frontend) drop exact deviceId constraint on dynamic track creation
- 🐛(frontend) fix permission store regression
- 🐛(frontend) handle missing device errors gracefully
- 🐛(frontend) display the meeting id in the join screen page title
## [1.25.2] - 2026-08-06
### 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 i18next from 26.3.1 to 26.3.4
- ⬆️(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
@@ -162,10 +26,8 @@ and this project adheres to
- 📝(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
## Fixed
- 🐛(transcription) fix silent bug in speaker assignment
- 🐛(summary) extend tasks auto retry logic
@@ -174,13 +36,6 @@ and this project adheres to
- 🐛(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
+14 -14
View File
@@ -44,7 +44,6 @@ 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
@@ -52,14 +51,6 @@ 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
@@ -133,7 +124,6 @@ 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
@@ -198,23 +188,27 @@ 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
@$(COMPOSE_RUN_LINT) sh -c "$(LINT_BACK)"
lint: \
lint-ruff-format \
lint-ruff-check \
lint-pylint
.PHONY: lint
lint-ruff-format: ## format back-end python sources with ruff
@echo 'lint:ruff-format started…'
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_FORMAT)
@$(COMPOSE_RUN_APP) ruff format .
.PHONY: lint-ruff-format
lint-ruff-check: ## lint back-end python sources with ruff
@echo 'lint:ruff-check started…'
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_CHECK)
@$(COMPOSE_RUN_APP) ruff check . --fix
.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_LINT) $(LINT_PYLINT)
@$(COMPOSE_RUN_APP) pylint meet demo core
.PHONY: lint-pylint
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
@@ -395,6 +389,12 @@ build-k8s-cluster: \
./bin/start-kind.sh
.PHONY: build-k8s-cluster
build-k8s-cluster-orbstack: ## setup the kubernetes environment on OrbStack's built-in cluster (macOS)
build-k8s-cluster-orbstack: \
env.d/development/kube-secret
./bin/start-orbstack.sh
.PHONY: build-k8s-cluster-orbstack
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
+1 -1
View File
@@ -4,7 +4,7 @@
Security is very important to us.
If you have any issue regarding security, please disclose the information responsibly by submitting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at visio@numerique.gouv.fr
If you have any issue regarding security, please disclose the information responsibly submiting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at visio@numerique.gouv.fr
We appreciate your effort to make Visio more secure.
-17
View File
@@ -16,23 +16,6 @@ the following command inside your docker container:
## [Unreleased]
### Removing S3 storage-event webhooks for recordings
Recordings were previously confirmed as saved by an S3 storage-event webhook posting to `/api/v1.0/recordings/storage-hook/`. That endpoint has been removed: recordings are now always finalized from LiveKit's own `egress_ended` webhook, which has been the default path since v1.22.0.
**Required for every deployment:** LiveKit must be able to deliver webhooks to the backend at `/api/v1.0/rooms/webhooks-livekit/`. This is now the only way a recording reaches a saved state; if `egress_ended` is never delivered, recordings stay in the `active` state.
For hosters who had configured storage-event webhooks:
- Recordings reach the same final state, but they are now finalized when LiveKit reports the egress as ended rather than when the storage backend reports the upload.
- Remove the event notification from your bucket configuration: it now targets a non-existent endpoint and will fail on every delivery.
For hosters who had **not** configured storage-event webhooks:
- Nothing changes. Recordings have been finalized from the `egress_ended` webhook since v1.22.0.
In both cases, the following settings are no longer used and can be removed from your env: `RECORDING_EVENT_PARSER_CLASS`, `RECORDING_ENABLE_STORAGE_EVENT_AUTH`, `RECORDING_STORAGE_EVENT_ENABLE`, `RECORDING_STORAGE_EVENT_TOKEN`.
On completion of the egress, a recording moves to `notification_succeeded`, or to `saved` if notifying external services failed.
## 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`.
+6
View File
@@ -1,5 +1,11 @@
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
load('ext://namespace', 'namespace_create', 'namespace_inject')
# OrbStack's built-in cluster (macOS) is a supported alternative to kind.
# Recent Tilt versions (>= 0.33) detect it as a local dev cluster; this is
# a no-op for kind and a safety net for older Tilt versions.
allow_k8s_contexts('orbstack')
namespace_create('meet')
DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak')
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
#
# Bootstrap the local dev environment on OrbStack's built-in Kubernetes
# cluster (macOS) instead of kind.
#
# This replicates what bin/start-kind.sh (numerique-gouv/tools
# kind/create_cluster.sh) provides, minus what OrbStack makes unnecessary:
# - no kind cluster: OrbStack ships a lightweight single-node cluster
# - no local registry (kind-registry): OrbStack's cluster shares the
# Docker image store, so images built by Tilt are directly visible
# to pods. Tilt detects the "orbstack" context as a local cluster
# and skips pushing images entirely.
#
# Requirements: OrbStack (with Kubernetes enabled), kubectl, mkcert, curl.
set -o errexit
APPLICATION=${1:-meet}
CONTEXT="orbstack"
echo "0. Check OrbStack Kubernetes is available"
if ! command -v mkcert >/dev/null 2>&1; then
echo "❌ mkcert is not installed. Install it first: brew install mkcert"
exit 1
fi
if ! kubectl config get-contexts -o name | grep -qx "${CONTEXT}"; then
echo "Context '${CONTEXT}' not found. Trying to start OrbStack Kubernetes..."
if command -v orb >/dev/null 2>&1; then
orb start k8s
else
echo "❌ Enable Kubernetes in OrbStack (Settings > Kubernetes) and retry."
exit 1
fi
fi
kubectl config use-context "${CONTEXT}"
echo "0b. Check ports 80/443 are free on localhost"
# OrbStack forwards LoadBalancer service ports to 127.0.0.1. If the kind
# cluster is still running, its docker proxy already holds 80/443.
# Skip the check if ingress-nginx is already installed here: in that case
# the listener on 80/443 is our own LoadBalancer.
if ! kubectl -n ingress-nginx get deployment ingress-nginx-controller >/dev/null 2>&1; then
for port in 80 443; do
if lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; then
echo "❌ Port ${port} is already in use on the host."
echo " If the kind cluster is running, delete it first:"
echo " kind delete cluster --name suite"
exit 1
fi
done
fi
echo "1. Create ca"
CURRENT_DIR=$(pwd)
mkcert -install
cd /tmp
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
cd "${CURRENT_DIR}"
echo "2. Install ingress-nginx (cloud provider: LoadBalancer service)"
# OrbStack exposes LoadBalancer services on 127.0.0.1, so the cloud
# manifest replaces kind's hostPort-based deploy. Every sub-step below is
# guarded individually so the script is safe to re-run after a partial
# failure (unlike the upstream kind script, which guards the whole block
# on namespace existence).
# Make sure no stale registry configmap tells Tilt to push to localhost:5001
# (there is no registry on OrbStack).
kubectl -n kube-public delete configmap local-registry-hosting --ignore-not-found
if ! kubectl -n ingress-nginx get deployment ingress-nginx-controller >/dev/null 2>&1; then
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/cloud/deploy.yaml
fi
if ! kubectl -n ingress-nginx get deployment nginx-errors >/dev/null 2>&1; then
kubectl apply -n ingress-nginx -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/refs/heads/main/docs/examples/customization/custom-errors/custom-default-backend.yaml
fi
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem || echo ok
# The meet charts render Ingresses without ingressClassName. The kind
# provider manifest handles this via --watch-ingress-without-class=true;
# the cloud manifest does not, so add it here (otherwise: 404 everywhere).
if ! kubectl -n ingress-nginx get deployment ingress-nginx-controller -o jsonpath='{.spec.template.spec.containers[0].args}' | grep -q 'watch-ingress-without-class'; then
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--watch-ingress-without-class=true"},{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"},{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-backend-service=ingress-nginx/nginx-errors"}
]'
fi
if ! kubectl -n ingress-nginx get deployment nginx-errors -o jsonpath='{.spec.template.spec.containers[0].image}' | grep -q 'error-pages'; then
kubectl -n ingress-nginx patch deployment nginx-errors --type=json -p='[
{"op": "replace", "path": "/spec/template/spec/containers/0/image", "value": "ghcr.io/tarampampam/error-pages:3.3.0"},
{"op": "add", "path": "/spec/template/spec/containers/0/env", "value": [{"name": "TEMPLATE_NAME", "value": "ghost"}, {"name": "SHOW_DETAILS", "value": "false"}, {"name": "SEND_SAME_HTTP_CODE", "value": "true"}]}
]'
fi
cat <<EOF | kubectl apply -n ingress-nginx -f -
apiVersion: v1
data:
allow-snippet-annotations: "true"
annotations-risk-level: Critical
custom-http-errors: 500,501,502,503,504
kind: ConfigMap
metadata:
name: ingress-nginx-controller
namespace: ingress-nginx
EOF
echo "2b. Wait for the ingress controller to be ready"
kubectl -n ingress-nginx rollout status deployment/ingress-nginx-controller --timeout=180s
echo "3. Patch CoreDNS so in-cluster pods resolve *.127.0.0.1.nip.io to the ingress"
# nip.io resolves to 127.0.0.1, which inside a pod is the pod itself.
# Rewrite these names to the ingress-nginx service, like the kind setup does.
# Unlike kind, we amend OrbStack's existing Corefile instead of replacing it.
if ! kubectl -n kube-system get configmap coredns -o jsonpath='{.data.Corefile}' | grep -q '127\.0\.0\.1\.nip\.io'; then
kubectl -n kube-system get configmap coredns -o jsonpath='{.data.Corefile}' \
| awk '/forward \./ && !done { print " rewrite stop {"; print " name regex (.*).127.0.0.1.nip.io ingress-nginx-controller.ingress-nginx.svc.cluster.local answer auto"; print " }"; done=1 } { print }' \
>/tmp/Corefile.orbstack
kubectl -n kube-system create configmap coredns --from-file=Corefile=/tmp/Corefile.orbstack --dry-run=client -o yaml | kubectl apply -f -
kubectl -n kube-system rollout restart deployments/coredns
fi
if ! kubectl get ns "${APPLICATION}" >/dev/null 2>&1; then
echo "4. Setup namespace"
kubectl create ns "${APPLICATION}"
fi
kubectl config set-context --current --namespace="${APPLICATION}"
kubectl -n "${APPLICATION}" create secret generic mkcert --from-file=rootCA.pem="$(mkcert -CAROOT)/rootCA.pem" || echo ok
if ! kubectl get configmap certifi -n "${APPLICATION}" >/dev/null 2>&1; then
echo "5. Inject our custom CA in a configmap for certifi"
curl https://raw.githubusercontent.com/certifi/python-certifi/refs/heads/master/certifi/cacert.pem -o /tmp/cacert.pem
cat "$(mkcert -CAROOT)/rootCA.pem" >>/tmp/cacert.pem
kubectl -n "${APPLICATION}" create configmap certifi --from-file=cacert.pem=/tmp/cacert.pem
kubectl -n "${APPLICATION}" create secret generic certifi --from-file=/tmp/cacert.pem || echo ok
fi
echo "5b. Smoke test: the ingress chain answers on https://127.0.0.1"
# Before Tilt deploys the app this returns the styled 404 from the default
# backend — that still proves LB -> controller works. 000 means the
# LoadBalancer is not bound to localhost.
HTTP_CODE=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 10 https://127.0.0.1/ || true)
if [ "${HTTP_CODE}" = "000" ]; then
echo "⚠️ Nothing answered on https://127.0.0.1 — check the LoadBalancer:"
echo " kubectl -n ingress-nginx get svc ingress-nginx-controller"
else
echo "✅ Ingress reachable (HTTP ${HTTP_CODE})"
fi
echo "6. Check pod readiness across all namespaces..."
sleep_interval=10
echo "Initial wait time: $((sleep_interval * 2)) seconds…"
sleep $((sleep_interval * 2))
check_pods_ready() {
local max_attempts=60 # Maximum number of attempts (10 minutes with 10s intervals)
local attempt=1
while [ $attempt -le $max_attempts ]; do
echo "Attempt $attempt/$max_attempts - Checking pod status..."
not_ready_count=$( kubectl get po -A --no-headers | grep -v -E "Running|Completed"| wc -l | tr -d ' ')
if [ "$not_ready_count" -eq 0 ]; then
echo "✅ All pods are ready!"
return 0
else
echo "$not_ready_count pod(s) still not ready. Waiting $sleep_interval seconds…"
sleep $sleep_interval
((attempt++))
fi
done
echo "❌ Timeout: Some pods are still not ready after 10 minutes"
echo "Final pod status:"
kubectl get po -A
return 1
}
if check_pods_ready; then
echo "🎉 Cluster is fully ready!"
else
echo "⚠️ Some pods may need manual intervention"
exit 1
fi
+20 -4
View File
@@ -46,6 +46,21 @@ services:
/usr/bin/mc mb meet/meet-media-storage && \
exit 0;"
createwebhook:
image: minio/mc
depends_on:
minio:
condition: service_healthy
restart: true
entrypoint: >
sh -c "
/usr/bin/mc alias set meet http://minio:9000 meet password &&
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
/usr/bin/mc admin service restart meet --wait --json &&
sleep 15 &&
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put --prefix "recordings" &&
exit 0;"
app-dev:
build:
context: .
@@ -70,7 +85,9 @@ services:
- postgresql
- mailcatcher
- redis
- livekit
- createbuckets
- createwebhook
extra_hosts:
- "127.0.0.1.nip.io:host-gateway"
networks:
@@ -80,7 +97,7 @@ services:
celery-dev:
user: ${DOCKER_USER:-1000}
image: meet:backend-development
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "DEBUG", "--pool=solo"]
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "DEBUG"]
environment:
- DJANGO_CONFIGURATION=Development
env_file:
@@ -115,7 +132,7 @@ services:
celery:
user: ${DOCKER_USER:-1000}
image: meet:backend-production
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "INFO", "--pool=solo"]
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "INFO"]
environment:
- DJANGO_CONFIGURATION=Demo
env_file:
@@ -164,7 +181,7 @@ services:
working_dir: /app
node:
image: node:22
image: node:18
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
@@ -251,7 +268,6 @@ services:
build:
context: ./src/agents
target: development
command: ["python", "multi_user_transcriber.py", "dev"]
env_file:
- env.d/development/multi_user_transcriber
volumes:
-5
View File
@@ -65,11 +65,6 @@ 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;
+21
View File
@@ -143,3 +143,24 @@ $ make start-tilt-keycloak
```
Monitor Tilts progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://meet.127.0.0.1.nip.io/](https://meet.127.0.0.1.nip.io/).
### Alternative: OrbStack's built-in Kubernetes (macOS)
If you use [OrbStack](https://orbstack.dev/) on macOS, you can run the stack on its built-in Kubernetes cluster instead of kind. It uses noticeably less RAM (no nested kubeadm node container) and no local registry is needed: OrbStack's cluster shares the Docker image store, so Tilt uses images directly without pushing.
Enable Kubernetes in OrbStack (Settings > Kubernetes), then:
```shellscript
$ make build-k8s-cluster-orbstack
```
This installs ingress-nginx (exposed by OrbStack on `127.0.0.1:80/443`), the mkcert TLS certificates, and the CoreDNS rewrite for `*.127.0.0.1.nip.io`, then you start Tilt as usual:
```shellscript
$ make start-tilt-keycloak
```
Notes:
- Ports 80/443 must be free: delete the kind cluster first if you used it (`kind delete cluster --name suite`).
- If you "Reset Kubernetes" in OrbStack, re-run `make build-k8s-cluster-orbstack`.
- kind remains the reference setup (matches CI and lets you pin the Kubernetes version).
+22 -2
View File
@@ -23,11 +23,14 @@ It uses LiveKit Egress to record room sessions. For reference, see the [LiveKit
To use the room recording feature, the following components are required:
- A running [LiveKit Egress](https://github.com/livekit/egress) server capable of handling room composite recordings.
- A S3-compatible object storage where the egress uploads the recorded files.
- A S3-compatible object storage that supports webhook events to notify the backend when recordings are uploaded.
- An email service to notify room owners when a recording is available for download.
- Webhook events configured between LiveKit Server and the backend.
> [!CAUTION]
> Minio supports lifecycle events; other providers may not work out of the box. There is currently a dependency on Minio, which is planned to be refactored in the future.
> [!NOTE]
> Celery isnt in use for these async tasks yet. Its something wed like to add, but its not planned at this stage.
@@ -72,7 +75,7 @@ sequenceDiagram
LiveKit->>Egress: Stop recording
Egress->>Storage: Upload recorded file
LiveKit->>Backend: POST /api/v1.0/rooms/webhooks-livekit/ (egress_ended)
Storage->>Backend: Storage event notification
Backend->>Backend: Update Recording status to SAVED
Backend->>Email: Send notification to room owner
@@ -91,6 +94,10 @@ sequenceDiagram
| **RECORDING_ENABLE** | Boolean | `False` | Enable or disable the room recording feature. |
| **RECORDING_OUTPUT_FOLDER** | String | `"recordings"` | Folder/prefix where recordings are stored in the object storage. |
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
| **RECORDING_EVENT_PARSER_CLASS** | String | `"core.recording.event.parsers.MinioParser"` | Class responsible for parsing storage events and updating the backend. |
| **RECORDING_ENABLE_STORAGE_EVENT_AUTH** | Boolean | `True` | Enable authentication for storage event webhook requests. |
| **RECORDING_STORAGE_EVENT_ENABLE** | Boolean | `False` | Enable handling of storage events (must configure webhook in storage). If `False`, fallback to LiveKit egress complete webhook. |
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). |
@@ -102,6 +109,19 @@ sequenceDiagram
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
### Manual Storage Webhook
Storage events must be configured manually; the Kubernetes chart does not do this automatically.
1. Configure your S3 bucket to send file creation events to the backend webhook.
2. Enable events and token in settings:
```python
RECORDING_STORAGE_EVENT_ENABLE = True
RECORDING_ENABLE_STORAGE_EVENT_AUTH = True
RECORDING_STORAGE_EVENT_TOKEN = <token>
```
> [!NOTE]
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
+4
View File
@@ -406,6 +406,10 @@ These are the environmental options available on meet backend.
| RECORDING_ENABLE | Record meeting option | false |
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
| RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser |
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events. If false, fallback to egress webhook. | false |
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
-27
View File
@@ -50,24 +50,10 @@ paths:
The application must be authorized for the user's email domain.
The returned token expires after a configured duration and must be refreshed by calling this endpoint again.
Request parameters may be sent either as "application/x-www-form-urlencoded"
(as specified by RFC 6749 for OAuth 2.0 token endpoints) or as "application/json".
operationId: generateToken
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/TokenRequest'
examples:
tokenRequest:
summary: Request token for user delegation
value:
client_id: "550e8400-e29b-41d4-a716-446655440000"
client_secret: "1234567890abcdefghijklmnopqrstuvwxyz"
grant_type: "client_credentials"
scope: "user@example.com"
application/json:
schema:
$ref: '#/components/schemas/TokenRequest'
@@ -131,19 +117,6 @@ paths:
summary: Domain not authorized
value:
error: "This application is not authorized for this email domain."
'415':
description: |
Unsupported media type. The request body must be sent as
"application/x-www-form-urlencoded" or "application/json".
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
unsupportedMediaType:
summary: Unsupported request content type
value:
detail: 'Unsupported media type "text/plain" in request.'
/rooms:
get:
+2 -7
View File
@@ -63,6 +63,8 @@ ALLOW_UNREGISTERED_ROOMS=False
# Recording
RECORDING_ENABLE=True
RECORDING_STORAGE_EVENT_ENABLE=True
RECORDING_STORAGE_EVENT_TOKEN=password
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
@@ -83,10 +85,6 @@ 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
@@ -102,6 +100,3 @@ 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
+4 -13
View File
@@ -1,23 +1,14 @@
AWS_S3_ENDPOINT_URL=minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
STT_PROVIDER=voxtral-vllm # voxtral-vllm, kyutai, deepgram
STT_PROVIDER=kyutai # kyutai, deepgram
ENABLE_SILERO_VAD=False
DEEPGRAM_API_KEY=your-deepgram-api-key
DEEPGRAM_API_KEY=
KYUTAI_STT_BASE_URL=url
KYUTAI_API_KEY=your-kyutai-api-key
VOXTRAL_VLLM_BASE_URL=wss://<host>/v1/realtime
VOXTRAL_VLLM_MODEL=voxtral-mini-4b-realtime-2602
VOXTRAL_VLLM_API_KEY=your-vllm-api-key
VOXTRAL_VLLM_TARGET_STREAMING_DELAY_MS=480
KYUTAI_STT_BASE_URL=
KYUTAI_API_KEY=
SENTRY_DSN=
SENTRY_ENVIRONMENT=
+5 -5
View File
@@ -10,7 +10,7 @@
"license": "MIT",
"dependencies": {
"core-js": "3.49.0",
"i18next": "^26.3.6",
"i18next": "^26.3.4",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
@@ -9364,9 +9364,9 @@
}
},
"node_modules/i18next": {
"version": "26.3.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
"version": "26.3.4",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz",
"integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==",
"funding": [
{
"type": "individual",
@@ -9383,7 +9383,7 @@
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6 || ^7"
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
+1 -1
View File
@@ -27,7 +27,7 @@
},
"dependencies": {
"core-js": "3.49.0",
"i18next": "26.3.6",
"i18next": "26.3.4",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
+16 -53
View File
@@ -1,7 +1,6 @@
"""Multi user transcription agent."""
import asyncio
import contextlib
import logging
import os
@@ -26,7 +25,6 @@ from livekit.agents import (
)
from livekit.plugins import deepgram, silero
import voxtral_vllm_stt
from observability import configure_sentry, set_job_context
from tasks import done_callback
@@ -38,18 +36,9 @@ TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcr
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
SESSION_DRAIN_TIMEOUT_S = 15.0
def create_stt_provider(vad: silero.VAD | None = None):
"""Create STT provider based on environment configuration.
Args:
vad: Shared, prewarmed VAD instance. Required in practice for
voxtral-vllm (no server-side endpointing): if omitted, the plugin
loads its own Silero model synchronously on the event loop, once
per participant, freezing all active sessions for the duration.
"""
def create_stt_provider():
"""Create STT provider based on environment configuration."""
if STT_PROVIDER == "deepgram":
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
# detect_language is NOT supported for real-time streaming
@@ -60,9 +49,6 @@ def create_stt_provider(vad: silero.VAD | None = None):
)
elif STT_PROVIDER == "kyutai":
_stt_instance = kyutai.STT(base_url=os.getenv("KYUTAI_STT_BASE_URL"))
elif STT_PROVIDER == "voxtral-vllm":
# The plugin resolves base_url / model / api_key from the environment.
_stt_instance = voxtral_vllm_stt.STT(vad=vad)
else:
raise ValueError(f"Unknown STT_PROVIDER: {STT_PROVIDER}")
@@ -72,9 +58,9 @@ def create_stt_provider(vad: silero.VAD | None = None):
class Transcriber(Agent):
"""Create a transcription agent for a specific participant."""
def __init__(self, *, participant_identity: str, vad: silero.VAD | None = None):
def __init__(self, *, participant_identity: str):
"""Init transcription agent."""
stt = create_stt_provider(vad=vad)
stt = create_stt_provider()
super().__init__(
instructions="not-needed",
@@ -90,7 +76,6 @@ class MultiUserTranscriber:
"""Init multi user transcription agent."""
self.ctx = ctx
self._sessions: dict[str, AgentSession] = {}
self._starting: dict[str, asyncio.Task] = {}
self._tasks: set[asyncio.Task] = set()
def start(self):
@@ -111,30 +96,22 @@ class MultiUserTranscriber:
def on_participant_connected(self, participant: rtc.RemoteParticipant):
"""Handle new participant connection by starting transcription session."""
identity = participant.identity
if identity in self._sessions or identity in self._starting:
if participant.identity in self._sessions:
return
logger.info(f"starting session for {identity}")
logger.info(f"starting session for {participant.identity}")
task = asyncio.create_task(self._start_session(participant))
self._starting[identity] = task
self._tasks.add(task)
task.add_done_callback(lambda t, i=identity: self._starting.pop(i, None))
task.add_done_callback(
done_callback(
logger,
self._tasks,
f"start transcription session for {identity}",
f"start transcription session for {participant.identity}",
)
)
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
"""Handle participant disconnection by closing transcription session."""
if (start_task := self._starting.pop(participant.identity, None)) is not None:
logger.info(f"cancelling pending session start for {participant.identity}")
start_task.cancel()
return
if (session := self._sessions.pop(participant.identity, None)) is None:
return
@@ -150,12 +127,10 @@ class MultiUserTranscriber:
)
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
"""Create and start transcription session for participant.
"""Create and start transcription session for participant."""
if participant.identity in self._sessions:
return self._sessions[participant.identity]
Deduplication happens synchronously in on_participant_connected via
self._starting; by the time this coroutine runs, the identity is
already reserved.
"""
vad = self.ctx.proc.userdata.get("vad", None)
session = AgentSession(vad=vad)
room_io = RoomIO(
@@ -166,30 +141,18 @@ class MultiUserTranscriber:
text_input=False, audio_output=False, text_output=True
),
)
try:
await room_io.start()
await session.start(
agent=Transcriber(
participant_identity=participant.identity,
vad=vad,
)
await room_io.start()
await session.start(
agent=Transcriber(
participant_identity=participant.identity,
)
except BaseException:
with contextlib.suppress(Exception):
await session.aclose()
raise
)
self._sessions[participant.identity] = session
return session
async def _close_session(self, sess: AgentSession) -> None:
"""Close and cleanup transcription session."""
try:
await asyncio.wait_for(sess.drain(), timeout=SESSION_DRAIN_TIMEOUT_S)
except (TimeoutError, asyncio.TimeoutError):
logger.warning(
"session drain timed out after %.0fs; forcing close",
SESSION_DRAIN_TIMEOUT_S,
)
await sess.drain()
await sess.aclose()
+6 -8
View File
@@ -1,24 +1,22 @@
[project]
name = "agents"
version = "1.29.0"
version = "1.24.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.6.7",
"livekit-plugins-deepgram==1.6.7",
"livekit-plugins-silero==1.6.7",
"livekit-agents==1.6.4",
"livekit-plugins-deepgram==1.6.4",
"livekit-plugins-silero==1.6.4",
"livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.2",
"protobuf==6.33.6",
"minio==7.2.20",
"sentry-sdk==2.66.1",
"websockets==17.1",
"httpx==0.28.1",
"sentry-sdk==2.60.0",
]
[project.optional-dependencies]
dev = [
"ruff==0.16.0",
"ruff==0.15.19",
]
[tool.uv]
+559 -819
View File
File diff suppressed because it is too large Load Diff
-476
View File
@@ -1,476 +0,0 @@
"""LiveKit STT plugin for Voxtral Realtime served via vLLM (/v1/realtime).
vLLM exposes Voxtral Realtime over a WebSocket that follows the OpenAI Realtime
API protocol (not Mistral's proprietary realtime protocol).
"""
from __future__ import annotations
import asyncio
import base64
import json
import logging
import os
import weakref
from collections import deque
from dataclasses import dataclass, field
import websockets
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
APIConnectionError,
APIConnectOptions,
APIStatusError,
stt,
utils,
)
from livekit.agents import (
vad as vad_module,
)
from livekit.agents.types import NOT_GIVEN, NotGivenOr
from livekit.agents.utils import is_given
logger = logging.getLogger("voxtral-vllm-stt")
SAMPLE_RATE = 16000
NUM_CHANNELS = 1
CHUNK_SAMPLES = 1600 # 100 ms @ 16 kHz mono
PREROLL_CHUNKS = 5 # keep 500 ms of audio before start of speech as detected by VAD
# Reconnect policy: exponential backoff capped at MAX, give up after MAX_ATTEMPTS
# consecutive failures (a successful handshake resets the counter).
RECONNECT_BACKOFF_BASE_S = 0.5
RECONNECT_BACKOFF_MAX_S = 8.0
RECONNECT_MAX_ATTEMPTS = 5
@dataclass
class _STTOptions:
base_url: str
model: str
api_key: str | None
target_streaming_delay_ms: int | None
@dataclass
class _PendingUtterance:
"""An utterance in flight on the shared websocket used for reconnect.
`sent_chunks` holds every chunk we have already enqueued for send on this
or a prior connection; on reconnect we replay them before resuming reads
from `queue`. vLLM concatenates `input_audio_buffer.append` events into a
single audio buffer per generation, so duplicates from a partial prior send
are harmless.
"""
queue: asyncio.Queue[bytes | None]
sent_chunks: list[bytes] = field(default_factory=list)
ended: bool = False
class STT(stt.STT):
"""LiveKit STT speaking the OpenAI Realtime protocol served by vLLM."""
def __init__(
self,
*,
base_url: NotGivenOr[str] = NOT_GIVEN,
model: NotGivenOr[str] = NOT_GIVEN,
api_key: NotGivenOr[str] = NOT_GIVEN,
target_streaming_delay_ms: NotGivenOr[int] = NOT_GIVEN,
vad: vad_module.VAD | None = None,
) -> None:
"""Build the STT.
Args:
base_url: WebSocket URL of the vLLM realtime endpoint, e.g.
ws://example:8000/v1/realtime. Falls back to $VOXTRAL_VLLM_BASE_URL.
model: Model name exposed by vLLM, default
mistralai/Voxtral-Mini-4B-Realtime-2602.
api_key: Optional bearer token. Falls back to $VOXTRAL_VLLM_API_KEY.
target_streaming_delay_ms: Target streaming delay in ms forwarded to
vLLM via session.update. Falls back to
$VOXTRAL_VLLM_TARGET_STREAMING_DELAY_MS, else server default.
vad: Voice Activity Detector. If omitted, Silero VAD is loaded.
"""
super().__init__(
capabilities=stt.STTCapabilities(streaming=True, interim_results=True)
)
resolved_url = (
base_url
if is_given(base_url)
else os.environ.get(
"VOXTRAL_VLLM_BASE_URL", "ws://127.0.0.1:8000/v1/realtime"
)
)
resolved_model = (
model
if is_given(model)
else os.environ.get(
"VOXTRAL_VLLM_MODEL", "mistralai/Voxtral-Mini-4B-Realtime-2602"
)
)
resolved_key = (
api_key if is_given(api_key) else os.environ.get("VOXTRAL_VLLM_API_KEY")
)
resolved_delay = (
target_streaming_delay_ms
if is_given(target_streaming_delay_ms)
else (
int(os.environ["VOXTRAL_VLLM_TARGET_STREAMING_DELAY_MS"])
if os.environ.get("VOXTRAL_VLLM_TARGET_STREAMING_DELAY_MS")
else None
)
)
if vad is None:
try:
from livekit.plugins.silero import VAD as SileroVAD # noqa: PLC0415
except ImportError as exc:
raise ImportError(
"livekit-plugins-silero is required for vLLM Voxtral realtime "
"(no server-side endpointing)."
) from exc
vad = SileroVAD.load()
self._vad = vad
self._opts = _STTOptions(
base_url=resolved_url,
model=resolved_model,
api_key=resolved_key,
target_streaming_delay_ms=resolved_delay,
)
self._streams: weakref.WeakSet[SpeechStream] = weakref.WeakSet()
@property
def model(self) -> str:
"""Return the configured vLLM model name."""
return self._opts.model
@property
def provider(self) -> str:
"""Return the provider identifier."""
return "vllm-voxtral-realtime"
async def _recognize_impl(self, *_args, **_kwargs) -> stt.SpeechEvent:
raise NotImplementedError(
"vLLM Voxtral Realtime STT only supports streaming recognition."
)
def stream(
self,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> SpeechStream:
"""Open a new streaming recognition stream."""
s = SpeechStream(
stt=self,
opts=self._opts,
vad_instance=self._vad,
conn_options=conn_options,
)
self._streams.add(s)
return s
class SpeechStream(stt.RecognizeStream):
"""Voxtral realtime handler."""
def __init__(
self,
*,
stt: STT,
opts: _STTOptions,
vad_instance: vad_module.VAD,
conn_options: APIConnectOptions,
) -> None:
"""Init the speech stream."""
super().__init__(stt=stt, conn_options=conn_options, sample_rate=SAMPLE_RATE)
self._opts = opts
self._vad = vad_instance
self._utterance_q: asyncio.Queue[bytes | None] | None = None
self._speaking = False
self._preroll: deque[bytes] = deque(maxlen=PREROLL_CHUNKS)
# Voxtral realtime is strictly sequential: only one generation runs at a
# time, and a new `commit` is ignored while the previous one is still
# producing. We queue per-utterance audio buffers here and let the
# pipeline process them one by one on the shared websocket.
self._utterance_chan: asyncio.Queue[asyncio.Queue[bytes | None] | None] = (
asyncio.Queue()
)
@utils.log_exceptions(logger=logger)
async def _run(self) -> None:
vad_stream = self._vad.stream()
bstream = utils.audio.AudioByteStream(
sample_rate=SAMPLE_RATE,
num_channels=NUM_CHANNELS,
samples_per_channel=CHUNK_SAMPLES,
)
async def input_task() -> None:
async for data in self._input_ch:
if isinstance(data, self._FlushSentinel):
for frame in bstream.flush():
self._handle_chunk(frame.data.tobytes())
continue
vad_stream.push_frame(data)
for frame in bstream.write(data.data.tobytes()):
self._handle_chunk(frame.data.tobytes())
vad_stream.end_input()
async def vad_task() -> None:
async for ev in vad_stream:
if ev.type == vad_module.VADEventType.START_OF_SPEECH:
self._on_start_of_speech()
elif ev.type == vad_module.VADEventType.END_OF_SPEECH:
self._on_end_of_speech()
pipeline_t = asyncio.create_task(self._utterance_pipeline())
try:
await asyncio.gather(input_task(), vad_task())
# signal end-of-stream; pipeline finishes pending utterances first
self._utterance_chan.put_nowait(None)
await pipeline_t
except (APIStatusError, APIConnectionError, asyncio.CancelledError):
raise
except Exception as exc:
logger.exception("vLLM realtime stream failed")
raise APIConnectionError() from exc
finally:
if not pipeline_t.done():
pipeline_t.cancel()
try:
await pipeline_t
except asyncio.CancelledError:
# CancelledError is the expected flow on cancel()
pass
except Exception:
logger.exception("utterance pipeline failed during finalize")
await vad_stream.aclose()
def _handle_chunk(self, chunk: bytes) -> None:
self._preroll.append(chunk)
if self._speaking and self._utterance_q is not None:
self._utterance_q.put_nowait(chunk)
def _on_start_of_speech(self) -> None:
if self._speaking:
return
self._speaking = True
q: asyncio.Queue[bytes | None] = asyncio.Queue()
for chunk in self._preroll:
q.put_nowait(chunk)
self._utterance_q = q
self._utterance_chan.put_nowait(q)
self._event_ch.send_nowait(
stt.SpeechEvent(type=stt.SpeechEventType.START_OF_SPEECH)
)
def _on_end_of_speech(self) -> None:
if not self._speaking:
return
self._speaking = False
if self._utterance_q is not None:
self._utterance_q.put_nowait(None)
self._utterance_q = None
self._event_ch.send_nowait(
stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH)
)
async def _handshake(self, ws: websockets.ClientConnection) -> str:
created = json.loads(await ws.recv())
if created.get("type") != "session.created":
raise APIStatusError(
f"expected session.created, got {created}",
status_code=500,
body=created,
)
session_update: dict = {"type": "session.update", "model": self._opts.model}
if self._opts.target_streaming_delay_ms is not None:
session_update["target_streaming_delay_ms"] = (
self._opts.target_streaming_delay_ms
)
await ws.send(json.dumps(session_update))
return created.get("id", "")
def _auth_headers(self) -> dict[str, str]:
if self._opts.api_key:
return {"Authorization": f"Bearer {self._opts.api_key}"}
return {}
async def _utterance_pipeline(self) -> None:
# Owns the websocket lifecycle. On drop, reopens and resumes the
# in-flight utterance (if any) by replaying its already-sent chunks.
pending: _PendingUtterance | None = None
attempt = 0
while True:
try:
async with websockets.connect(
self._opts.base_url,
additional_headers=self._auth_headers(),
open_timeout=self._conn_options.timeout,
) as ws:
request_id = await self._handshake(ws)
attempt = 0
while True:
if pending is None:
q = await self._utterance_chan.get()
if q is None:
return
pending = _PendingUtterance(queue=q)
await self._process_utterance(ws, pending, request_id)
pending = None
except (websockets.WebSocketException, OSError, TimeoutError) as exc:
attempt += 1
if attempt > RECONNECT_MAX_ATTEMPTS:
logger.exception(
"vLLM realtime: giving up after %d reconnect attempts",
RECONNECT_MAX_ATTEMPTS,
)
raise APIConnectionError() from exc
backoff = min(
RECONNECT_BACKOFF_BASE_S * (2 ** (attempt - 1)),
RECONNECT_BACKOFF_MAX_S,
)
if pending is None:
logger.warning(
"vLLM WS connection lost between utterances "
"(attempt %d/%d): %s; retrying in %.1fs",
attempt,
RECONNECT_MAX_ATTEMPTS,
exc,
backoff,
)
else:
logger.warning(
"vLLM WS dropped mid-utterance (%d chunks buffered, "
"ended=%s, attempt %d/%d): %s; retrying in %.1fs",
len(pending.sent_chunks),
pending.ended,
attempt,
RECONNECT_MAX_ATTEMPTS,
exc,
backoff,
)
await asyncio.sleep(backoff)
async def _process_utterance(
self,
ws: websockets.ClientConnection,
pending: _PendingUtterance,
request_id: str,
) -> None:
# Start a fresh generation. Safe to send here: the previous utterance's
# transcription.done has already been received (we await it below), so
# the server-side generation_task is done and won't ignore this commit.
await ws.send(json.dumps({"type": "input_audio_buffer.commit"}))
send_t = asyncio.create_task(self._send_audio(ws, pending))
try:
await self._receive_one_transcription(ws, request_id)
finally:
if not send_t.done():
send_t.cancel()
try:
await send_t
except (asyncio.CancelledError, websockets.WebSocketException):
pass
except Exception:
logger.exception("send-audio task failed during finalize")
@staticmethod
async def _send_audio(
ws: websockets.ClientConnection, pending: _PendingUtterance
) -> None:
# Replay anything already sent on a previous (now-dead) connection.
# sent_chunks is appended before send, so a chunk that failed to send
# last time is still present and gets retried here.
for chunk in pending.sent_chunks:
await ws.send(
json.dumps(
{
"type": "input_audio_buffer.append",
"audio": base64.b64encode(chunk).decode("ascii"),
}
)
)
if pending.ended:
await ws.send(
json.dumps({"type": "input_audio_buffer.commit", "final": True})
)
return
while True:
chunk = await pending.queue.get()
if chunk is None:
pending.ended = True
await ws.send(
json.dumps({"type": "input_audio_buffer.commit", "final": True})
)
return
pending.sent_chunks.append(chunk)
await ws.send(
json.dumps(
{
"type": "input_audio_buffer.append",
"audio": base64.b64encode(chunk).decode("ascii"),
}
)
)
async def _receive_one_transcription(
self, ws: websockets.ClientConnection, request_id: str
) -> None:
# Use recv() rather than `async for`: the latter swallows
# ConnectionClosed on close-mid-iteration, which would let a dropped
# WS look like a clean "no transcription" return.
current_text = ""
while True:
raw = await ws.recv()
data = json.loads(raw)
event_type = data.get("type")
if event_type == "transcription.delta":
delta = data.get("delta", "")
if not delta:
continue
current_text += delta
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.INTERIM_TRANSCRIPT,
request_id=request_id,
alternatives=[stt.SpeechData(text=current_text, language="")],
)
)
elif event_type == "transcription.done":
final_text = data.get("text") or current_text
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
request_id=request_id,
alternatives=[stt.SpeechData(text=final_text, language="")],
)
)
usage = data.get("usage") or {}
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.RECOGNITION_USAGE,
request_id=request_id,
recognition_usage=stt.RecognitionUsage(
audio_duration=float(
usage.get("audio_seconds")
or usage.get("prompt_audio_seconds")
or 0
),
input_tokens=int(usage.get("prompt_tokens") or 0),
output_tokens=int(usage.get("completion_tokens") or 0),
),
)
)
return
elif event_type == "error":
err = data.get("error")
raise APIStatusError(str(err), status_code=500, body=data)
-3
View File
@@ -8,6 +8,3 @@ class AnalyticsEvent(StrEnum):
# Rooms
ROOM_CREATED = "room_created"
# Roomkit (meeting-room SIP devices)
ROOMKIT_JOINED = "roomkit_joined"
-4
View File
@@ -61,11 +61,7 @@ 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,
+2 -2
View File
@@ -11,12 +11,12 @@ class FeatureFlag:
FLAGS = {
"recording": "RECORDING_ENABLE",
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
"subtitle": "ROOM_SUBTITLE_ENABLED",
"file_upload": "FILE_UPLOAD_ENABLED",
"addons": "ADDONS_ENABLED",
"application": "APPLICATION_ENABLED",
"roomkit": "ROOMKIT_ENABLED",
"connection_test": "CONNECTION_TEST_ENABLED",
"user_access_token": "USER_ACCESS_TOKEN_ENABLED",
}
@classmethod
+1 -46
View File
@@ -5,7 +5,7 @@ from django.http import Http404
from rest_framework import permissions
from ..models import RoleChoices, RoomAccessLevel
from ..models import RoleChoices
from ..services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagement,
@@ -198,48 +198,3 @@ class IsPresentInMeeting(permissions.BasePermission):
return False
except ParticipantsManagementException:
return False
class CanManageLobby(permissions.BasePermission):
"""Grant lobby management (list/accept/deny waiting participants).
- Room admins/owners can always manage the lobby.
- When the room access level is TRUSTED, any authenticated user who is
currently connected to the meeting can manage the lobby. Presence is
verified cache-first (Redis), falling back to the LiveKit API.
Access level is always read fresh from the DB; only presence is cached,
so changing the room to RESTRICTED takes effect immediately.
"""
message = "You are not allowed to manage this room's lobby."
# pylint: disable=too-many-return-statements
def has_object_permission(self, request, view, obj): # noqa: PLR0911
"""Check privileges first, then the trusted-room presence path."""
user = request.user
if not user or not user.is_authenticated:
return False
# Product choice: lobby management is reserved for session-authenticated
# users with a real account, not holders of a LiveKit room token.
if request.auth and hasattr(request.auth, "video"):
return False
if obj.is_administrator_or_owner(user):
return True
if obj.access_level != RoomAccessLevel.TRUSTED:
return False
self.message = "You must be connected to the meeting to manage its lobby."
try:
return ParticipantsManagement().check_if_in_meeting_cached(
room_name=str(obj.pk), identity=str(user.sub)
)
except ParticipantNotFoundException:
return False
except ParticipantsManagementException:
return False
+28 -20
View File
@@ -31,28 +31,9 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = [
"id",
"email",
"full_name",
"short_name",
"timezone",
"language",
"default_room_access_level",
"default_room_configuration",
]
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
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."""
@@ -292,6 +273,11 @@ class RequestEntrySerializer(BaseValidationOnlySerializer):
"""Validate request entry data."""
username = serializers.CharField(required=True)
participant_id = serializers.UUIDField(required=False, allow_null=True)
def validate_participant_id(self, value):
"""The id is a bearer credential: never trusted, only looked up."""
return str(value) if value else None
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
@@ -599,3 +585,25 @@ class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
# 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)
class TransitCodeSerializer(BaseValidationOnlySerializer):
"""Validate the single-use transit code sent to the exchange endpoint."""
# todo if I can pass the max length directly to the char field
code = serializers.CharField(max_length=255, trim_whitespace=True)
def validate_code(self, value):
"""Reject codes whose length cannot match a generated one.
`secrets.token_urlsafe(nbytes)` produces (4 * nbytes + 2) // 3
url-safe characters. Checking the length against the configured
TRANSIT_CODE_NBYTES makes malformed codes fail fast with a 400,
before any cache lookup.
"""
expected_length = (4 * settings.TRANSIT_CODE_NBYTES + 2) // 3
if len(value) != expected_length:
raise serializers.ValidationError("Invalid transit code format.")
return value
+22 -30
View File
@@ -1,11 +1,11 @@
"""Throttling modules for the API."""
from django.conf import settings
from lasuite.drf.throttling import MonitoredThrottleMixin
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
from sentry_sdk import capture_message
from . import serializers
def sentry_monitoring_throttle_failure(message):
"""Log when a failure occurs to detect rate limiting issues."""
@@ -42,13 +42,14 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
def get_cache_key(self, request, view):
"""Use the lobby participant cookie ID as the throttle cache key.
Only throttle if a cookie is already set. If no cookie exists yet,
return None to skip throttling the cookie will be set on the first
response, and throttling will apply from the second request onward.
Only throttle requests carrying a participant identifier. The
identifier is returned by the first request-entry response and
echoed back by the client from the second request onward, which is
when throttling starts applying.
Keying on the cookie rather than the IP address prevents penalising
multiple users behind the same NAT/proxy, and is consistent with how
LobbyService identifies participants.
Keying on the identifier rather than the IP address prevents
penalising multiple users behind the same NAT/proxy, and is
consistent with how the lobby identifies participants.
Note: as per DRF documentation, application-level throttling is not a
security measure against brute-force or DoS attacks. This throttle exists
@@ -58,10 +59,14 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
if request.user and request.user.is_authenticated:
return None # Only throttle unauthenticated requests.
participant_id = request.COOKIES.get(settings.LOBBY_COOKIE_NAME)
serializer = serializers.RequestEntrySerializer(data=request.data)
if not serializer.is_valid():
return None
if participant_id is None:
return None # No throttling for cookieless requests
participant_id = serializer.validated_data.get("participant_id")
if not participant_id:
return None # No throttling for unidentified requests
return self.cache_format % {
"scope": self.scope,
@@ -75,25 +80,12 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
scope = "creation_callback"
class RoomKitJoinRateThrottle(MonitoredUserRateThrottle):
"""Throttle the LiveKit SIP module requesting roomkit joins.
class ExchangeAccessTokenAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle anonymous transit code exchange attempts.
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.
Abuse mitigation only, not a security boundary: DRF throttling is
best-effort. The security of the exchange rests on the codes'
entropy and single use.
"""
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"
scope = "exchange_access_token"
+142 -101
View File
@@ -2,10 +2,8 @@
# 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
@@ -29,9 +27,6 @@ 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,
)
@@ -41,15 +36,28 @@ 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 RecordingProcessWebhookAuthentication
from core.recording.event.authentication import (
RecordingProcessWebhookAuthentication,
StorageEventAuthentication,
)
from core.recording.event.exceptions import (
InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError,
ParsingEventDataError,
)
from core.recording.event.parsers import get_parser
from core.recording.services.metadata_collector import (
MetadataCollectorException,
MetadataCollectorService,
)
from core.recording.services.recording_events import (
RecordingEventsService,
RecordingNotSavableError,
)
from core.recording.worker.exceptions import (
RecordingStartError,
RecordingStopError,
@@ -61,6 +69,7 @@ from core.recording.worker.mediator import (
WorkerServiceMediator,
)
from core.services.invitation import InvitationService
from core.services.jwt_token import JwtTokenService
from core.services.livekit_events import (
LiveKitEventsService,
LiveKitWebhookError,
@@ -85,9 +94,8 @@ from core.services.room_roles import (
RoomRoleService,
)
from core.services.subtitle import SubtitleException, SubtitleService
from core.tasks.connection_test import delete_connection_test_room
from core.services.transit_code import TransitCodeService
from core.tasks.file import process_file_deletion
from core.utils import generate_token
from ..authentication.livekit import LiveKitTokenAuthentication
from ..models import RoomAccessLevel
@@ -223,6 +231,76 @@ class UserViewSet(
self.serializer_class(request.user, context=context).data
)
@decorators.action(
detail=False,
methods=["post"],
url_path="exchange-access-token",
permission_classes=[],
throttle_classes=[throttling.ExchangeAccessTokenAnonRateThrottle],
)
@FeatureFlag.require("user_access_token")
def exchange_access_token(self, request):
"""Exchange a single-use transit code for a user access token.
The endpoint is unauthenticated: the transit code itself, an opaque
random string obtained through the external API and delivered to
the embedded frontend via a URL fragment, is the credential. Each
code can be exchanged exactly once (consuming it deletes it from
the cache); replaying a consumed code is denied and logged.
The issued JWT authenticates the user the code was minted for on
the whole core API, exactly like a session cookie would (similar
to lib-jitsi-meet's token authentication), and never appears in
any URL. Role-based permissions apply unchanged.
"""
serializer = serializers.TransitCodeSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
code_data = TransitCodeService().consume_code(serializer.validated_data["code"])
if code_data is None:
logger.warning("Invalid, expired or already used transit code")
raise drf_exceptions.PermissionDenied(
"Invalid, expired or already used transit code."
)
# Re-check the user at exchange time so that a deactivation after
# the transit code was minted is taken into account.
try:
user = models.User.objects.get(id=code_data["user_id"], is_active=True)
except models.User.DoesNotExist as excpt:
raise drf_exceptions.PermissionDenied(
"This account can no longer access the application."
) from excpt
token_service = JwtTokenService(
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithm=settings.USER_ACCESS_TOKEN_ALG,
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
token_type=settings.USER_ACCESS_TOKEN_TYPE,
)
# todo - discuss wether it's the relevant scope
data = token_service.generate_jwt(
user,
"user:access",
{
"token_type": "user_access",
"client_id": code_data.get("client_id", "unknown"),
},
)
# Log for auditing
logger.info(
"User access token issued from transit code: user_id=%s, client_id=%s",
user.id,
code_data.get("client_id", "unknown"),
)
return drf_response.Response(data)
class RoomViewSet(
mixins.CreateModelMixin,
@@ -302,27 +380,8 @@ class RoomViewSet(
return drf_response.Response(serializer.data)
def perform_create(self, serializer):
"""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)
"""Set the current user as owner of the newly created room."""
room = serializer.save()
models.ResourceAccess.objects.create(
resource=room,
user=self.request.user,
@@ -512,17 +571,14 @@ class RoomViewSet(
request=request,
**serializer.validated_data,
)
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
lobby_service.prepare_response(response, participant.id)
return response
return drf_response.Response({**participant.to_dict(), "livekit": livekit})
@decorators.action(
detail=True,
methods=["post"],
url_path="enter",
permission_classes=[
permissions.CanManageLobby,
permissions.HasPrivilegesOnRoom,
],
)
def allow_participant_to_enter(self, request, pk=None): # pylint: disable=unused-argument
@@ -560,7 +616,7 @@ class RoomViewSet(
methods=["GET"],
url_path="waiting-participants",
permission_classes=[
permissions.CanManageLobby,
permissions.HasPrivilegesOnRoom,
],
)
def list_waiting_participants(self, request, pk=None): # pylint: disable=unused-argument
@@ -1020,6 +1076,56 @@ class RecordingViewSet(
.filter(Q(accesses__user=user) | Q(accesses__team__in=user.get_teams()))
)
@decorators.action(
detail=False,
methods=["post"],
url_path="storage-hook",
authentication_classes=[StorageEventAuthentication],
)
@FeatureFlag.require("storage_event")
def on_storage_event_received(self, request, pk=None): # pylint: disable=unused-argument
"""Handle incoming storage hook events for recordings."""
parser = get_parser()
try:
recording_id = parser.get_recording_id(request.data)
except ParsingEventDataError as e:
raise drf_exceptions.PermissionDenied("Invalid request data.") from e
except InvalidBucketError as e:
raise drf_exceptions.PermissionDenied("Invalid bucket specified.") from e
except InvalidFilepathError:
return drf_response.Response(
{"message": "Notification ignored."},
)
except InvalidFileTypeError:
return drf_response.Response(
{"message": "Notification ignored."},
)
try:
recording = models.Recording.objects.get(id=recording_id)
except models.Recording.DoesNotExist as e:
raise drf_exceptions.NotFound("No recording found for this event.") from e
# Save recording
recording_events_service = RecordingEventsService()
try:
recording_events_service.handle_complete(recording)
except RecordingNotSavableError:
raise drf_exceptions.PermissionDenied(
f"Recording with ID {recording_id} cannot be saved because it is either,"
" in an error state or has already been saved."
) from None
return drf_response.Response(
{"message": "Event processed."},
)
@decorators.action(
detail=False,
methods=["post"],
@@ -1507,68 +1613,3 @@ 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,
},
}
)
+9 -2
View File
@@ -9,6 +9,8 @@ from rest_framework import authentication, exceptions
UserModel = get_user_model()
LIVEKIT_AUTH_SCHEME = "X-LiveKit-Token"
class LiveKitTokenAuthentication(authentication.BaseAuthentication):
"""Authenticate using LiveKit token and load the associated Django user."""
@@ -20,9 +22,14 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
return None # No authentication attempted
parts = auth_header.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
if not parts or parts[0].lower() != LIVEKIT_AUTH_SCHEME.lower():
# Not our scheme (e.g. "Bearer <user access token>"): defer, another
# backend may recognize it.
return None
if len(parts) != 2:
raise exceptions.AuthenticationFailed(
"Authorization header must be: Bearer <token>"
f"Authorization header must be: {LIVEKIT_AUTH_SCHEME} <token>"
)
token = parts[1]
@@ -0,0 +1,71 @@
"""User access JWT authentication for the Meet core API.
Allows an embedded frontend (e.g. rendered in an iframe, where third-party
session cookies are blocked) to authenticate requests on the core API with
a JWT, obtained by exchanging a single-use transit code (see
core.services.transit_code and the users exchange-access-token endpoint)
and passed as a Bearer header. The JWT itself never appears in any URL.
Similar to lib-jitsi-meet's token authentication, the token is bound to a
user, not to a resource: once authenticated, the request is treated
exactly like a session-authenticated one, and the existing role-based
permissions apply unchanged.
"""
import logging
from django.conf import settings
from rest_framework import exceptions
from core.external_api.authentication import BaseJWTAuthentication
logger = logging.getLogger(__name__)
USER_ACCESS_TOKEN_TYPE_CLAIM = "user_access" # noqa: S105
class UserAccessJWTAuthentication(BaseJWTAuthentication):
"""JWT authentication for user access tokens.
Validates user access tokens issued by the users exchange-access-token
endpoint and authenticates the user they were issued for. A bearer
token that does not verify against the user access token secret is
deferred to the next authentication backend; a token that does verify
but carries wrong claims is rejected.
When the feature is disabled (USER_ACCESS_TOKEN_ENABLED=False), the
backend is entirely inert: `BaseJWTAuthentication.authenticate`
returns None before reading the Authorization header, deferring every
request to the next authentication backend.
"""
def __init__(self):
"""Initialize the backend with user access token settings."""
super().__init__(
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithm=settings.USER_ACCESS_TOKEN_ALG,
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
token_type=settings.USER_ACCESS_TOKEN_TYPE,
is_enabled=settings.USER_ACCESS_TOKEN_ENABLED,
)
def validate_payload(self, payload):
"""Validate the token type and the issuance-audit claim.
Raises:
AuthenticationFailed: If the token verified against the user
access token secret but does not carry the expected claims.
"""
if payload.get("token_type") != USER_ACCESS_TOKEN_TYPE_CLAIM:
logger.warning("Wrong 'token_type' in user access token payload")
raise exceptions.AuthenticationFailed("Invalid token type.")
# Every token we issue carries the client_id of the application the
# transit code was minted for: its absence means the token does not
# come from the exchange endpoint.
if not payload.get("client_id"):
logger.warning("Missing 'client_id' in user access token payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
class BaseJWTAuthentication(authentication.BaseAuthentication):
"""Base JWT authentication class."""
def __init__( # noqa: PLR0917
def __init__(
self,
secret_key,
algorithm,
@@ -86,6 +86,14 @@ class HasRequiredRoomScope(BaseScopePermission):
}
class HasRequiredUserScope(BaseScopePermission):
"""Scope-based permissions for the external user endpoints."""
scope_map = {
"generate_transit_code": models.ApplicationScope.USERS_SESSION,
}
class RoomPermissions(permissions.BasePermission):
"""Permissions applying to the room API endpoint."""
+60 -4
View File
@@ -12,9 +12,6 @@ from rest_framework import decorators, mixins, viewsets
from rest_framework import (
exceptions as drf_exceptions,
)
from rest_framework import (
parsers as drf_parsers,
)
from rest_framework import (
response as drf_response,
)
@@ -25,6 +22,7 @@ from rest_framework import (
from core import analytics, api, models
from core.api.feature_flag import FeatureFlag
from core.services.jwt_token import JwtTokenService
from core.services.transit_code import TransitCodeService
from ..services.provisional_user_service import (
ProvisionalUserCreationDisabledError,
@@ -44,7 +42,6 @@ class ApplicationViewSet(viewsets.ViewSet):
methods=["post"],
url_path="token",
url_name="token",
parser_classes=[drf_parsers.FormParser, drf_parsers.JSONParser],
)
@FeatureFlag.require("application")
def generate_jwt_access_token(self, request, *args, **kwargs):
@@ -222,3 +219,62 @@ class RoomViewSet(
"$set": {"email": self.request.user.email},
},
)
class UserViewSet(viewsets.GenericViewSet):
"""Application-delegated API for user operations.
Provides JWT-authenticated access to user operations for external
applications acting on behalf of users. All operations are
scope-based. Meant to grow with the other user actions exposed to
third parties.
Supported operations:
- transit-code: Mint a single-use transit code for the delegated user
(requires 'users:session' scope)
"""
authentication_classes = [
authentication.ApplicationJWTAuthentication,
ResourceServerAuthentication,
]
permission_classes = [
api.permissions.IsAuthenticated & permissions.HasRequiredUserScope
]
@decorators.action(
detail=False,
methods=["post"],
url_path="transit-code",
url_name="transit-code",
)
@FeatureFlag.require("user_access_token")
def generate_transit_code(self, request):
"""Mint a transit code for the delegated user.
Returns a short-lived, single-use opaque code to pass to an embedded
frontend (e.g. via a URL fragment when cookies are unavailable). The
frontend exchanges it once on
POST /api/v1.0/users/exchange-access-token/ for a JWT access token,
equivalent to session-cookie authentication and never exposed in a URL.
"""
auth_method = type(request.successful_authenticator).__name__
client_id = (request.auth or {}).get("client_id", "unknown")
code = TransitCodeService().create_code(request.user, client_id=client_id)
# Log for auditing
logger.info(
"Transit code issued: user_id=%s, client_id=%s, auth_method=%s",
request.user.id,
client_id,
auth_method,
)
return drf_response.Response(
{
"transit_code": code,
"expires_in": settings.TRANSIT_CODE_TTL,
},
status=drf_status.HTTP_200_OK,
)
@@ -0,0 +1,19 @@
# Generated by Django 5.2.14 on 2026-07-31 18:27
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0021_recording_external_process_id_alter_recording_status'),
]
operations = [
migrations.AlterField(
model_name='application',
name='scopes',
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rooms:create', 'Create rooms'), ('rooms:list', 'List rooms'), ('rooms:retrieve', 'Retrieve room details'), ('rooms:update', 'Update rooms'), ('rooms:delete', 'Delete rooms'), ('users:session', 'Create user session tokens')], max_length=50), blank=True, default=list, size=None),
),
]
@@ -1,23 +0,0 @@
# 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'),
),
]
+2 -27
View File
@@ -189,25 +189,6 @@ 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,
@@ -448,14 +429,7 @@ class Room(Resource):
def save(self, *args, **kwargs):
"""Generate a unique n-digit pin code for new rooms."""
# 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
):
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
self.pin_code = self.generate_unique_pin_code(
length=settings.ROOM_TELEPHONY_PIN_LENGTH
)
@@ -795,6 +769,7 @@ class ApplicationScope(models.TextChoices):
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
ROOMS_UPDATE = "rooms:update", _("Update rooms")
ROOMS_DELETE = "rooms:delete", _("Delete rooms")
USERS_SESSION = "users:session", _("Create user session tokens")
class Application(BaseModel):
@@ -1,4 +1,4 @@
"""Authentication classes for server-to-server webhook token validation."""
"""Authentication class for storage event token validation."""
import logging
import secrets
@@ -12,9 +12,9 @@ logger = logging.getLogger(__name__)
class MachineUser:
"""Represent a non-interactive system user for automated operations."""
"""Represent a non-interactive system user for automated storage operations."""
def __init__(self, username: str = "machine_user") -> None:
def __init__(self, username: str = "storage_event_user") -> None:
self.pk = None
self.username = username
self.is_active = True
@@ -41,17 +41,24 @@ class HeaderBasedAuthentication(BaseAuthentication):
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 self.IS_ENFORCED_SETTINGS_KEY is not None:
if not getattr(settings, self.IS_ENFORCED_SETTINGS_KEY):
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 token is not configured.")
raise AuthenticationFailed(
"Authentication is enabled but token is not configured."
)
auth_header = request.headers.get(self.AUTH_HEADER)
if not auth_header:
@@ -81,6 +88,18 @@ class HeaderBasedAuthentication(BaseAuthentication):
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.
@@ -0,0 +1,17 @@
"""Storage parsers specific exceptions."""
class ParsingEventDataError(Exception):
"""Raised when the request data is malformed, incomplete, or missing."""
class InvalidBucketError(Exception):
"""Raised when the bucket name in the request does not match the expected one."""
class InvalidFileTypeError(Exception):
"""Raised when the file type in the request is not supported."""
class InvalidFilepathError(Exception):
"""Raised when the filepath in the request is invalid."""
@@ -9,7 +9,7 @@ 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, gettext, override
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
import aiohttp
@@ -121,7 +121,7 @@ class NotificationService:
msg_plain = render_to_string(
"mail/text/screen_recording.txt", personalized_context
)
subject = gettext("Your recording is ready") # Force translation
subject = str(_("Your recording is ready")) # Force translation
try:
send_mail(
@@ -192,7 +192,7 @@ class NotificationService:
"""Generate title from context or return default."""
if recording_datetime is None:
with override(locale):
return gettext("Transcription")
return _("Transcription")
dt = recording_datetime
if owner_timezone:
+178
View File
@@ -0,0 +1,178 @@
"""Meet storage event parser classes."""
import logging
import mimetypes
import re
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Dict, Optional, Protocol
from urllib.parse import quote
from django.conf import settings
from django.utils.module_loading import import_string
from core.enums import FILE_EXT_REGEX, UUID_REGEX
from .exceptions import (
InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError,
ParsingEventDataError,
)
# Additional MIME type mapping
mimetypes.add_type("audio/ogg", ".ogg")
logger = logging.getLogger(__name__)
@dataclass
class StorageEvent:
"""Represents a storage event with relevant metadata.
Attributes:
filepath: Identifier for the affected recording
filetype: Type of storage event
bucket_name: When the event occurred
metadata: Additional event data
"""
filepath: str
filetype: str
bucket_name: str
metadata: Optional[Dict[str, Any]]
def __post_init__(self):
if self.filepath is None:
raise TypeError("filepath cannot be None")
if self.filetype is None:
raise TypeError("filetype cannot be None")
if self.bucket_name is None:
raise TypeError("bucket_name cannot be None")
class EventParser(Protocol):
"""Interface for parsing storage events."""
def __init__(self, bucket_name, allowed_filetypes=None):
"""Initialize parser with bucket name and optional allowed filetypes."""
def parse(self, data: Dict) -> StorageEvent:
"""Extract storage event data from raw dictionary input."""
def validate(self, data: StorageEvent) -> str:
"""Verify storage event data meets all requirements."""
def get_recording_id(self, data: Dict) -> str:
"""Extract recording ID from event dictionary."""
@lru_cache(maxsize=1)
def get_parser() -> EventParser:
"""Return cached instance of configured event parser.
Uses function memoization instead of a factory class since the only
varying parameter is the parser class from settings. A factory class
would add unnecessary complexity when a cached function provides the
same singleton behavior with simpler code.
"""
event_parser_cls = import_string(settings.RECORDING_EVENT_PARSER_CLASS)
return event_parser_cls(bucket_name=settings.AWS_STORAGE_BUCKET_NAME)
class BaseS3Parser:
"""Base class for handling parsing and validation of S3-compatible storage events."""
def __init__(self, bucket_name: str, allowed_filetypes=None):
"""Initialize parser with target bucket name and accepted filetypes."""
if not bucket_name:
raise ValueError("Bucket name cannot be None or empty")
self._bucket_name = bucket_name
self._allowed_filetypes = allowed_filetypes or {"audio/ogg", "video/mp4"}
# pylint: disable=line-too-long
self._filepath_regex = re.compile(
rf"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?{settings.RECORDING_OUTPUT_FOLDER}%2F(?P<recording_id>{UUID_REGEX})\.(?P<extension>{FILE_EXT_REGEX})"
)
def validate(self, event_data: StorageEvent) -> str:
"""Verify StorageEvent matches bucket, filetype and filepath requirements."""
if event_data.bucket_name != self._bucket_name:
raise InvalidBucketError(
f"Invalid bucket: expected {self._bucket_name}, got {event_data.bucket_name}"
)
if event_data.filetype not in self._allowed_filetypes:
raise InvalidFileTypeError(
f"Invalid file type, expected {self._allowed_filetypes},"
f"got '{event_data.filetype}'"
)
match = self._filepath_regex.match(event_data.filepath)
if not match:
raise InvalidFilepathError(
f"Invalid filepath structure: {event_data.filepath}"
)
recording_id = match.group("recording_id")
return recording_id
def get_recording_id(self, data):
"""Extract recording ID from S3 event through parsing and validation."""
event_data = self.parse(data)
return self.validate(event_data)
def parse(self, data: Dict) -> StorageEvent:
"""To be implemented by subclasses."""
raise NotImplementedError("Subclasses must implement parse()")
class MinioParser(BaseS3Parser):
"""Minio specific event parsing."""
def parse(self, data: Dict) -> StorageEvent:
if not data:
raise ParsingEventDataError("Received empty data.")
try:
record = data["Records"][0]
s3 = record["s3"]
return StorageEvent(
filepath=s3["object"]["key"],
filetype=s3["object"]["contentType"], # Minio-specific field
bucket_name=s3["bucket"]["name"],
metadata=None,
)
except (KeyError, IndexError) as e:
raise ParsingEventDataError(f"Malformed Minio event: {e}") from e
except TypeError as e:
raise ParsingEventDataError(f"Missing essential data fields: {e}") from e
class S3Parser(BaseS3Parser):
"""AWS S3 specific event parsing."""
def parse(self, data: Dict) -> StorageEvent:
if not data:
raise ParsingEventDataError("Received empty data.")
try:
# AWS S3 structure can slightly differ from Minio implementation
record = data["Records"][0]
s3 = record["s3"]
filepath = s3["object"]["key"]
if not filepath:
raise ParsingEventDataError("Missing object key name")
filetype, _ = mimetypes.guess_type(filepath)
# Normalize raw S3-compatible object keys without re-encoding
# already encoded AWS S3 notification keys.
filepath = quote(filepath, safe="%+")
return StorageEvent(
filepath=filepath,
filetype=filetype,
bucket_name=s3["bucket"]["name"],
metadata=None,
)
except (KeyError, IndexError) as e:
raise ParsingEventDataError(f"Malformed S3 event: {e}") from e
-1
View File
@@ -1 +0,0 @@
"""Meet core roomkit API endpoints for meeting-room (SIP) device integration."""
@@ -1,65 +0,0 @@
"""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
@@ -1,21 +0,0 @@
"""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
@@ -1,89 +0,0 @@
"""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__( # noqa: PLR0917
def __init__(
self,
secret_key: str,
algorithm: str,
+14 -49
View File
@@ -23,13 +23,12 @@ from core.recording.services.recording_events import (
)
from .lobby import LobbyService
from .presence import PresenceCache
from .room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from .sip_management import SIPException, SIPManagement
from .telephony import TelephonyException, TelephonyService
logger = getLogger(__name__)
@@ -100,7 +99,6 @@ class LiveKitEventsService:
"egress_ended": self._handle_egress_ended,
"room_started": self._handle_room_started,
"room_finished": self._handle_room_finished,
"participant_left": self._handle_participant_left,
}
token_verifier = api.TokenVerifier(
@@ -109,8 +107,7 @@ class LiveKitEventsService:
)
self.webhook_receiver = api.WebhookReceiver(token_verifier)
self.lobby_service = LobbyService()
self.presence_cache = PresenceCache()
self.sip_management = SIPManagement()
self.telephony_service = TelephonyService()
self.recording_events = RecordingEventsService()
self._filter_regex = None
@@ -140,13 +137,6 @@ 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
@@ -220,8 +210,10 @@ class LiveKitEventsService:
f"Failed to process limit reached event for recording {recording}"
) from e
# Finalize the recording, the egress has uploaded the file to the storage
if data.egress_info.status in [
# Fallback for completion when no MinIO/S3 webhooks are configured
if (
not settings.RECORDING_STORAGE_EVENT_ENABLE
) and data.egress_info.status in [
api.EgressStatus.EGRESS_COMPLETE,
api.EgressStatus.EGRESS_LIMIT_REACHED,
]:
@@ -236,11 +228,6 @@ 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."""
@@ -258,12 +245,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 or settings.ROOMKIT_ENABLED:
if settings.ROOM_TELEPHONY_ENABLED:
try:
self.sip_management.ensure_dispatch_rule(room)
except SIPException as e:
self.telephony_service.create_dispatch_rule(room)
except TelephonyException as e:
raise ActionFailedError(
f"Failed to create sip dispatch rule for room {room_id}"
f"Failed to create telephony dispatch rule for room {room_id}"
) from e
def _handle_room_finished(self, data):
@@ -278,39 +265,17 @@ class LiveKitEventsService:
)
raise ActionFailedError("Failed to process room finished event") from e
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
if settings.ROOM_TELEPHONY_ENABLED:
try:
self.sip_management.delete_dispatch_rule(room_id)
except SIPException as e:
self.telephony_service.delete_dispatch_rule(room_id)
except TelephonyException as e:
raise ActionFailedError(
f"Failed to delete sip dispatch rule for room {room_id}"
f"Failed to delete telephony dispatch rule for room {room_id}"
) from e
self.presence_cache.clear_room(room_id)
try:
self.lobby_service.clear_room_cache(room_id)
except Exception as e:
raise ActionFailedError(
f"Failed to clear room cache for room {room_id}"
) from e
def _handle_participant_left(self, data):
"""Handle 'participant_left': invalidate the presence cache.
Presence entries are created lazily (only for users who administrate
the lobby of a trusted room), so for most participants this delete is
a no-op DEL on a key that never existed. Eager invalidation shrinks
the window during which a departed participant could still act on a
trusted room's lobby (cache hit until TTL expiry). It is gated behind
`PRESENCE_CLEAR_ON_PARTICIPANT_LEFT` so its production impact can be
measured and the behaviour reverted independently of the feature.
When disabled, invalidation relies on `room_finished` and the TTL.
"""
if not settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT:
return
identity = data.participant.identity
if not identity:
return
self.presence_cache.clear(data.room.name, identity)
+43 -57
View File
@@ -86,23 +86,6 @@ class LobbyService:
"""Generate cache key for participant(s) data."""
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
@staticmethod
def _get_or_create_participant_id(request) -> str:
"""Extract unique participant identifier from the request."""
return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, str(uuid.uuid4()))
@staticmethod
def prepare_response(response, participant_id):
"""Set participant cookie if needed."""
if not response.cookies.get(settings.LOBBY_COOKIE_NAME):
response.set_cookie(
key=settings.LOBBY_COOKIE_NAME,
value=participant_id,
httponly=True,
secure=True,
samesite="Lax",
)
@staticmethod
def can_bypass_lobby(room, user, role) -> bool:
"""Determines if a user can bypass the waiting lobby and join a room directly.
@@ -135,6 +118,7 @@ class LobbyService:
room: models.Room,
request,
username: str,
participant_id: Optional[uuid.UUID] = None,
) -> Tuple[LobbyParticipant, Optional[Dict]]:
"""Request entry to a room for a participant.
@@ -149,22 +133,20 @@ class LobbyService:
5. If denied, do nothing.
"""
participant_id = self._get_or_create_participant_id(request)
participant = self._get_participant(room.id, participant_id)
participant = None
if participant_id:
participant = self._get_participant(room.id, participant_id)
is_new_participant = participant is None
if is_new_participant:
participant = self._create_participant(room.id, username)
room_id = str(room.id)
user_role = room.get_role(request.user)
if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
if participant is None:
participant = LobbyParticipant(
status=LobbyParticipantStatus.ACCEPTED,
username=username,
id=participant_id,
color=utils.generate_color(participant_id),
)
else:
participant.status = LobbyParticipantStatus.ACCEPTED
participant.status = LobbyParticipantStatus.ACCEPTED
self._save_participant(room.id, participant)
livekit_config = utils.generate_livekit_config(
room_id=room_id,
@@ -172,18 +154,18 @@ class LobbyService:
username=username,
color=participant.color,
configuration=room.configuration,
participant_id=participant_id,
participant_id=participant.id,
role=user_role,
)
return participant, livekit_config
livekit_config = None
if participant is None:
participant = self.enter(room.id, participant_id, username)
if is_new_participant:
self._notify_entry_request(room_id)
elif participant.status == LobbyParticipantStatus.WAITING:
self.refresh_waiting_status(room.id, participant_id)
self.refresh_waiting_status(room.id, participant.id)
elif participant.status == LobbyParticipantStatus.ACCEPTED:
# wrongly named, contains access token to join a room
@@ -193,7 +175,7 @@ class LobbyService:
username=username,
color=participant.color,
configuration=room.configuration,
participant_id=participant_id,
participant_id=participant.id,
role=user_role,
)
@@ -210,27 +192,36 @@ class LobbyService:
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
)
def enter(
self, room_id: UUID, participant_id: str, username: str
) -> LobbyParticipant:
"""Add participant to waiting lobby.
def _create_participant(self, room_id: UUID, username: str) -> LobbyParticipant:
"""Create and persist a new waiting participant.
Create a new participant entry in waiting status and notify room
participants of the new entry request.
Participant identifiers are minted here, server-side, exclusively.
"""
color = utils.generate_color(participant_id)
participant_id = str(uuid.uuid4())
participant = LobbyParticipant(
status=LobbyParticipantStatus.WAITING,
username=username,
id=participant_id,
color=color,
color=utils.generate_color(participant_id),
)
self._save_participant(room_id, participant)
return participant
def _save_participant(self, room_id: UUID, participant: LobbyParticipant):
"""Persist a participant in the room's lobby."""
cache.set(
self._get_cache_key(room_id, participant.id),
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
@staticmethod
def _notify_entry_request(room_id: str):
"""Notify room participants of a new entry request."""
try:
utils.notify_participants(
room_name=str(room_id),
room_name=room_id,
notification_data={
"type": settings.LOBBY_NOTIFICATION_TYPE,
},
@@ -239,15 +230,6 @@ class LobbyService:
# If room not created yet, there is no participants to notify
logger.exception("Failed to notify room participants")
cache_key = self._get_cache_key(room_id, participant_id)
cache.set(
cache_key,
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
return participant
def _get_participant(
self, room_id: UUID, participant_id: str
) -> Optional[LobbyParticipant]:
@@ -270,7 +252,7 @@ class LobbyService:
"""List all waiting participants for a room."""
pattern = self._get_cache_key(room_id, "*")
keys = list(cache.iter_keys(pattern, itersize=utils.CACHE_SCAN_ITERSIZE))
keys = cache.keys(pattern)
if not keys:
return []
@@ -345,9 +327,13 @@ class LobbyService:
def clear_room_cache(self, room_id: UUID) -> None:
"""Clear all participant entries from the cache for a specific room."""
cache.delete_pattern(
self._get_cache_key(room_id, "*"), itersize=utils.CACHE_SCAN_ITERSIZE
)
pattern = self._get_cache_key(room_id, "*")
keys = cache.keys(pattern)
if not keys:
return
cache.delete_many(keys)
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
"""Clear a given participant entry from the cache for a specific room."""
@@ -20,7 +20,6 @@ from livekit.protocol.models import ParticipantInfo
from core import utils
from .lobby import LobbyService
from .presence import PresenceCache
logger = getLogger(__name__)
@@ -73,9 +72,7 @@ class ParticipantsManagement:
@async_to_sync
async def remove(self, room_name: str, identity: str):
"""Remove a participant from a room and clear their lobby/presence cache."""
PresenceCache().clear(room_name, identity)
"""Remove a participant from a room and clear their lobby cache."""
try:
LobbyService().clear_participant_cache(
@@ -115,7 +112,7 @@ class ParticipantsManagement:
await lkapi.aclose()
@async_to_sync
async def update( # noqa: PLR0917
async def update(
self,
room_name: str,
identity: str,
@@ -159,30 +156,6 @@ class ParticipantsManagement:
finally:
await lkapi.aclose()
def check_if_in_meeting_cached(self, room_name: str, identity: str) -> bool:
"""Cache-first variant of `check_if_in_meeting`.
Cache hit -> True without touching LiveKit.
Cache miss -> ask LiveKit; memoize only positive answers.
Raises the same exceptions as `check_if_in_meeting` so callers keep
failing closed the same way.
"""
if not room_name or not identity:
return False
presence_cache = PresenceCache()
if presence_cache.is_marked_present(room_name, identity):
return True
present = self.check_if_in_meeting(room_name=room_name, identity=identity)
if present:
presence_cache.mark_present(room_name, identity)
return present
@async_to_sync
async def check_if_in_meeting(self, room_name: str, identity: str) -> bool:
"""Check whether `identity` is currently a participant in `room_name`.
-52
View File
@@ -1,52 +0,0 @@
"""Presence cache.
Redis-backed memo of "this identity is currently connected to this room".
This module is intentionally a *pure cache store* with no dependency on other
services, so that `participants_management` (which talks to LiveKit) can
import it without creating an import cycle. The composition of "check cache,
fall back to LiveKit" lives in
`ParticipantsManagement.check_if_in_meeting_cached`.
Only positive answers are stored: a sticky negative would lock out someone
who joins right after a miss for the whole TTL. The TTL is a safety net in
case an invalidation webhook is lost.
"""
from uuid import UUID
from django.conf import settings
from django.core.cache import cache
from core.utils import CACHE_SCAN_ITERSIZE
class PresenceCache:
"""Store and invalidate (room, identity) presence entries."""
@staticmethod
def _get_cache_key(room_id: UUID | str, identity: str) -> str:
"""Cache key for a (room, identity) presence entry."""
return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}"
def is_marked_present(self, room_id: UUID | str, identity: str) -> bool:
"""Return True if a positive presence entry exists in cache."""
return bool(cache.get(self._get_cache_key(room_id, identity)))
def mark_present(self, room_id: UUID | str, identity: str) -> None:
"""Record that `identity` is in `room_id`."""
cache.set(
self._get_cache_key(room_id, identity),
True,
timeout=settings.PRESENCE_CACHE_TIMEOUT,
)
def clear(self, room_id: UUID | str, identity: str) -> None:
"""Forget presence for one participant (e.g. on participant_left)."""
cache.delete(self._get_cache_key(room_id, identity))
def clear_room(self, room_id: UUID | str) -> None:
"""Forget presence for every participant of a room (on room_finished)."""
cache.delete_pattern(
self._get_cache_key(room_id, "*"), itersize=CACHE_SCAN_ITERSIZE
)
@@ -8,7 +8,6 @@ from typing import Dict, Optional
from asgiref.sync import async_to_sync
from livekit.api import (
DeleteRoomRequest,
ListRoomsRequest,
TwirpError,
UpdateRoomMetadataRequest,
@@ -89,30 +88,3 @@ 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()
@@ -1,9 +1,9 @@
"""SIP management service for managing SIP dispatch rules for room access."""
"""Telephony 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, TwirpErrorCode
from livekit.api import TwirpError
from livekit.protocol.sip import (
CreateSIPDispatchRuleRequest,
DeleteSIPDispatchRuleRequest,
@@ -17,16 +17,12 @@ from core import utils
logger = getLogger(__name__)
class SIPException(Exception):
"""Exception raised when SIP operations fail."""
class TelephonyException(Exception):
"""Exception raised when telephony operations fail."""
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)."""
class TelephonyService:
"""Service for managing participant access through the telephony system (SIP)."""
def _rule_name(self, room_id):
"""Generate the rule name for a room based on its ID."""
@@ -36,7 +32,7 @@ class SIPManagement:
async def create_dispatch_rule(self, room):
"""Create a SIP inbound dispatch rule for direct room routing.
Configures livekit-sip to route incoming SIP calls directly to the specified room
Configures telephony to route incoming SIP calls directly to the specified room
using the room's ID and PIN code for authentication.
"""
@@ -55,12 +51,10 @@ class SIPManagement:
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 SIPException("Could not create dispatch rule") from e
raise TelephonyException("Could not create dispatch rule") from e
finally:
await lkapi.aclose()
@@ -85,7 +79,7 @@ class SIPManagement:
)
except TwirpError as e:
logger.exception("Failed to list dispatch rules for room %s", room_id)
raise SIPException("Could not list dispatch rules") from e
raise TelephonyException("Could not list dispatch rules") from e
finally:
await lkapi.aclose()
@@ -100,28 +94,6 @@ class SIPManagement:
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."""
@@ -146,7 +118,7 @@ class SIPManagement:
except TwirpError as e:
logger.exception("Failed to delete dispatch rules for room %s", room_id)
raise SIPException("Could not delete dispatch rules") from e
raise TelephonyException("Could not delete dispatch rules") from e
finally:
await lkapi.aclose()
+74
View File
@@ -0,0 +1,74 @@
"""Service handling the lifecycle of transit codes.
A transit code is an opaque, cryptographically random, single-use code
handed to an embedded frontend (through a URL fragment) so it can obtain a
user access token on the core API without a session cookie. The code
carries no information by itself: everything it references (user, client)
is stored server-side in the cache, and consumed atomically on exchange.
"""
import hashlib
import secrets
from django.conf import settings
from django.core.cache import cache
class TransitCodeService:
"""Create and consume single-use transit codes."""
@staticmethod
def _cache_key(code):
"""Build the cache key for a code.
The code is hashed so that a dump of the cache never reveals
directly usable codes.
"""
digest = hashlib.sha256(code.encode("utf-8")).hexdigest()
return f"{settings.TRANSIT_CODE_CACHE_PREFIX}:{digest}"
def create_code(self, user, client_id="unknown"):
"""Generate a transit code for a user, and store it.
The code expires after TRANSIT_CODE_TTL seconds.
Returns:
str: The opaque code to hand to the client.
"""
# Default 48 random bytes -> 64 url-safe characters, 384 bits of
# entropy: unguessable and safe to transit through a URL fragment.
code = secrets.token_urlsafe(settings.TRANSIT_CODE_NBYTES)
cache.set(
self._cache_key(code),
{
"user_id": str(user.id),
"client_id": client_id,
},
timeout=settings.TRANSIT_CODE_TTL,
)
return code
def consume_code(self, code):
"""Consume a transit code, enforcing single use.
The code is deleted from the cache upon consumption. `cache.delete`
returns whether a key was actually deleted, so if two requests race
on the same code, only one of them wins.
Returns:
dict | None: The data stored at creation time ('user_id',
'client_id'), or None if the code is unknown, expired or
already consumed.
"""
if not code:
return None
key = self._cache_key(code)
data = cache.get(key)
if data is None or not cache.delete(key):
return None
return data
-9
View File
@@ -1,9 +0,0 @@
"""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,11 +1,4 @@
"""
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
@@ -1,39 +0,0 @@
"""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)
@@ -11,98 +11,135 @@ from rest_framework.exceptions import AuthenticationFailed
from core.recording.event.authentication import (
MachineUser,
RecordingProcessWebhookAuthentication,
StorageEventAuthentication,
)
def test_successful_authentication(settings):
"""Test successful authentication with valid token."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/")
request.headers = {"Authorization": "Bearer valid-test-token"}
user, token = RecordingProcessWebhookAuthentication().authenticate(request)
user, token = StorageEventAuthentication().authenticate(request)
assert token == "valid-test-token"
assert isinstance(user, MachineUser)
def test_authentication_fails_when_token_not_configured(settings):
"""Authentication should fail when no token is configured."""
def test_disabled_authentication_with_header(settings):
"""Authentication should pass when no auth is configured, and header is present."""
settings.RECORDING_STORAGE_EVENT_TOKEN = None
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = None
request = RequestFactory().get("/")
request.headers = {"Authorization": "Bearer some-token"}
user, token = StorageEventAuthentication().authenticate(request)
assert token is None
assert isinstance(user, MachineUser)
def test_disabled_authentication_without_header(settings):
"""Authentication should pass when no auth is configured, and no header is present."""
settings.RECORDING_STORAGE_EVENT_TOKEN = None
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
request = RequestFactory().get("/")
user, token = StorageEventAuthentication().authenticate(request)
assert token is None
assert isinstance(user, MachineUser)
def test_authentication_when_disabled(settings):
"""Authentication should pass when disabled, regardless of token configuration."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "some-token"
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
request = RequestFactory().get("/")
user, token = StorageEventAuthentication().authenticate(request)
assert token is None
assert isinstance(user, MachineUser)
def test_authentication_fails_when_token_not_configured(settings):
"""Authentication should fail when authentication is enabled but no token is configured."""
# By default RECORDING_ENABLE_STORAGE_EVENT_AUTH should be True
settings.RECORDING_STORAGE_EVENT_TOKEN = None
request = RequestFactory().get("/")
with pytest.raises(
AuthenticationFailed,
match="Authentication token is not configured",
match="Authentication is enabled but token is not configured",
):
RecordingProcessWebhookAuthentication().authenticate(request)
StorageEventAuthentication().authenticate(request)
def test_missing_auth_header(settings):
"""Test failure when Authorization header is missing."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/")
request.headers = {}
with pytest.raises(AuthenticationFailed, match="Authorization header is required"):
RecordingProcessWebhookAuthentication().authenticate(request)
StorageEventAuthentication().authenticate(request)
def test_invalid_auth_header_format(settings):
"""Test failure when Authorization header has invalid format."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/")
request.headers = {"Authorization": "InvalidFormat"}
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
RecordingProcessWebhookAuthentication().authenticate(request)
StorageEventAuthentication().authenticate(request)
def test_invalid_token_type(settings):
"""Test failure when token type is not Bearer."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/")
request.headers = {"Authorization": "Basic some-token"}
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
RecordingProcessWebhookAuthentication().authenticate(request)
StorageEventAuthentication().authenticate(request)
def test_invalid_token(settings):
"""Test failure when token is invalid."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/")
request.headers = {"Authorization": "Bearer wrong-token"}
with pytest.raises(AuthenticationFailed, match="Invalid token"):
RecordingProcessWebhookAuthentication().authenticate(request)
StorageEventAuthentication().authenticate(request)
def test_malformed_auth_header(settings):
"""Test failure when Authorization header is malformed."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/")
request.headers = {"Authorization": "Bearer"} # Missing token part
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
RecordingProcessWebhookAuthentication().authenticate(request)
StorageEventAuthentication().authenticate(request)
def test_authenticate_header():
"""Test the WWW-Authenticate header value."""
request = RequestFactory().get("/")
header = RecordingProcessWebhookAuthentication().authenticate_header(request)
assert header == "Bearer realm='External process webhook API'"
header = StorageEventAuthentication().authenticate_header(request)
assert header == "Bearer realm='Storage event API'"
def test_multiple_spaces_in_auth_header(settings):
"""Extra spaces between the scheme and the token should be tolerated."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "extra-spaces-token"
"""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"}
user, token = RecordingProcessWebhookAuthentication().authenticate(request)
assert token == "extra-spaces-token"
assert isinstance(user, MachineUser)
header = StorageEventAuthentication().authenticate_header(request)
assert header == "Bearer realm='Storage event API'"
@@ -5,7 +5,6 @@ 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
@@ -419,63 +418,3 @@ def test_notify_summary_service_post_args_without_metadata(
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,512 @@
"""
Test event parsers.
"""
# pylint: disable=protected-access,redefined-outer-name,unused-argument
from unittest import mock
from django.conf import settings
import pytest
from core.recording.event.exceptions import (
InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError,
ParsingEventDataError,
)
from core.recording.event.parsers import (
MinioParser,
S3Parser,
StorageEvent,
get_parser,
)
# MinioParser
@pytest.fixture
def valid_minio_event():
"""Mock a valid Minio event."""
return {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"contentType": "audio/ogg",
},
}
}
]
}
@pytest.fixture
def minio_parser():
"""Mock a Minio parser."""
return MinioParser(bucket_name="test-bucket")
def test_minio_parse_valid_event(minio_parser, valid_minio_event):
"""Test parsing a valid Minio event."""
event = minio_parser.parse(valid_minio_event)
assert isinstance(event, StorageEvent)
assert event.filepath == "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
assert event.filetype == "audio/ogg"
assert event.bucket_name == "test-bucket"
assert event.metadata is None
def test_minio_parse_with_video_type(minio_parser):
"""Test parsing event with video file type."""
video_event = {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "46d1a121-2426-484d-8fb3-09b5d886f7a8.mp4",
"contentType": "video/mp4",
},
}
}
]
}
event = minio_parser.parse(video_event)
assert event.filetype == "video/mp4"
assert event.filepath.endswith(".mp4")
def test_minio_parse_empty_data(minio_parser):
"""Test parsing empty event data raises error."""
with pytest.raises(ParsingEventDataError, match="Received empty data."):
minio_parser.parse({})
def test_minio_parse_missing_keys(minio_parser):
"""Test parsing event with missing key."""
invalid_minio_event = {
"Records": [
{
"s3": {
"bucket": {"name": None},
# Missing 'object' key
}
}
]
}
with pytest.raises(ParsingEventDataError, match="Malformed Minio event:"):
minio_parser.parse(invalid_minio_event)
def test_minio_parse_none_key(minio_parser):
"""Test parsing event with None field."""
invalid_minio_event = {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"contentType": None, # 'contentType' should not be None
},
}
}
]
}
with pytest.raises(ParsingEventDataError, match="Missing essential data fields"):
minio_parser.parse(invalid_minio_event)
def test_minio_validate_invalid_bucket(minio_parser):
"""Test validation with wrong bucket name."""
event = StorageEvent(
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/ogg",
bucket_name="wrong-bucket",
metadata=None,
)
with pytest.raises(InvalidBucketError):
minio_parser.validate(event)
def test_minio_validate_invalid_filetype(minio_parser):
"""Test validation with unsupported file type."""
event = StorageEvent(
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.txt",
filetype="text/plain", # Not included in the default allowed filetypes
bucket_name="test-bucket",
metadata=None,
)
with pytest.raises(InvalidFileTypeError):
minio_parser.validate(event)
@pytest.mark.parametrize(
"invalid_filepath",
[
"invalid_filepath", # totally invalid string
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
"46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing url_encoded_folder_path and extension
"", # empty string
"46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # no folder at all
"uploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # wrong folder name
"folder%2Fuploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # nested but no recordings/
],
)
def test_minio_validate_invalid_filepath(invalid_filepath, minio_parser):
"""Test validation with malformed filepath."""
event = StorageEvent(
filepath=invalid_filepath,
filetype="audio/ogg",
bucket_name="test-bucket",
metadata=None,
)
with pytest.raises(InvalidFilepathError):
minio_parser.validate(event)
def test_minio_validate_valid_event(minio_parser):
"""Test validation with valid event data."""
event = StorageEvent(
filepath="recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/ogg",
bucket_name="test-bucket",
metadata=None,
)
recording_id = minio_parser.validate(event)
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
def test_minio_get_recording_id_success(minio_parser, valid_minio_event):
"""Test successful extraction of recording ID."""
recording_id = minio_parser.get_recording_id(valid_minio_event)
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
def test_minio_validate_filepath_with_folder(minio_parser):
"""Test validation of filepath with folder structure."""
event = StorageEvent(
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/ogg",
bucket_name="test-bucket",
metadata=None,
)
recording_id = minio_parser.validate(event)
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
def test_minio_empty_allowed_filetypes():
"""Test MinioParser with empty allowed_filetypes."""
empty_types = set()
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=empty_types)
assert parser._allowed_filetypes == {"audio/ogg", "video/mp4"}
def test_minio_custom_allowed_filetypes():
"""Test MinioParser with empty allowed_filetypes."""
custom_types = {"audio/mp3", "video/mov"}
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=custom_types)
assert parser._allowed_filetypes == {"audio/mp3", "video/mov"}
def test_minio_validate_custom_filetypes():
"""Test validation of filepath with folder structure."""
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"})
event = StorageEvent(
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/mp3",
bucket_name="test-bucket",
metadata=None,
)
parser.validate(event)
def test_minio_constructor_none_bucket():
"""Test MinioParser constructor with None bucket name."""
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
MinioParser(bucket_name=None)
def test_minio_constructor_empty_bucket():
"""Test MinioParser constructor with empty bucket name."""
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
MinioParser(bucket_name="")
# S3Parser
@pytest.fixture
def valid_s3_event():
"""Mock a valid S3 event."""
return {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
},
}
}
]
}
@pytest.fixture
def s3_parser():
"""Mock an S3 parser."""
return S3Parser(bucket_name="test-bucket")
def test_s3_parse_valid_event(s3_parser, valid_s3_event):
"""Test parsing a valid S3 event."""
event = s3_parser.parse(valid_s3_event)
assert isinstance(event, StorageEvent)
assert event.filepath == "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
assert event.filetype == "audio/ogg"
assert event.bucket_name == "test-bucket"
assert event.metadata is None
def test_s3_parse_empty_data(s3_parser):
"""Test parsing empty S3 event data raises error."""
with pytest.raises(ParsingEventDataError, match="Received empty data."):
s3_parser.parse({})
def test_s3_parse_missing_keys(s3_parser):
"""Test parsing S3 event with missing key."""
invalid_s3_event = {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
# Missing 'object' key
}
}
]
}
with pytest.raises(ParsingEventDataError, match="Malformed S3 event:"):
s3_parser.parse(invalid_s3_event)
def test_s3_parse_none_key(s3_parser):
"""Test parsing S3 event with None field."""
invalid_s3_event = {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": None,
},
}
}
]
}
with pytest.raises(ParsingEventDataError, match="Missing object key name"):
s3_parser.parse(invalid_s3_event)
def test_s3_parse_with_video_type(s3_parser):
"""Test parsing S3 event with mp4 file extension."""
video_event = {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.mp4",
},
}
}
]
}
event = s3_parser.parse(video_event)
assert event.filetype == "video/mp4"
assert event.filepath.endswith(".mp4")
def test_s3_parse_unrecognized_extension(s3_parser):
"""Test parsing S3 event with unrecognized file extension."""
event_with_unknown_ext = {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.zzunknown999",
},
}
}
]
}
with pytest.raises(TypeError, match="filetype cannot be None"):
s3_parser.parse(event_with_unknown_ext)
def test_s3_parser_keeps_encoded_filepath_compatible(settings):
"""Test S3 parser keeps already encoded object keys compatible."""
settings.RECORDING_OUTPUT_FOLDER = "recordings"
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
parser = S3Parser(bucket_name="recordings-bucket")
data = {
"Records": [
{
"s3": {
"bucket": {"name": "recordings-bucket"},
"object": {
"key": f"recordings%2F{recording_id}.mp4",
},
}
}
]
}
assert parser.get_recording_id(data) == recording_id
def test_s3_parser_accepts_unencoded_filepath(settings):
"""Test S3 parser accepts raw object keys with slash separators."""
settings.RECORDING_OUTPUT_FOLDER = "recordings"
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
parser = S3Parser(bucket_name="recordings-bucket")
data = {
"Records": [
{
"s3": {
"bucket": {"name": "recordings-bucket"},
"object": {
"key": f"recordings/{recording_id}.mp4",
},
}
}
]
}
assert parser.get_recording_id(data) == recording_id
def test_s3_parser_preserves_plus_signs_in_encoded_filepath(settings):
"""Test S3 parser preserves plus signs in already encoded object keys."""
settings.RECORDING_OUTPUT_FOLDER = "recordings"
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
parser = S3Parser(bucket_name="recordings-bucket")
data = {
"Records": [
{
"s3": {
"bucket": {"name": "recordings-bucket"},
"object": {
"key": f"folder+name%2Frecordings%2F{recording_id}.mp4",
},
}
}
]
}
assert parser.get_recording_id(data) == recording_id
def test_s3_get_recording_id_success(s3_parser, valid_s3_event):
"""Test successful extraction of recording ID from S3 event."""
recording_id = s3_parser.get_recording_id(valid_s3_event)
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
# get_parser
@pytest.fixture
def clear_lru_cache():
"""Fixture to clear the LRU cache between tests."""
get_parser.cache_clear()
yield
get_parser.cache_clear()
def test_returns_correct_instance(clear_lru_cache):
"""Test if get_parser returns the correct parser instance."""
settings.AWS_STORAGE_BUCKET_NAME = "test-bucket"
parser = get_parser()
assert isinstance(parser, MinioParser)
assert parser._bucket_name == "test-bucket"
def test_caching_behavior(clear_lru_cache):
"""Test if the function properly caches the parser instance."""
settings.AWS_STORAGE_BUCKET_NAME = "test-bucket"
parser1 = get_parser()
parser2 = get_parser()
assert parser1 is parser2 # Check object identity
def test_different_settings_new_instance():
"""Test if changing settings creates a new instance."""
settings.AWS_STORAGE_BUCKET_NAME = "different-bucket"
parser = get_parser()
assert parser._bucket_name == "different-bucket"
def test_import_error_handling(clear_lru_cache):
"""Test handling of import errors for invalid parser class."""
settings.RECORDING_EVENT_PARSER_CLASS = "invalid.parser.path"
with pytest.raises(ImportError):
get_parser()
@mock.patch("core.recording.event.parsers.import_string")
def test_parser_instantiation_called_once(mock_import_string, clear_lru_cache):
"""Test that parser class is instantiated only once due to caching."""
mock_parser_cls = type(
"MockParser",
(),
{
"__init__": lambda self, bucket_name: setattr(
self, "_bucket_name", bucket_name
)
},
)
mock_import_string.return_value = mock_parser_cls
# First call
parser1 = get_parser()
# Second call
parser2 = get_parser()
# Verify import_string was called only once
mock_import_string.assert_called_once_with(settings.RECORDING_EVENT_PARSER_CLASS)
assert parser1 is parser2
def test_cache_clear_behavior(clear_lru_cache, settings):
"""Test that cache clearing creates new instance."""
settings.RECORDING_EVENT_PARSER_CLASS = "core.recording.event.parsers.MinioParser"
parser1 = get_parser()
get_parser.cache_clear()
parser2 = get_parser()
assert parser1 is not parser2 # Should be different instances after cache clear
@@ -12,7 +12,6 @@ from core.factories import RecordingFactory
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
RecordingNotSavableError,
)
from core.utils import NotificationError
@@ -71,56 +70,3 @@ def test_handle_limit_reached_error(mock_notify, mode, notification_type, servic
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
@pytest.mark.parametrize("status", ["active", "stopped"])
@pytest.mark.parametrize(
("notify_return_value", "expected_status"),
((True, "notification_succeeded"), (False, "saved")),
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
def test_handle_complete_saves_recording( # pylint: disable=too-many-arguments, too-many-positional-arguments
mock_notify_external_services,
notify_return_value,
expected_status,
status,
service,
):
"""Test handle_complete notifies external services and saves a savable recording."""
mock_notify_external_services.return_value = notify_return_value
recording = RecordingFactory(status=status)
service.handle_complete(recording)
mock_notify_external_services.assert_called_once_with(recording)
recording.refresh_from_db()
assert recording.status == expected_status
@pytest.mark.parametrize(
"status",
["initiated", "saved", "notification_succeeded", "aborted", "failed_to_start"],
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
def test_handle_complete_non_savable_recording(
mock_notify_external_services, status, service
):
"""Test handle_complete refuses recordings that are already saved or in error."""
recording = RecordingFactory(status=status)
with pytest.raises(RecordingNotSavableError):
service.handle_complete(recording)
mock_notify_external_services.assert_not_called()
recording.refresh_from_db()
assert recording.status == status
@@ -0,0 +1,267 @@
"""
Test recordings API endpoints in the Meet core app: save recording.
"""
# pylint: disable=redefined-outer-name,unused-argument
import uuid
from unittest import mock
import pytest
from rest_framework.test import APIClient
from ...factories import RecordingFactory
from ...models import Recording, RecordingStatusChoices
from ...recording.event.exceptions import (
InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError,
ParsingEventDataError,
)
pytestmark = pytest.mark.django_db
@pytest.fixture
def recording_settings(settings):
"""Configure recording-related and storage event Django settings."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
settings.RECORDING_STORAGE_EVENT_ENABLE = True
return settings
@pytest.fixture
def mock_get_parser():
"""Mock 'get_parser' factory function."""
with mock.patch("core.api.viewsets.get_parser") as mock_parser:
yield mock_parser
def test_save_recording_anonymous(settings, client):
"""Anonymous users should not be allowed to save room recordings."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
RecordingFactory(status="active")
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
)
assert response.status_code == 401
assert Recording.objects.count() == 1
def test_save_recording_wrong_bearer(settings, client):
"""Requests with incorrect bearer token should be rejected when auth is required."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer wrongAuthToken",
)
assert response.status_code == 401
def test_save_recording_permission_needed(settings, client):
"""Recordings should not be saved when feature is disabled."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
settings.RECORDING_STORAGE_EVENT_ENABLE = False
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_save_recording_parsing_error(recording_settings, mock_get_parser, client):
"""Test handling of parsing errors in recording event data."""
mock_parser = mock.Mock()
mock_parser.get_recording_id.side_effect = ParsingEventDataError("Error message")
mock_get_parser.return_value = mock_parser
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid request data."}
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
"""Test handling of invalid storage bucket errors in recording event data."""
mock_parser = mock.Mock()
mock_parser.get_recording_id.side_effect = InvalidBucketError("Error message")
mock_get_parser.return_value = mock_parser
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid bucket specified."}
def test_save_recording_filetype_error(recording_settings, mock_get_parser):
"""Test handling of unsupported file types in recording event data."""
mock_parser = mock.Mock()
mock_parser.get_recording_id.side_effect = InvalidFileTypeError(
"unsupported '.json'"
)
mock_get_parser.return_value = mock_parser
client = APIClient()
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Notification ignored."}
def test_save_recording_filepath_error(recording_settings, mock_get_parser):
"""Test handling of unsupported filepath in recording event data."""
mock_parser = mock.Mock()
mock_parser.get_recording_id.side_effect = InvalidFilepathError(
"Invalid filepath structure: parent/folder/recording.jpeg"
)
mock_get_parser.return_value = mock_parser
client = APIClient()
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Notification ignored."}
def test_save_recording_unknown_recording(recording_settings, mock_get_parser, client):
"""Test handling of events for non-existent recordings."""
RecordingFactory(status="active")
mock_parser = mock.Mock()
mock_parser.get_recording_id.return_value = uuid.uuid4()
mock_get_parser.return_value = mock_parser
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 404
assert response.json() == {"detail": "No recording found for this event."}
@pytest.mark.parametrize(
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
)
def test_save_recording_non_savable_recording(
recording_settings, mock_get_parser, client, status
):
"""Test that recordings in non-savable states cannot be saved."""
recording = RecordingFactory(status=status)
mock_parser = mock.Mock()
mock_parser.get_recording_id.return_value = recording.id
mock_get_parser.return_value = mock_parser
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 403
assert response.json() == {
"detail": f"Recording with ID {recording.id} cannot be saved because it is either,"
" in an error state or has already been saved."
}
@pytest.mark.parametrize("status", ["active", "stopped"])
def test_save_recording_success(recording_settings, mock_get_parser, client, status):
"""Test successful saving of recordings in valid states."""
recording = RecordingFactory(status=status)
mock_parser = mock.Mock()
mock_parser.get_recording_id.return_value = recording.id
mock_get_parser.return_value = mock_parser
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Event processed."}
recording.refresh_from_db()
assert recording.status == RecordingStatusChoices.SAVED
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@pytest.mark.parametrize("notification_succeeded", [True, False])
def test_save_recording_notifies_external_services(
mock_notify_external_services,
recording_settings,
mock_get_parser,
client,
notification_succeeded,
):
"""External services should be notified when a recording is saved."""
recording = RecordingFactory(status="active")
mock_parser = mock.Mock()
mock_parser.get_recording_id.return_value = recording.id
mock_get_parser.return_value = mock_parser
mock_notify_external_services.return_value = notification_succeeded
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Event processed."}
mock_notify_external_services.assert_called_once_with(recording)
recording.refresh_from_db()
assert recording.status == (
RecordingStatusChoices.NOTIFICATION_SUCCEEDED
if notification_succeeded
else RecordingStatusChoices.SAVED
)
@@ -1 +0,0 @@
"""Tests for the roomkit API of the Meet core app."""
@@ -1,305 +0,0 @@
"""
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()
@@ -2,15 +2,19 @@
Test rooms API endpoints in the Meet core app: create.
"""
from datetime import datetime, timedelta, timezone
from django.conf import settings as django_settings
# pylint: disable=redefined-outer-name,unused-argument
from django.conf import settings
from django.core.cache import cache
import jwt
import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...models import Room, RoomAccessLevel
from ...models import Room
pytestmark = pytest.mark.django_db
@@ -112,203 +116,36 @@ def test_api_rooms_create_authenticated_existing_slug():
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)
def generate_user_access_token(user):
"""Generate a valid user access JWT signed with the token secret."""
now = datetime.now(timezone.utc)
payload = {
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
"user_id": str(user.id),
"token_type": "user_access",
"client_id": "test-app",
"scope": "user:access",
}
return jwt.encode(
payload,
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
)
def test_api_rooms_create_authenticated_with_user_access_token():
"""A user access token should create a room exactly like a session would."""
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "my room",
},
)
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(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
assert room.accesses.filter(role="owner", user=user).exists()
@@ -2,8 +2,12 @@
Test rooms API endpoints in the Meet core app: list.
"""
from datetime import datetime, timedelta, timezone
from unittest import mock
from django.conf import settings as django_settings
import jwt
import pytest
from rest_framework.pagination import PageNumberPagination
from rest_framework.test import APIClient
@@ -156,3 +160,40 @@ def test_api_rooms_list_pagination_page_size():
assert len(content["results"]) == 3
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
assert content["previous"] is None
def generate_user_access_token(user):
"""Generate a valid user access JWT signed with the token secret."""
now = datetime.now(timezone.utc)
payload = {
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
"user_id": str(user.id),
"token_type": "user_access",
"client_id": "test-app",
"scope": "user:access",
}
return jwt.encode(
payload,
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
)
def test_api_rooms_list_authenticated_with_user_access_token():
"""A user access token should list rooms exactly like a session would."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
RoomFactory() # another user's room, not listed
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
response = client.get("/api/v1.0/rooms/")
assert response.status_code == 200
assert response.data["count"] == 1
assert response.data["results"][0]["id"] == str(room.id)
@@ -14,9 +14,6 @@ from rest_framework.test import APIClient
from ... import utils
from ...factories import RoomFactory, UserFactory
from ...models import RoomAccessLevel
from ...services.lobby import (
LobbyService,
)
pytestmark = pytest.mark.django_db
@@ -29,7 +26,6 @@ def test_request_entry_anonymous(settings):
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request
@@ -47,11 +43,10 @@ def test_request_entry_anonymous(settings):
assert response.status_code == 200
# Verify the lobby cookie was properly set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
participant_id = cookie.value
# The participant identifier is returned in the response body; no
# cookie is involved anymore
assert not response.cookies
participant_id = response.json()["id"]
# Verify response content matches expected structure and values
assert response.json() == {
@@ -78,7 +73,6 @@ def test_request_entry_authenticated_user(settings):
client = APIClient()
client.force_login(user)
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request
@@ -96,11 +90,10 @@ def test_request_entry_authenticated_user(settings):
assert response.status_code == 200
# Verify the lobby cookie was properly set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
participant_id = cookie.value
# The participant identifier is returned in the response body; no
# cookie is involved anymore
assert not response.cookies
participant_id = response.json()["id"]
# Verify response content matches expected structure and values
assert response.json() == {
@@ -127,7 +120,6 @@ def test_request_entry_with_existing_participants(settings):
client = APIClient()
# Configure test settings for cookies and cache
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Add two participants already waiting in the lobby
@@ -168,11 +160,10 @@ def test_request_entry_with_existing_participants(settings):
# Verify successful response
assert response.status_code == 200
# Verify the lobby cookie was properly set for the new participant
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
participant_id = cookie.value
# The participant identifier is returned in the response body; no
# cookie is involved anymore
assert not response.cookies
participant_id = response.json()["id"]
# Verify response content matches expected structure and values
assert response.json() == {
@@ -197,7 +188,6 @@ def test_request_entry_public_room(settings):
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request
@@ -206,9 +196,7 @@ def test_request_entry_public_room(settings):
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(
LobbyService, "_get_or_create_participant_id", return_value="123"
),
mock.patch("core.services.lobby.uuid.uuid4", return_value="123"),
mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
),
@@ -221,11 +209,6 @@ def test_request_entry_public_room(settings):
assert response.status_code == 200
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "123"
# Verify response content matches expected structure and values
assert response.json() == {
"id": "123",
@@ -235,9 +218,10 @@ def test_request_entry_public_room(settings):
"livekit": {"token": "test-token"},
}
# Verify lobby cache is still empty after the request
# The accepted participant is persisted, out of the waiting list
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys
assert len(lobby_keys) == 1
assert cache.get(lobby_keys[0])["status"] == "accepted"
def test_request_entry_authenticated_user_public_room(settings):
@@ -247,7 +231,6 @@ def test_request_entry_authenticated_user_public_room(settings):
client = APIClient()
client.force_login(user)
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request
@@ -256,9 +239,8 @@ def test_request_entry_authenticated_user_public_room(settings):
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(
LobbyService,
"_get_or_create_participant_id",
mock.patch(
"core.services.lobby.uuid.uuid4",
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
),
mock.patch.object(
@@ -273,11 +255,6 @@ def test_request_entry_authenticated_user_public_room(settings):
assert response.status_code == 200
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
# Verify response content matches expected structure and values
assert response.json() == {
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
@@ -287,9 +264,10 @@ def test_request_entry_authenticated_user_public_room(settings):
"livekit": {"token": "test-token"},
}
# Verify lobby cache is still empty after the request
# The accepted participant is persisted, out of the waiting list
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys
assert len(lobby_keys) == 1
assert cache.get(lobby_keys[0])["status"] == "accepted"
def test_request_entry_waiting_participant_public_room(settings):
@@ -297,7 +275,6 @@ def test_request_entry_waiting_participant_public_room(settings):
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Add a waiting participant to the room's lobby cache
@@ -311,9 +288,7 @@ def test_request_entry_waiting_participant_public_room(settings):
},
)
# Simulate a browser with existing participant cookie
client.cookies.load({"mocked-cookie": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"})
# Simulate a returning participant echoing its identifier
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(
@@ -322,16 +297,14 @@ def test_request_entry_waiting_participant_public_room(settings):
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "user1"},
{
"username": "user1",
"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
},
)
assert response.status_code == 200
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
# Verify response content matches expected structure and values
assert response.json() == {
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
@@ -389,7 +362,7 @@ def test_allow_participant_to_enter_anonymous():
def test_allow_participant_to_enter_non_owner():
"""Non-privileged users should not be allowed to manage entry requests."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
@@ -522,7 +495,7 @@ def test_list_waiting_participants_anonymous():
def test_list_waiting_participants_non_owner():
"""Non-privileged users should not be allowed to list waiting participants."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
@@ -637,15 +610,14 @@ def test_list_waiting_participants_empty(settings):
@mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
)
def test_request_entry_throttling_anonymous_without_cookie(
def test_request_entry_throttling_anonymous_unidentified(
mock_notify_participants, mock_generate_livekit_config, settings
):
"""Anonymous users without a cookie should not be throttled."""
"""Requests without a participant identifier should not be throttled."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "1/minute"
response = client.post(
@@ -654,9 +626,6 @@ def test_request_entry_throttling_anonymous_without_cookie(
)
assert response.status_code == 200
assert response.cookies.get("mocked-cookie") is not None
client.cookies.clear() # Simulate a new cookieless request
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
@@ -670,34 +639,32 @@ def test_request_entry_throttling_anonymous_without_cookie(
@mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
)
def test_request_entry_throttling_anonymous_with_cookie(
def test_request_entry_throttling_anonymous_identified(
mock_notify_participants, mock_generate_livekit_config, settings
):
"""Anonymous users with a cookie should be throttled after exceeding the rate limit."""
"""Identified requests should be throttled after exceeding the rate limit."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
participant_id = str(uuid.uuid4())
client.cookies.load({"mocked-cookie": participant_id})
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
{"username": "test_user", "participant_id": participant_id},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
{"username": "test_user", "participant_id": participant_id},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
{"username": "test_user", "participant_id": participant_id},
)
assert response.status_code == 429
@@ -716,7 +683,6 @@ def test_request_entry_throttling_authenticated_user(
client = APIClient()
client.force_login(user)
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
response = client.post(
@@ -737,3 +703,124 @@ def test_request_entry_throttling_authenticated_user(
)
assert response.status_code == 429
def test_request_entry_with_participant_id(settings):
"""Echoing the previously issued identifier preserves the lobby identity across requests."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
participant_id = response.json()["id"]
# Echoing the identifier must be recognized as the same
# participant: no duplicate in the lobby
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user", "participant_id": participant_id},
)
assert response.status_code == 200
assert response.json()["id"] == participant_id
assert response.json()["status"] == "waiting"
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert len(lobby_keys) == 1
def test_request_entry_unknown_participant_id_not_seeded(settings):
"""An identifier unknown to the room's lobby must not be honored."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
forged_id = str(uuid.uuid4())
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user", "participant_id": forged_id},
)
assert response.status_code == 200
assert response.json()["id"] != forged_id
# Nothing was stored under the forged identifier
assert cache.get(f"mocked-cache-prefix_{room.id}_{forged_id}") is None
def test_request_entry_participant_id_bound_to_room(settings):
"""An identifier minted for one room must not be honored in another."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
other_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
participant_id = response.json()["id"]
response = client.post(
f"/api/v1.0/rooms/{other_room.id}/request-entry/",
{"username": "test_user", "participant_id": participant_id},
)
assert response.status_code == 200
assert response.json()["id"] != participant_id
def test_request_entry_legacy_cookie_ignored():
"""The retired cookie channel must not be honored anymore."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
legacy_participant_id = str(uuid.uuid4())
client.cookies["lobbyParticipantId"] = legacy_participant_id
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
returned_id = response.json()["id"]
assert returned_id != legacy_participant_id
uuid.UUID(returned_id)
def test_request_entry_malformed_participant_id(settings):
"""A non-UUID identifier is rejected by the serializer with a 400."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user", "participant_id": "../../../evil-key"},
)
assert response.status_code == 400
assert "participant_id" in response.json()
@@ -1,106 +0,0 @@
"""Trusted rooms: any authenticated participant present in the meeting can manage the lobby."""
from unittest import mock
import pytest
from rest_framework.test import APIClient
from core.factories import RoomFactory, UserFactory
from core.models import RoomAccessLevel
from core.services.presence import PresenceCache
pytestmark = pytest.mark.django_db
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_trusted_room_present_user_can_list_waiting(mock_check):
"""Authenticated + present in a trusted room -> 200, LiveKit asked once then cached."""
mock_check.return_value = True
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/rooms/{room.id}/waiting-participants/"
assert client.get(url).status_code == 200
assert client.get(url).status_code == 200
assert mock_check.call_count == 1
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_trusted_room_absent_user_forbidden(mock_check):
"""Authenticated but not connected to the meeting -> 403."""
mock_check.return_value = False
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 403
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_restricted_room_present_user_forbidden(mock_check):
"""Presence is not enough on a restricted room; LiveKit must not even be asked."""
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 403
mock_check.assert_not_called()
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_trusted_room_presence_cleared_after_leave(mock_check):
"""Once the presence cache is cleared (participant_left), LiveKit is re-checked."""
mock_check.return_value = True
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/rooms/{room.id}/waiting-participants/"
assert client.get(url).status_code == 200
PresenceCache().clear(room.id, str(user.sub))
mock_check.return_value = False
assert client.get(url).status_code == 403
assert mock_check.call_count == 2
def test_trusted_room_anonymous_forbidden():
"""Anonymous users never manage the lobby."""
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
response = APIClient().get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 401
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_trusted_room_present_user_can_accept_entry(mock_check):
"""Authenticated + present in a trusted room can accept a waiting participant."""
mock_check.return_value = True
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
)
# Permission passed; 404 because that participant isn't actually waiting.
assert response.status_code == 404
assert response.json() == {"message": "Participant not found."}
@@ -20,7 +20,11 @@ from rest_framework.test import APIClient
from core import utils
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
from core.services.lobby import LobbyService
from core.services.lobby import (
LobbyParticipant,
LobbyParticipantStatus,
LobbyService,
)
pytestmark = pytest.mark.django_db
@@ -87,7 +91,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -113,7 +117,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -153,7 +157,7 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -300,7 +304,7 @@ def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -330,7 +334,7 @@ def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client)
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -361,7 +365,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -381,7 +385,7 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -412,7 +416,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -440,7 +444,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -469,7 +473,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -849,7 +853,15 @@ def test_remove_participant_success_lobby_cache(mock_livekit_client):
participant_identity = str(uuid4())
# Create participant in lobby cache first
LobbyService().enter(room.id, participant_identity, "John doe")
LobbyService()._save_participant(
room.id,
LobbyParticipant(
id=participant_identity,
username="John doe",
status=LobbyParticipantStatus.WAITING,
color="#123456",
),
)
# Accept participant
LobbyService().handle_participant_entry(room.id, participant_identity, True)
@@ -1020,3 +1032,6 @@ def test_remove_participant_not_found(mock_livekit_client):
assert response.data == {"error": "Participant not found"}
mock_livekit_client.aclose.assert_called_once()
# todo - try to pass another scheme to make sure it defers to the next auth
@@ -69,7 +69,10 @@ def test_toggle_hand_raise_success(mock_livekit_client, room, token):
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-token {token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -84,7 +87,10 @@ def test_toggle_hand_lower_success(mock_livekit_client, room, token):
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url, {"raised": False}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"raised": False},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -101,7 +107,10 @@ def test_toggle_hand_raise_sets_timestamp(mock_livekit_client, room, token):
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -117,7 +126,10 @@ def test_toggle_hand_identity_derived_from_token(
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
call_kwargs = mock_livekit_client.room.update_participant.call_args
@@ -128,7 +140,9 @@ def test_toggle_hand_missing_raised_field(room, token):
"""Test toggle hand with missing raised field returns 400."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post(
url, {}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "raised" in response.data
@@ -142,7 +156,7 @@ def test_toggle_hand_invalid_raised_field(room, token):
url,
{"raised": "not-a-boolean"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -166,7 +180,10 @@ def test_toggle_hand_forbidden_token_for_wrong_room(user):
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": target_room.id})
response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {wrong_token}"
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {wrong_token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -181,7 +198,10 @@ def test_toggle_hand_unexpected_twirp_error(mock_livekit_client, room, token):
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
@@ -200,7 +220,7 @@ def test_toggle_hand_raise_success_anonymous(
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -220,7 +240,7 @@ def test_toggle_hand_lower_success_anonymous(
url,
{"raised": False},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -240,7 +260,7 @@ def test_toggle_hand_identity_derived_from_token_anonymous(
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
)
call_kwargs = mock_livekit_client.room.update_participant.call_args
@@ -257,7 +277,10 @@ def test_rename_participant_success(mock_livekit_client, room, token):
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -272,7 +295,10 @@ def test_rename_participant_sets_correct_name(mock_livekit_client, room, token):
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
client.post(
url, {"name": "Jane Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"name": "Jane Doe"},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
call_kwargs = mock_livekit_client.room.update_participant.call_args
@@ -286,7 +312,10 @@ def test_rename_participant_uses_identity_from_token(
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
call_kwargs = mock_livekit_client.room.update_participant.call_args
@@ -298,7 +327,7 @@ def test_rename_participant_empty_name(room, token):
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": ""}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url, {"name": ""}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -309,7 +338,9 @@ def test_rename_participant_missing_name(room, token):
"""Test rename with missing name field returns 400."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post(
url, {}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "name" in response.data
@@ -320,7 +351,10 @@ def test_rename_participant_name_too_long(room, token):
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": "a" * 256}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"name": "a" * 256},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -348,7 +382,7 @@ def test_rename_participant_forbidden_token_for_wrong_room(user):
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {wrong_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {wrong_token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -363,7 +397,10 @@ def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, to
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
@@ -382,7 +419,7 @@ def test_rename_participant_success_anonymous(
url,
{"name": "Guest User"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
)
assert response.status_code == status.HTTP_200_OK
@@ -402,7 +439,7 @@ def test_rename_participant_uses_identity_from_token_anonymous(
url,
{"name": "Guest User"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
)
call_kwargs = mock_livekit_client.room.update_participant.call_args
@@ -419,7 +456,7 @@ def test_rename_participant_sets_correct_name_anonymous(
url,
{"name": "Guest User"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
)
call_kwargs = mock_livekit_client.room.update_participant.call_args
@@ -436,7 +473,7 @@ def test_rename_participant_forbidden_anonymous_token_for_wrong_room(anonymous_t
url,
{"name": "Guest User"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -462,7 +499,7 @@ def test_toggle_hand_expired_token(room, expired_token):
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -476,7 +513,7 @@ def test_rename_participant_expired_token(room, expired_token):
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -490,7 +527,7 @@ def test_toggle_hand_malformed_token(room):
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt",
HTTP_AUTHORIZATION="X-LiveKit-Token this-is-not-a-valid-jwt",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -504,7 +541,10 @@ def test_toggle_hand_room_not_found(user):
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": non_existent_room_id})
response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -519,7 +559,10 @@ def test_toggle_hand_participant_not_found(mock_livekit_client, room, token):
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -536,7 +579,7 @@ def test_rename_participant_malformed_token(room):
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt",
HTTP_AUTHORIZATION="X-LiveKit-Token this-is-not-a-valid-jwt",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -550,7 +593,10 @@ def test_rename_participant_room_not_found(user):
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": non_existent_room_id})
response = client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -565,10 +611,16 @@ def test_rename_participant_not_found(mock_livekit_client, room, token):
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.data == {"error": "Participant not found"}
mock_livekit_client.aclose.assert_called_once()
# todo - try to pass another scheme to make sure it defers to the next auth
@@ -3,11 +3,14 @@ Test rooms API endpoints in the Meet core app: retrieve.
"""
import random
from datetime import datetime, timedelta, timezone
from unittest import mock
from django.conf import settings as django_settings
from django.contrib.auth.models import AnonymousUser
from django.test.utils import override_settings
import jwt
import pytest
from rest_framework.test import APIClient
@@ -453,8 +456,6 @@ 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,
@@ -468,8 +469,6 @@ 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,
@@ -507,3 +506,40 @@ def test_api_rooms_retrieve_administrators(
role=str(user_access.role),
participant_id=None,
)
def generate_user_access_token(user):
"""Generate a valid user access JWT signed with the token secret."""
now = datetime.now(timezone.utc)
payload = {
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
"user_id": str(user.id),
"token_type": "user_access",
"client_id": "test-app",
"scope": "user:access",
}
return jwt.encode(
payload,
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
)
def test_api_rooms_retrieve_authenticated_with_user_access_token():
"""A user access token should retrieve a room exactly like a session would."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
assert response.status_code == 200
assert response.data["id"] == str(room.id)
# Authenticated as the owner: privileged fields are included
assert response.data["pin_code"] == room.pin_code
@@ -110,7 +110,7 @@ def test_start_subtitle_invalid_token():
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{},
HTTP_AUTHORIZATION="Bearer invalid-token",
HTTP_AUTHORIZATION="X-LiveKit-Token invalid-token",
)
assert response.status_code == 403
@@ -128,7 +128,7 @@ def test_start_subtitle_disabled_by_default(mock_livekit_token):
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{},
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
)
assert response.status_code == 404
@@ -148,7 +148,7 @@ def test_start_subtitle_valid_token(
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{},
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
)
assert response.status_code == 200
@@ -178,7 +178,7 @@ def test_start_subtitle_twirp_error(
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{},
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
)
assert response.status_code == 500
@@ -198,7 +198,7 @@ def test_start_subtitle_wrong_room(settings, mock_livekit_token):
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{},
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
)
assert response.status_code == 403
@@ -219,10 +219,13 @@ def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{},
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Invalid LiveKit token: Signature verification failed"
}
# todo - try to pass another scheme to make sure it defers to the next auth
@@ -3,8 +3,12 @@ Test rooms API endpoints in the Meet core app: update.
"""
import random
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from django.conf import settings as django_settings
import jwt
import pytest
from rest_framework.test import APIClient
@@ -437,3 +441,45 @@ def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
"configuration": {"can_publish_sources": ["camera"]},
},
)
def generate_user_access_token(user):
"""Generate a valid user access JWT signed with the token secret."""
now = datetime.now(timezone.utc)
payload = {
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
"user_id": str(user.id),
"token_type": "user_access",
"client_id": "test-app",
"scope": "user:access",
}
return jwt.encode(
payload,
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
)
def test_api_rooms_update_authenticated_with_user_access_token():
"""Role-based permissions apply unchanged with a user access token."""
user = UserFactory()
room = RoomFactory(users=[(user, "member")])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
# A simple member cannot update the room
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
assert response.status_code == 403
# An administrator can
room.accesses.filter(user=user).update(role="administrator")
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
assert response.status_code == 200
room.refresh_from_db()
assert room.name == "new name"
@@ -21,10 +21,7 @@ from core.services.livekit_events import (
)
from core.services.lobby import LobbyService
from core.services.room_management import RoomManagementException
from core.services.sip_management import (
SIPException,
SIPManagement,
)
from core.services.telephony import TelephonyException, TelephonyService
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@@ -62,7 +59,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.sip_management, SIPManagement)
assert isinstance(service.telephony_service, TelephonyService)
assert isinstance(service.recording_events, RecordingEventsService)
@@ -75,7 +72,7 @@ def test_initialization(
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too-many-positional-arguments
def test_handle_egress_ended_success(
mock_update_metadata, mock_notify, mode, notification_type, service
):
"""Should successfully stop recording and notifies all participant."""
@@ -158,7 +155,7 @@ def test_handle_egress_updated_non_handled(
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_metadata_update_fails( # pylint: disable=too-many-arguments, too-many-positional-arguments
def test_handle_egress_ended_metadata_update_fails(
mock_update_metadata, mock_notify, mode, notification_type, service
):
"""Should successfully stop and save recording when metadata's update fails."""
@@ -348,7 +345,7 @@ 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, PLR0917
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
mock_update_metadata,
mock_notify,
mock_notify_external_services,
@@ -356,10 +353,12 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
recording_status,
egress_status,
service,
settings,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Should notify external services and save the recording on egress completion
(EGRESS_COMPLETE or EGRESS_LIMIT_REACHED).
(EGRESS_COMPLETE or EGRESS_LIMIT_REACHED) when RECORDING_STORAGE_EVENT_ENABLE is False.
"""
settings.RECORDING_STORAGE_EVENT_ENABLE = False
mock_notify_external_services.return_value = notify_return_value
recording = RecordingFactory(worker_id="worker-1", status="active")
@@ -375,6 +374,47 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
assert recording.status == recording_status
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
@pytest.mark.parametrize(
"egress_status, expected_status",
[
(EgressStatus.EGRESS_COMPLETE, "active"),
(EgressStatus.EGRESS_LIMIT_REACHED, "stopped"),
],
)
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
mock_update_metadata,
mock_notify,
mock_notify_external_services,
egress_status,
expected_status,
service,
settings,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""When storage event webhooks are enabled, egress_ended must not finalize the
recording: external services are never notified. EGRESS_LIMIT_REACHED still stops
the recording, EGRESS_COMPLETE leaves it active.
"""
settings.RECORDING_STORAGE_EVENT_ENABLE = True
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
service._handle_egress_ended(mock_data)
mock_notify_external_services.assert_not_called()
recording.refresh_from_db()
assert recording.status == expected_status
@pytest.mark.parametrize(
"egress_status",
[
@@ -387,9 +427,10 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
)
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_does_not_save_on_wrong_status(
mock_update_metadata, egress_status, service
mock_update_metadata, egress_status, service, settings
):
"""Shouldn't save on invalid status."""
settings.RECORDING_STORAGE_EVENT_ENABLE = False
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
@@ -407,13 +448,14 @@ def test_handle_egress_ended_does_not_save_on_wrong_status(
)
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_ignores_non_savable_recording(
mock_update_metadata, status, service
mock_update_metadata, status, service, settings
):
"""Should handle non-savable recordings idempotently without raising.
'egress_ended' may be redelivered (e.g. for an already-saved recording);
this must not raise, otherwise the webhook would 500 and LiveKit would retry.
"""
settings.RECORDING_STORAGE_EVENT_ENABLE = False
recording = RecordingFactory(worker_id="worker-1", status=status)
mock_data = mock.MagicMock()
@@ -427,11 +469,11 @@ def test_handle_egress_ended_ignores_non_savable_recording(
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(TelephonyService, "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 SIP dispatch rule when room finishes."""
"""Should clear lobby cache and delete telephony dispatch rule when room finishes."""
settings.ROOM_TELEPHONY_ENABLED = True
mock_room_name = uuid.uuid4()
mock_data = mock.MagicMock()
@@ -444,31 +486,12 @@ def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
@mock.patch.object(LobbyService, "clear_room_cache")
@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")
@mock.patch.object(TelephonyService, "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)
@@ -482,7 +505,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(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_raises_error_when_cache_clearing_fails(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
@@ -505,9 +528,9 @@ def test_handle_room_finished_raises_error_when_cache_clearing_fails(
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(
SIPManagement,
TelephonyService,
"delete_dispatch_rule",
side_effect=SIPException("Test error"),
side_effect=TelephonyException("Test error"),
)
def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
@@ -518,7 +541,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 sip dispatch rule for room "
"Failed to delete telephony dispatch rule for room "
"00000000-0000-0000-0000-000000000000"
)
@@ -539,11 +562,11 @@ def test_handle_room_finished_raises_error_for_invalid_room_name(service):
service._handle_room_finished(mock_data)
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
@mock.patch.object(TelephonyService, "create_dispatch_rule")
def test_handle_room_started_creates_dispatch_rule_successfully(
mock_ensure_dispatch_rule, service, settings
mock_create_dispatch_rule, service, settings
):
"""Should ensure the SIP dispatch rule exists when room starts successfully."""
"""Should create telephony dispatch rule when room starts successfully."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
mock_data = mock.MagicMock()
@@ -551,75 +574,22 @@ def test_handle_room_started_creates_dispatch_rule_successfully(
service._handle_room_started(mock_data)
mock_ensure_dispatch_rule.assert_called_once_with(room)
mock_create_dispatch_rule.assert_called_once_with(room)
@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 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_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")
@mock.patch.object(TelephonyService, "create_dispatch_rule")
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
mock_ensure_dispatch_rule, service, settings
mock_create_dispatch_rule, service, settings
):
"""Should skip ensuring the SIP dispatch rule when telephony is disabled during room start."""
"""Should skip creating telephony 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()
mock_create_dispatch_rule.assert_not_called()
def test_handle_room_started_raises_error_for_invalid_room_name(service):
@@ -673,7 +643,6 @@ 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
@@ -777,61 +746,3 @@ 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()
@mock.patch("core.services.presence.cache.delete")
def test_participant_left_clearing_gated_by_setting(mock_delete, service, settings):
"""PRESENCE_CLEAR_ON_PARTICIPANT_LEFT toggles eager presence invalidation."""
data = mock.Mock()
data.room.name = "room-name"
data.participant.identity = "user-sub"
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = False
service._handle_participant_left(data) # pylint: disable=protected-access
mock_delete.assert_not_called()
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = True
service._handle_participant_left(data) # pylint: disable=protected-access
mock_delete.assert_called_once()
@mock.patch("core.services.presence.cache.delete")
def test_participant_left_without_identity_is_ignored(mock_delete, service, settings):
"""No cache operation when the webhook carries no identity."""
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = True
data = mock.Mock()
data.room.name = "room-name"
data.participant.identity = ""
service._handle_participant_left(data) # pylint: disable=protected-access
mock_delete.assert_not_called()
+54 -127
View File
@@ -3,7 +3,6 @@ Test lobby service.
"""
# pylint: disable=W0621,W0613, W0212, R0913
# ruff: noqa: PLR0913, PLR0917
import uuid
from unittest import mock
@@ -11,7 +10,6 @@ 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
@@ -24,7 +22,6 @@ from core.services.lobby import (
LobbyParticipantStatus,
LobbyService,
)
from core.services.presence import CACHE_SCAN_ITERSIZE
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@@ -136,59 +133,6 @@ def test_get_cache_key(lobby_service, participant_id):
assert cache_key == expected_key
def test_get_or_create_participant_id_from_cookie(lobby_service):
"""Test extracting participant ID from cookie."""
request = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: "existing-id"}
participant_id = lobby_service._get_or_create_participant_id(request)
assert participant_id == "existing-id"
@mock.patch.object(uuid, "uuid4", return_value="generated-id")
def test_get_or_create_participant_id_new(mock_uuid4, lobby_service):
"""Test creating new participant ID when cookie is missing."""
request = mock.Mock()
request.COOKIES = {}
participant_id = lobby_service._get_or_create_participant_id(request)
assert participant_id == "generated-id"
mock_uuid4.assert_called_once()
def test_prepare_response_existing_cookie(lobby_service, participant_id):
"""Test response preparation with existing cookie."""
response = HttpResponse()
response.cookies[settings.LOBBY_COOKIE_NAME] = "existing-cookie"
lobby_service.prepare_response(response, participant_id)
# Verify cookie wasn't set again
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
assert cookie.value == "existing-cookie"
assert cookie.value != participant_id
def test_prepare_response_new_cookie(lobby_service, participant_id):
"""Test response preparation with new cookie."""
response = HttpResponse()
lobby_service.prepare_response(response, participant_id)
# Verify cookie was set
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
assert cookie is not None
assert cookie.value == participant_id
assert cookie["httponly"] is True
assert cookie["secure"] is True
assert cookie["samesite"] == "Lax"
# It's a session cookies (no max_age specified):
assert not cookie["max-age"]
def test_can_bypass_lobby_public_room(lobby_service):
"""Should return True for public rooms regardless of user auth and role."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
@@ -267,11 +211,12 @@ def test_request_entry_public_room(
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)
participant, livekit_config = lobby_service.request_entry(
room, request, username, participant_id=participant_id
)
assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"}
@@ -305,11 +250,12 @@ def test_request_entry_trusted_room(
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)
participant, livekit_config = lobby_service.request_entry(
room, request, username, participant_id=participant_id
)
assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"}
@@ -326,18 +272,19 @@ def test_request_entry_trusted_room(
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.LobbyService.enter")
@mock.patch("core.services.lobby.LobbyService._notify_entry_request")
@mock.patch("core.services.lobby.LobbyService._create_participant")
def test_request_entry_new_participant(
mock_enter, lobby_service, participant_id, username
mock_create, mock_notify, lobby_service, participant_id, username
):
"""Test requesting entry for a new participant."""
"""A new participant gets a server-minted identifier - any provided
one is unknown to the lobby and therefore discarded - and the room is
notified of the entry request."""
request = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
request.user = AnonymousUser()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=None)
participant_data = LobbyParticipant(
@@ -346,14 +293,20 @@ def test_request_entry_new_participant(
id=participant_id,
color="#123456",
)
mock_enter.return_value = participant_data
mock_create.return_value = participant_data
participant, livekit_config = lobby_service.request_entry(room, request, username)
forged_id = str(uuid.uuid4())
participant, livekit_config = lobby_service.request_entry(
room, request, username, participant_id=forged_id
)
assert participant == participant_data
assert livekit_config is None
mock_enter.assert_called_once_with(room.id, participant_id, username)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
# The provided identifier was looked up, found unknown, and replaced
# by a freshly minted participant
lobby_service._get_participant.assert_called_once_with(room.id, forged_id)
mock_create.assert_called_once_with(room.id, username)
mock_notify.assert_called_once_with(str(room.id))
@mock.patch("core.services.lobby.LobbyService.refresh_waiting_status")
@@ -362,7 +315,6 @@ 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)
@@ -373,10 +325,11 @@ def test_request_entry_waiting_participant(
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)
participant, livekit_config = lobby_service.request_entry(room, request, username)
participant, livekit_config = lobby_service.request_entry(
room, request, username, participant_id=participant_id
)
assert participant.status == LobbyParticipantStatus.WAITING
assert livekit_config is None
@@ -391,7 +344,6 @@ def test_request_entry_accepted_participant(
"""Test requesting entry for an accepted participant."""
request = mock.Mock()
request.user = AnonymousUser()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -401,12 +353,13 @@ def test_request_entry_accepted_participant(
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)
participant, livekit_config = lobby_service.request_entry(
room, request, username, participant_id=participant_id
)
assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"}
@@ -429,7 +382,6 @@ def test_request_entry_participant_with_role(
"""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)
@@ -441,12 +393,13 @@ def test_request_entry_participant_with_role(
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)
participant, livekit_config = lobby_service.request_entry(
room, request, username, participant_id=participant_id
)
assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"}
@@ -473,73 +426,47 @@ def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
)
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants")
def test_enter_success(
mock_notify,
def test_create_participant(
mock_generate_color,
mock_cache,
lobby_service,
participant_id,
username,
settings,
):
"""Test successful participant entry."""
"""A created participant is waiting, colored, and persisted."""
mock_generate_color.return_value = "#123456"
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
participant = lobby_service.enter(room.id, participant_id, username)
participant = lobby_service._create_participant(room.id, username)
mock_generate_color.assert_called_once_with(participant_id)
# The identifier is minted server-side
uuid.UUID(participant.id)
mock_generate_color.assert_called_once_with(participant.id)
assert participant.status == LobbyParticipantStatus.WAITING
assert participant.username == username
assert participant.id == participant_id
assert participant.color == "#123456"
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
lobby_service._get_cache_key.assert_called_once_with(room.id, participant.id)
mock_cache.set.assert_called_once_with(
"mocked_cache_key",
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
mock_notify.assert_called_once_with(
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
)
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants")
def test_enter_with_notification_error(
mock_notify,
mock_generate_color,
mock_cache,
lobby_service,
participant_id,
username,
):
"""Test participant entry with notification error."""
mock_generate_color.return_value = "#123456"
def test_notify_entry_request_with_notification_error(mock_notify, lobby_service):
"""A notification error must not break the entry request flow."""
mock_notify.side_effect = NotificationError("Error notifying")
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
participant = lobby_service.enter(room.id, participant_id, username)
lobby_service._notify_entry_request("room-id")
mock_generate_color.assert_called_once_with(participant_id)
assert participant.status == LobbyParticipantStatus.WAITING
assert participant.username == username
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
mock_cache.set.assert_called_once_with(
"mocked_cache_key",
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
mock_notify.assert_called_once_with(
room_name="room-id", notification_data={"type": "participantWaiting"}
)
@@ -579,14 +506,14 @@ def test_get_participant_parsing_error(
@mock.patch("core.services.lobby.cache")
def test_list_waiting_participants_empty(mock_cache, lobby_service):
"""Test listing waiting participants when none exist."""
mock_cache.iter_keys.return_value = []
mock_cache.keys.return_value = []
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
result = lobby_service.list_waiting_participants(room.id)
assert result == []
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_not_called()
@@ -595,7 +522,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
"""Test listing waiting participants with valid data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
mock_cache.iter_keys.return_value = [cache_key]
mock_cache.keys.return_value = [cache_key]
mock_cache.get_many.return_value = {cache_key: participant_dict}
result = lobby_service.list_waiting_participants(room.id)
@@ -604,7 +531,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
assert result[0]["status"] == "waiting"
assert result[0]["username"] == "test-username"
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_called_once_with([cache_key])
@@ -629,7 +556,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
"color": "#654321",
}
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
mock_cache.keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = {
cache_key1: participant1,
cache_key2: participant2,
@@ -647,7 +574,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
assert all(p["status"] == "waiting" for p in result)
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
@@ -656,7 +583,7 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
"""Test listing waiting participants with corrupted data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
mock_cache.iter_keys.return_value = [cache_key]
mock_cache.keys.return_value = [cache_key]
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
result = lobby_service.list_waiting_participants(room.id)
@@ -681,7 +608,7 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
corrupted_participant = {"invalid": "data"}
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
mock_cache.keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = {
cache_key1: corrupted_participant,
cache_key2: valid_participant,
@@ -700,7 +627,7 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
# Verify both cache keys were queried
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
@@ -724,7 +651,7 @@ def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
"color": "#654321",
}
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
mock_cache.keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = {
cache_key1: participant1,
cache_key2: participant2,
@@ -1,108 +0,0 @@
"""Tests for the presence cache and the cached presence check."""
# pylint: disable=W0212
from unittest import mock
from uuid import uuid4
from django.core.cache import cache
import pytest
from core.services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagement,
ParticipantsManagementException,
)
from core.services.presence import PresenceCache
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_cache_hit_skips_livekit(mock_check):
"""A cached positive answer must not call LiveKit."""
room_id, identity = str(uuid4()), "user-sub"
PresenceCache().mark_present(room_id, identity)
assert (
ParticipantsManagement().check_if_in_meeting_cached(room_id, identity) is True
)
mock_check.assert_not_called()
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_cache_miss_calls_livekit_and_caches_positive(mock_check):
"""On a miss, LiveKit is asked once and a positive answer is memoized."""
mock_check.return_value = True
room_id, identity = str(uuid4()), "user-sub"
service = ParticipantsManagement()
assert service.check_if_in_meeting_cached(room_id, identity) is True
assert service.check_if_in_meeting_cached(room_id, identity) is True
assert mock_check.call_count == 1
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_negative_not_cached(mock_check):
"""Negative answers are never memoized."""
mock_check.return_value = False
room_id, identity = str(uuid4()), "user-sub"
service = ParticipantsManagement()
assert service.check_if_in_meeting_cached(room_id, identity) is False
assert service.check_if_in_meeting_cached(room_id, identity) is False
assert mock_check.call_count == 2
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_errors_propagate_and_cache_nothing(mock_check):
"""LiveKit errors propagate to the caller (which fails closed); nothing cached."""
room_id, identity = str(uuid4()), "user-sub"
for exc in (ParticipantNotFoundException(), ParticipantsManagementException()):
mock_check.side_effect = exc
with pytest.raises(type(exc)):
ParticipantsManagement().check_if_in_meeting_cached(room_id, identity)
assert cache.get(PresenceCache._get_cache_key(room_id, identity)) is None
def test_presence_clear_and_clear_room():
"""clear() removes one entry, clear_room() removes all entries of a room."""
room_id, other_room = str(uuid4()), str(uuid4())
presence = PresenceCache()
presence.mark_present(room_id, "a")
presence.mark_present(room_id, "b")
presence.mark_present(other_room, "a")
presence.clear(room_id, "a")
assert presence.is_marked_present(room_id, "a") is False
assert presence.is_marked_present(room_id, "b") is True
presence.clear_room(room_id)
assert presence.is_marked_present(room_id, "b") is False
assert presence.is_marked_present(other_room, "a") is True
def test_presence_clear_room_scans_in_pages():
"""clear_room removes every match, even across several SCAN pages,
and only within the room."""
room_id, other_room = str(uuid4()), str(uuid4())
presence = PresenceCache()
for i in range(7):
presence.mark_present(room_id, f"user-{i}")
presence.mark_present(other_room, "user-0")
# An itersize smaller than the match count forces delete_pattern to
# page through several SCAN cursors rather than finish in one pass.
with mock.patch("core.utils.CACHE_SCAN_ITERSIZE", 3):
presence.clear_room(room_id)
assert all(not presence.is_marked_present(room_id, f"user-{i}") for i in range(7))
assert presence.is_marked_present(other_room, "user-0") is True
@@ -1,60 +0,0 @@
"""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 SIP mamagement service.
Test telephony service.
"""
# pylint: disable=W0212
@@ -20,11 +20,7 @@ from livekit.protocol.sip import (
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.services.sip_management import (
DispatchRuleConflictError,
SIPException,
SIPManagement,
)
from core.services.telephony import TelephonyException, TelephonyService
pytestmark = pytest.mark.django_db
@@ -39,9 +35,9 @@ def create_mock_livekit_client():
def test_rule_name():
"""Test rule name generation."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
rule_name = sip_management._rule_name(room.id)
rule_name = telephony_service._rule_name(room.id)
assert rule_name == f"SIP_{str(room.id)}"
@@ -49,14 +45,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."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
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
sip_management.create_dispatch_rule(room)
telephony_service.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"]
@@ -71,7 +67,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."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
@@ -80,8 +76,8 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
)
mock_client_factory.return_value = mock_api
with pytest.raises(SIPException, match="Could not create dispatch rule"):
sip_management.create_dispatch_rule(room)
with pytest.raises(TelephonyException, match="Could not create dispatch rule"):
telephony_service.create_dispatch_rule(room)
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once()
@@ -90,7 +86,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."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_rules = [
@@ -115,7 +111,7 @@ def test_list_dispatch_rules_ids_success(mock_client_factory):
)
mock_client_factory.return_value = mock_api
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert len(result) == 2
assert "rule-1" in result
@@ -131,7 +127,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."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
@@ -140,7 +136,7 @@ def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
)
mock_client_factory.return_value = mock_api
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert result == []
mock_api.aclose.assert_called_once()
@@ -149,7 +145,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."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_rules = [
@@ -167,7 +163,7 @@ def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
)
mock_client_factory.return_value = mock_api
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert result == []
mock_api.aclose.assert_called_once()
@@ -176,7 +172,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."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
@@ -185,34 +181,34 @@ def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
)
mock_client_factory.return_value = mock_api
with pytest.raises(SIPException, match="Could not list dispatch rules"):
async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
with pytest.raises(TelephonyException, match="Could not list dispatch rules"):
async_to_sync(telephony_service._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.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.services.telephony.TelephonyService._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."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = []
result = sip_management.delete_dispatch_rule(room.id)
result = telephony_service.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.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.services.telephony.TelephonyService._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."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1"]
@@ -220,7 +216,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 = sip_management.delete_dispatch_rule(room.id)
result = telephony_service.delete_dispatch_rule(room.id)
assert result is True
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
@@ -230,11 +226,11 @@ def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
mock_api.aclose.assert_called_once()
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.services.telephony.TelephonyService._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."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
@@ -242,7 +238,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 = sip_management.delete_dispatch_rule(room.id)
result = telephony_service.delete_dispatch_rule(room.id)
assert result is True
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3
@@ -257,11 +253,11 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
mock_api.aclose.assert_called_once()
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.services.telephony.TelephonyService._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."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
@@ -281,18 +277,18 @@ def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rul
)
mock_client_factory.return_value = mock_api
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
sip_management.delete_dispatch_rule(room.id)
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
telephony_service.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.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.services.telephony.TelephonyService._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."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1"]
@@ -302,131 +298,8 @@ def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
)
mock_client_factory.return_value = mock_api
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
sip_management.delete_dispatch_rule(room.id)
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
telephony_service.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,46 @@
"""
Unit tests for the TransitCodeService.
"""
import pytest
from core.factories import UserFactory
from core.services.transit_code import TransitCodeService
pytestmark = pytest.mark.django_db
def test_create_code_returns_unique_opaque_codes():
"""Each created code should be a distinct high-entropy string."""
user = UserFactory()
service = TransitCodeService()
codes = {service.create_code(user) for _ in range(5)}
assert len(codes) == 5
for code in codes:
assert len(code) >= 43
def test_consume_code_returns_stored_data_once():
"""Consuming a code should return its data exactly once."""
user = UserFactory()
service = TransitCodeService()
code = service.create_code(user, client_id="my-app")
assert service.consume_code(code) == {
"user_id": str(user.id),
"client_id": "my-app",
}
# Single use: a second consumption fails
assert service.consume_code(code) is None
def test_consume_code_unknown_or_empty():
"""Unknown or empty codes should not be consumable."""
service = TransitCodeService()
assert service.consume_code("unknown-code") is None
assert service.consume_code("") is None
assert service.consume_code(None) is None
@@ -1,51 +0,0 @@
"""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")
@@ -1,166 +0,0 @@
"""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
@@ -0,0 +1,200 @@
"""
Tests for user access JWT authentication on the core API.
The token authenticates the user on the whole API, exactly like a session
cookie would (similar to lib-jitsi-meet's token authentication): the
existing role-based permissions apply unchanged. Room endpoint coverage
with a user access token lives in the room test files.
"""
from datetime import datetime, timedelta, timezone
from django.conf import settings as django_settings
import jwt
import pytest
from rest_framework.test import APIClient
from core.factories import RoomFactory, UserFactory
from core.models import RoleChoices
pytestmark = pytest.mark.django_db
def generate_user_access_token(user, **overrides):
"""Generate a valid user access JWT signed with the token secret."""
now = datetime.now(timezone.utc)
payload = {
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
"user_id": str(user.id),
"token_type": "user_access",
"client_id": "test-app",
"scope": "user:access",
}
payload.update(overrides)
payload = {key: value for key, value in payload.items() if value is not None}
return jwt.encode(
payload,
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
)
def test_user_access_token_users_me():
"""A user access token should authenticate the user on /users/me/."""
user = UserFactory()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 200
assert response.data["email"] == user.email
def test_user_access_token_expired():
"""An expired user access token should be rejected."""
user = UserFactory()
now = datetime.now(timezone.utc)
token = generate_user_access_token(
user,
iat=now - timedelta(hours=3),
exp=now - timedelta(hours=1),
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
assert "token expired" in str(response.data).lower()
def test_user_access_token_invalid_signature():
"""A token signed with the wrong key should defer and end unauthenticated."""
user = UserFactory()
now = datetime.now(timezone.utc)
token = jwt.encode(
{
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=600),
"user_id": str(user.id),
"token_type": "user_access",
"client_id": "test-app",
},
"wrong-secret-key-padded-for-minimum-len!",
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
# UserAccessJWTAuthentication defers, session auth finds no session
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
def test_user_access_token_wrong_token_type():
"""A verified token with the wrong 'token_type' claim should be rejected."""
user = UserFactory()
token = generate_user_access_token(user, token_type="addons")
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
assert "invalid token type" in str(response.data).lower()
def test_user_access_token_missing_client_id_claim():
"""A token without the issuance-audit claim should be rejected."""
user = UserFactory()
token = generate_user_access_token(user, client_id=None)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
assert "invalid token claims" in str(response.data).lower()
def test_user_access_token_inactive_user():
"""A user access token for an inactive user should be rejected."""
user = UserFactory(is_active=False)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
def test_user_access_token_feature_disabled(settings):
"""When the feature is disabled, user access tokens should be ignored."""
settings.USER_ACCESS_TOKEN_ENABLED = False
user = UserFactory()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
def test_user_access_token_does_not_break_session_authentication():
"""A session-authenticated user should keep full access to the API."""
user = UserFactory()
RoomFactory(users=[(user, RoleChoices.OWNER)])
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/rooms/")
assert response.status_code == 200
assert response.data["count"] == 1
def test_user_access_token_application_jwt_not_accepted_on_core_api():
"""An application-delegation JWT must not authenticate on the core API."""
user = UserFactory()
now = datetime.now(timezone.utc)
token = jwt.encode(
{
"iss": django_settings.APPLICATION_JWT_ISSUER,
"aud": django_settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=600),
"user_id": str(user.id),
"client_id": "some-client",
"delegated": True,
"scope": "rooms:retrieve",
},
django_settings.APPLICATION_JWT_SECRET_KEY,
algorithm=django_settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
# The user token backend must defer (wrong signature) and the request
# must end up unauthenticated.
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
-2
View File
@@ -119,8 +119,6 @@ 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,165 @@
"""
Test users API endpoints in the Meet core app: exchange transit code.
"""
# pylint: disable=W0621
import secrets
import jwt
import pytest
from rest_framework.test import APIClient
from core.factories import UserFactory
from core.services.transit_code import TransitCodeService
pytestmark = pytest.mark.django_db
def decode_user_access_token(token, settings):
"""Decode a user access token with the token secret."""
return jwt.decode(
token,
settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithms=[settings.USER_ACCESS_TOKEN_ALG],
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
)
def generate_unknown_code(settings):
"""Generate a well-formed code that was never stored."""
return secrets.token_urlsafe(settings.TRANSIT_CODE_NBYTES)
@pytest.fixture
def client():
"""Return an anonymous API client with a random source IP.
A fresh IP per test isolates the anonymous throttle history, both
between the tests of this module and between test runs.
"""
# `secrets` rather than `random`: the global random module is seeded
# deterministically by the factories, its sequence repeats across runs.
remote_addr = (
f"10.{secrets.randbelow(256)}.{secrets.randbelow(256)}"
f".{secrets.randbelow(254) + 1}"
)
return APIClient(REMOTE_ADDR=remote_addr)
def test_exchange_access_token_missing_code(client):
"""The exchange endpoint should validate its input."""
response = client.post("/api/v1.0/users/exchange-access-token/")
assert response.status_code == 400
assert "code" in response.data
def test_exchange_access_token_malformed_code(client):
"""A code whose length cannot match a generated one should be a 400."""
response = client.post(
"/api/v1.0/users/exchange-access-token/",
{"code": "not-a-valid-code"},
)
assert response.status_code == 400
assert "invalid transit code format" in str(response.data).lower()
def test_exchange_access_token_unknown_code(client, settings):
"""A well-formed but unknown code should be denied."""
response = client.post(
"/api/v1.0/users/exchange-access-token/",
{"code": generate_unknown_code(settings)},
)
assert response.status_code == 403
assert "invalid, expired or already used" in str(response.data).lower()
def test_exchange_access_token_success(client, settings):
"""A valid transit code should be exchangeable for an access token."""
user = UserFactory()
code = TransitCodeService().create_code(user, client_id="my-app")
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
assert response.status_code == 200
assert response.data["token_type"] == settings.USER_ACCESS_TOKEN_TYPE
assert response.data["expires_in"] == settings.USER_ACCESS_TOKEN_TTL
assert response.data["scope"] == "user:access"
payload = decode_user_access_token(response.data["access_token"], settings)
assert payload["token_type"] == "user_access"
assert payload["user_id"] == str(user.id)
assert payload["client_id"] == "my-app"
assert payload["exp"] - payload["iat"] == settings.USER_ACCESS_TOKEN_TTL
def test_exchange_access_token_single_use(client):
"""A transit code should be exchangeable exactly once."""
user = UserFactory()
code = TransitCodeService().create_code(user)
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
assert response.status_code == 200
# Replaying the same code must be denied
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
assert response.status_code == 403
assert "invalid, expired or already used" in str(response.data).lower()
def test_exchange_access_token_inactive_user(client):
"""A code minted for a now-inactive user should be denied."""
user = UserFactory()
code = TransitCodeService().create_code(user)
user.is_active = False
user.save()
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
assert response.status_code == 403
assert "no longer access" in str(response.data).lower()
def test_exchange_access_token_feature_disabled(client, settings):
"""The exchange endpoint should return 404 when the feature is disabled."""
settings.USER_ACCESS_TOKEN_ENABLED = False
user = UserFactory()
code = TransitCodeService().create_code(user)
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
assert response.status_code == 404
def test_exchange_access_token_throttled(client, settings):
"""Anonymous exchange attempts should be rate limited."""
throttle_rates = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]
initial_rate = throttle_rates["exchange_access_token"]
# The rates dict is mutated in place: restore it explicitly, the
# `settings` fixture only rolls back attribute assignments.
throttle_rates["exchange_access_token"] = "2/minute"
try:
for _ in range(2):
response = client.post(
"/api/v1.0/users/exchange-access-token/",
{"code": generate_unknown_code(settings)},
)
assert response.status_code == 403
response = client.post(
"/api/v1.0/users/exchange-access-token/",
{"code": generate_unknown_code(settings)},
)
assert response.status_code == 429
finally:
throttle_rates["exchange_access_token"] = initial_rate
@@ -1,111 +0,0 @@
"""
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
@@ -5,7 +5,6 @@ Tests for external API /token endpoint
# pylint: disable=W0621
from unittest import mock
from urllib.parse import urlencode
import jwt
import pytest
@@ -89,155 +88,6 @@ def test_api_applications_generate_token_success(settings):
}
def test_api_applications_generate_token_form_urlencoded(settings):
"""The token endpoint should accept "application/x-www-form-urlencoded"
requests, as mandated by RFC 6749 (sections 3.2 and 4.4.2) for OAuth 2.0
token endpoints, so that standard OAuth 2.0 client libraries work
out of the box."""
UserFactory(email="user@example.com")
application = ApplicationFactory(
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
(
f"client_id={application.client_id}"
f"&client_secret={plain_secret}"
"&grant_type=client_credentials"
"&scope=user%40example.com"
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 200
assert "access_token" in response.data
response.data.pop("access_token")
assert response.data == {
"token_type": "Bearer",
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
"scope": "rooms:list rooms:create",
}
def test_api_applications_generate_token_form_urlencoded_invalid_credentials():
"""Invalid credentials sent as form-urlencoded should be parsed and
rejected with 401, proving the request body is properly decoded."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=True)
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode(
{
"client_id": application.client_id,
"client_secret": "wrong-secret",
"grant_type": "client_credentials",
"scope": user.email,
}
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 401
assert "Invalid credentials" in str(response.data)
def test_api_applications_generate_token_form_urlencoded_missing_fields():
"""Missing required fields in a form-urlencoded request should return
a 400 validation error, like for JSON requests."""
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode({"grant_type": "client_credentials"}),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 400
for field in ("client_id", "client_secret", "scope"):
assert field in response.data
def test_api_applications_generate_token_form_urlencoded_invalid_grant_type():
"""An unsupported grant_type sent as form-urlencoded should return 400."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=True)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode(
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "authorization_code",
"scope": user.email,
}
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 400
assert "grant_type" in response.data
def test_api_applications_generate_token_form_urlencoded_special_characters():
"""Percent-encoded reserved characters ("&", "=", "+", "%") in the
client_secret should survive form-urlencoded decoding."""
UserFactory(email="user@example.com")
application = ApplicationFactory(
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST],
)
plain_secret = "s3cr3t&with=special+chars%42"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode(
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "user@example.com",
}
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 200
assert "access_token" in response.data
def test_api_applications_generate_token_unsupported_media_type():
"""Content types other than JSON and form-urlencoded should still be
rejected with 415 Unsupported Media Type."""
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
"client_id=x&client_secret=y&grant_type=client_credentials&scope=a@b.co",
content_type="text/plain",
)
assert response.status_code == 415
def test_api_applications_generate_token_invalid_client_id():
"""Invalid client_id should return 401."""
user = UserFactory(email="user@example.com")
@@ -0,0 +1,166 @@
"""
Tests for external API /users endpoints (transit codes)
"""
# pylint: disable=W0621
from datetime import datetime, timedelta, timezone
from unittest import mock
from django.conf import settings as django_settings
import jwt
import pytest
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework.test import APIClient
from core.factories import ApplicationFactory, UserFactory
from core.models import ApplicationScope
from core.services.transit_code import TransitCodeService
pytestmark = pytest.mark.django_db
def generate_test_token(user, scopes):
"""Generate a valid application JWT token for testing."""
now = datetime.now(timezone.utc)
scope_string = " ".join(scopes)
application = ApplicationFactory()
payload = {
"iss": django_settings.APPLICATION_JWT_ISSUER,
"aud": django_settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now
+ timedelta(seconds=django_settings.APPLICATION_JWT_EXPIRATION_SECONDS),
"client_id": str(application.client_id),
"scope": scope_string,
"user_id": str(user.id),
"delegated": True,
}
return jwt.encode(
payload,
django_settings.APPLICATION_JWT_SECRET_KEY,
algorithm=django_settings.APPLICATION_JWT_ALG,
)
def test_api_users_transit_code_requires_authentication():
"""Minting a transit code without authentication should return 401."""
client = APIClient()
response = client.post("/external-api/v1.0/users/transit-code/")
assert response.status_code == 401
def test_api_users_transit_code_missing_scope():
"""A token without the 'users:session' scope should be rejected."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/users/transit-code/")
assert response.status_code == 403
assert "users:session" in str(response.data)
def test_api_users_transit_code_success(settings):
"""A delegated user with the scope should be able to mint a transit code."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/users/transit-code/")
assert response.status_code == 200
assert response.data["expires_in"] == settings.TRANSIT_CODE_TTL
code = response.data["transit_code"]
# Opaque, high-entropy random string
assert len(code) == (4 * settings.TRANSIT_CODE_NBYTES + 2) // 3
# The code is stored server-side and references the delegated user
code_data = TransitCodeService().consume_code(code)
assert code_data == {
"user_id": str(user.id),
"client_id": mock.ANY,
}
def test_api_users_transit_code_with_rs_token():
"""A resource-server-authenticated user should be able to mint a code."""
user = UserFactory()
# todo - add a decorator instead
with mock.patch.object(
ResourceServerAuthentication,
"authenticate",
return_value=(user, {"scope": "users:session", "client_id": "rs-client"}),
) as mock_rs_authenticate:
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some-opaque-rs-token")
response = client.post("/external-api/v1.0/users/transit-code/")
mock_rs_authenticate.assert_called_once()
assert response.status_code == 200
code_data = TransitCodeService().consume_code(response.data["transit_code"])
assert code_data == {
"user_id": str(user.id),
"client_id": "rs-client",
}
def test_api_users_transit_code_with_rs_token_missing_scope():
"""A resource server token without the scope should be rejected."""
user = UserFactory()
# todo - add a decorator instead
with mock.patch.object(
ResourceServerAuthentication,
"authenticate",
return_value=(user, {"scope": "rooms:list", "client_id": "rs-client"}),
):
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some-opaque-rs-token")
response = client.post("/external-api/v1.0/users/transit-code/")
assert response.status_code == 403
assert "users:session" in str(response.data)
def test_api_users_transit_code_feature_disabled(settings):
"""Minting a transit code should return 404 when the feature is disabled."""
settings.USER_ACCESS_TOKEN_ENABLED = False
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/users/transit-code/")
assert response.status_code == 404
def test_api_users_transit_code_inactive_user():
"""An inactive user should not be able to mint a transit code."""
user = UserFactory(is_active=False)
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/users/transit-code/")
assert response.status_code == 401
# todo - add a test to make sure the addon authentification doesn't allow to mint a transit token
+2 -15
View File
@@ -184,13 +184,12 @@ def test_models_rooms_is_public_property():
@mock.patch.object(Room, "generate_unique_pin_code")
def test_telephony_and_roomkit_disabled_skips_pin_generation(
def test_telephony_disabled_skips_pin_generation(
mock_generate_unique_pin_code, settings
):
"""Telephony and roomkit both disabled should not generate pin codes."""
"""Telephony disabled should not generate pin codes."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = False
room = RoomFactory()
@@ -198,18 +197,6 @@ def test_telephony_and_roomkit_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."""
+5 -11
View File
@@ -9,7 +9,6 @@ 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()
@@ -20,21 +19,11 @@ 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()
@@ -48,6 +37,11 @@ external_router.register(
external_viewsets.RoomViewSet,
basename="external_room",
)
external_router.register(
"users",
external_viewsets.UserViewSet,
basename="external_user",
)
urlpatterns = [
path(
+2 -10
View File
@@ -12,7 +12,6 @@ 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
@@ -60,7 +59,7 @@ def generate_color(identity: str) -> str:
return f"hsl({hue}, {saturation}%, {lightness}%)"
def generate_token( # noqa: PLR0917
def generate_token(
room: str,
user,
username: Optional[str] = None,
@@ -68,7 +67,6 @@ def generate_token( # noqa: PLR0917
sources: Optional[List[str]] = None,
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.
@@ -84,7 +82,6 @@ def generate_token( # noqa: PLR0917
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.
@@ -138,13 +135,11 @@ def generate_token( # noqa: PLR0917
}
)
)
if ttl is not None:
token = token.with_ttl(ttl)
return token.to_jwt()
def generate_livekit_config( # noqa: PLR0917
def generate_livekit_config(
room_id: str,
user,
username: str,
@@ -512,6 +507,3 @@ def build_telephony_config():
"default_country": country,
"international_phone_number": international,
}
CACHE_SCAN_ITERSIZE = 500
@@ -677,7 +677,3 @@ msgstr "Niederländisch"
#: meet/settings.py:231
msgid "German"
msgstr "Deutsch"
#: meet/settings.py:233
msgid "Spanish"
msgstr "Spanisch"
@@ -672,7 +672,3 @@ msgstr "Dutch"
#: meet/settings.py:231
msgid "German"
msgstr "German"
#: meet/settings.py:233
msgid "Spanish"
msgstr "Spanish"
@@ -1,652 +0,0 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:67
msgid "Personal info"
msgstr "Datos Personales"
#: core/admin.py:80
msgid "Permissions"
msgstr "Permisos"
#: core/admin.py:92
msgid "Important dates"
msgstr "Fechas importantes"
#: core/admin.py:206
msgid "Content"
msgstr "Contenido"
#: core/admin.py:217
msgid "Deletion"
msgstr "Eliminar Salas"
#: core/admin.py:226
msgid "Derived info"
msgstr ""
#: core/admin.py:237
# 'Marcas de tiempo' is a bad translation for spanish
msgid "Timestamps"
msgstr ""
#: core/admin.py:240
msgid "File preview"
msgstr "Vista previa"
#: core/admin.py:300 core/admin.py:443
msgid "No owner"
msgstr "Sin propietario"
#: core/admin.py:303 core/admin.py:446
msgid "Multiple owners"
msgstr "Varios propietarios"
#: core/admin.py:316
msgid "Resend notification to external service"
msgstr "Reenviar la notificación al servicio externo"
#: core/admin.py:339
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Error al notificar la grabación %(id)s"
#: core/admin.py:347
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Error al notificar la grabación %(id)s: %(error)s"
#: core/admin.py:355
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Notificaciones enviadas correctamente para %(count)s grabación(es)."
#: core/admin.py:363
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "Se han omitido %(count)s grabación(es) caducada(s)."
#: core/admin.py:368
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Marcar las grabaciones seleccionadas como «Error al detener»"
#: core/admin.py:386
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s grabación(es) marcada(s) correctamente como «Error al detener»."
#: core/admin.py:394
#, python-format
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "Se han omitido %(count)s grabación(es) con un estado no elegible."
#: core/admin.py:510
msgid "No scopes"
msgstr ""
#: core/admin.py:512
msgid "Scopes"
msgstr "Ámbitos"
#: core/api/filters.py:25
msgid "Creator is me"
msgstr "Yo soy el creador"
#: core/api/serializers.py:89
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "Debes ser administrador o propietario de una reunión para añadirle accesos."
#: core/api/serializers.py:534
msgid "This file extension is not allowed."
msgstr "Esta extensión de archivo no está permitida."
#: core/api/viewsets.py:1222
msgid "You have reached the maximum number of files for this type."
msgstr "Has alcanzado el número máximo de archivos de este tipo."
#: core/models.py:37
msgid "Member"
msgstr "Miembro"
#: core/models.py:38
msgid "Administrator"
msgstr "Administrador"
#: core/models.py:39
msgid "Owner"
msgstr "Propietario"
#: core/models.py:55
# To check here and following lines for gender agreement (Masculine/Feminine)
msgid "Initiated"
msgstr "Iniciada"
#: core/models.py:56
msgid "Active"
msgstr "Activa"
#: core/models.py:57
msgid "Stopped"
msgstr "Detenida"
#: core/models.py:58
msgid "Saved"
msgstr "Guardada"
#: core/models.py:59
msgid "Aborted"
msgstr "Cancelada"
#: core/models.py:60
msgid "Failed to Start"
msgstr "Error al iniciar"
#: core/models.py:61
msgid "Failed to Stop"
msgstr "Error al detener"
#: core/models.py:62
msgid "Notification succeeded"
msgstr "Notificado correctamente"
#: core/models.py:65
msgid "External process successful"
msgstr "Proceso externo finalizado correctamente"
#: core/models.py:67
msgid "External process failed"
msgstr "Error en el Proceso externo"
#: core/models.py:96
msgid "SCREEN_RECORDING"
msgstr "GRABACIÓN_DE_PANTALLA"
#: core/models.py:97
msgid "TRANSCRIPT"
msgstr "TRANSCRIPCIÓN"
#: core/models.py:103
msgid "Public Access"
msgstr "Acceso público"
#: core/models.py:104
msgid "Trusted Access"
msgstr "Acceso usuarios autorizados"
#: core/models.py:105
msgid "Restricted Access"
msgstr "Acceso restringido"
#: core/models.py:117
msgid "id"
msgstr "id"
#: core/models.py:118
msgid "primary key for the record as UUID"
msgstr "clave primaria del registro en forma de UUID"
#: core/models.py:124
msgid "created on"
msgstr "creado el"
#: core/models.py:125
msgid "date and time at which a record was created"
msgstr "fecha y hora en que se creó un registro"
#: core/models.py:130
msgid "updated on"
msgstr "actualizado el"
#: core/models.py:131
msgid "date and time at which a record was last updated"
msgstr "fecha y hora de la última actualización de un registro"
#: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr "Introduce un sub válido. Este valor solo puede contener letras, números y los caracteres @/./+/-/_."
#: core/models.py:157
msgid "sub"
msgstr "sub"
#: core/models.py:159
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr "Opcional para los usuarios pendientes; obligatorio al activar la cuenta. 255 caracteres como máximo. Solo letras, números y los caracteres @/./+/-/_."
#: core/models.py:168
msgid "identity email address"
msgstr "dirección de correo electrónico"
#: core/models.py:173
msgid "admin email address"
msgstr "dirección de correo electrónico de administrador"
#: core/models.py:175
msgid "full name"
msgstr "nombre completo"
#: core/models.py:177
msgid "short name"
msgstr "nombre corto"
#: core/models.py:183
msgid "language"
msgstr "idioma"
#: core/models.py:184
msgid "The language in which the user wants to see the interface."
msgstr "El idioma preferido de interfaz."
#: core/models.py:190
msgid "The timezone in which the user wants to see times."
msgstr "La zona horaria en la que el usuario quiere ver las horas."
#: core/models.py:193
msgid "device"
msgstr "dispositivo"
#: core/models.py:195
msgid "Whether the user is a device or a real user."
msgstr "Si el usuario es un dispositivo o un usuario real."
#: core/models.py:198
msgid "staff status"
msgstr "estado del personal"
#: core/models.py:200
msgid "Whether the user can log into this admin site."
msgstr "Si el usuario puede acceder a este sitio de administración."
#: core/models.py:203
msgid "active"
msgstr "activo"
#: core/models.py:206
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr "Si este usuario debe considerarse activo. Desmarca esta opción en lugar de eliminar cuentas."
#: core/models.py:219
msgid "user"
msgstr "usuario"
#: core/models.py:220
msgid "users"
msgstr "usuarios"
#: core/models.py:286
msgid "Resource"
msgstr "Recurso"
#: core/models.py:287
msgid "Resources"
msgstr "Recursos"
#: core/models.py:345
msgid "Resource access"
msgstr "Acceso a recursos"
#: core/models.py:346
msgid "Resource accesses"
msgstr "Accesos a recursos"
#: core/models.py:352
msgid "Resource access with this User and Resource already exists."
msgstr "Ya existe un acceso al recurso con este usuario y este recurso."
#: core/models.py:409
msgid "Visio room configuration"
msgstr "Configuración de la videoconferencia"
#: core/models.py:410
msgid "Values for Visio parameters to configure the room."
msgstr "Valores de los parámetros de videoconferencia para configurar la reunión."
#: core/models.py:417
msgid "Room PIN code"
msgstr "Código PIN de la reunión"
#: core/models.py:418
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Código único de n dígitos que identifica esta reunión en modo telefónico."
#: core/models.py:424 core/models.py:578
msgid "Room"
msgstr "Reunión"
#: core/models.py:425
msgid "Rooms"
msgstr "Reuniones"
#: core/models.py:589
msgid "Worker ID"
msgstr "ID del worker"
#: 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."
msgstr "Introduce un identificador para la grabación del worker. Este identificador se conserva incluso cuando el worker se detiene, lo que permite un seguimiento sencillo."
#: core/models.py:599
msgid "Recording mode"
msgstr "Modo de grabación"
#: core/models.py:600
msgid "Defines the mode of recording being called."
msgstr "Define el modo de grabación a utilizar."
#: core/models.py:605 core/models.py:606
msgid "Recording options"
msgstr "Opciones de grabación"
#: core/models.py:613
msgid "External Process ID"
msgstr "ID del proceso externo"
#: core/models.py:614
msgid "ID of the external process associated with the recording."
msgstr "ID del proceso externo asociado a la grabación."
#: core/models.py:620
msgid "Recording"
msgstr "Grabación"
#: core/models.py:621
msgid "Recordings"
msgstr "Grabaciones"
#: core/models.py:731
msgid "Recording/user relation"
msgstr "Relación grabación/usuario"
#: core/models.py:732
msgid "Recording/user relations"
msgstr "Relaciones grabación/usuario"
#: core/models.py:738
msgid "This user is already in this recording."
msgstr "Este usuario ya está en esta grabación."
#: core/models.py:744
msgid "This team is already in this recording."
msgstr "Este equipo ya está en esta grabación."
#: core/models.py:750
msgid "Either user or team must be set, not both."
msgstr "Debe definirse el usuario o el equipo, pero no ambos."
#: core/models.py:767
msgid "Create rooms"
msgstr "Crear reunión"
#: core/models.py:768
msgid "List rooms"
msgstr "Listar reuniones"
#: core/models.py:769
msgid "Retrieve room details"
msgstr "Ver los detalles de una reunión"
#: core/models.py:770
msgid "Update rooms"
msgstr "Actualizar las reuniones"
#: core/models.py:771
msgid "Delete rooms"
msgstr "Eliminar las reuniones"
#: core/models.py:784
msgid "Application name"
msgstr "Nombre de la aplicación"
#: core/models.py:785
msgid "Descriptive name for this application."
msgstr "Nombre descriptivo de esta aplicación."
#: core/models.py:795
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Se cifra al guardar. Cópialo ahora si se trata de un secreto nuevo."
#: core/models.py:806
msgid "Application"
msgstr "Aplicación"
#: core/models.py:807
msgid "Applications"
msgstr "Aplicaciones"
#: core/models.py:830
msgid "Enter a valid domain"
msgstr "Introduce un dominio válido"
#: core/models.py:833
msgid "Domain"
msgstr "Dominio"
#: core/models.py:834
msgid "Email domain this application can act on behalf of."
msgstr "Dominio de correo electrónico en cuyo nombre puede actuar esta aplicación."
#: core/models.py:846
msgid "Application domain"
msgstr "Dominio de aplicación"
#: core/models.py:847
msgid "Application domains"
msgstr "Dominios de aplicación"
#: core/models.py:865
msgid "Pending"
msgstr "Pendiente"
#: core/models.py:866
msgid "Analyzing"
msgstr "Analizando"
#: core/models.py:873
msgid "Ready"
msgstr "Listo"
#: core/models.py:879
msgid "Background image"
msgstr "Imagen de fondo"
#: core/models.py:891
msgid "title"
msgstr "título"
#: core/models.py:915
msgid "Malware detection info when the analysis status is unsafe."
msgstr "Información sobre la detección de malware cuando el estado del análisis no es seguro."
#: core/models.py:920
msgid "File"
msgstr "Archivo"
#: core/models.py:921
msgid "Files"
msgstr "Archivos"
#: core/models.py:1041
msgid "This file is already hard deleted."
msgstr "Este archivo ya se ha eliminado definitivamente."
#: core/models.py:1051
msgid "To hard delete a file, it must first be soft deleted."
msgstr "Para eliminar definitivamente un archivo, primero debe haberse marcado como eliminado."
#: core/recording/event/notification.py:123
msgid "Your recording is ready"
msgstr "Tu grabación está lista"
#: core/recording/event/notification.py:194
msgid "Transcription"
msgstr "Transcripción"
#: core/recording/event/notification.py:204
#, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr "Reunión \"{room}\" del {room_recording_date} a las {room_recording_time}"
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Videollamada en curso: {sender.email} está esperando a que te conectes"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email"
msgstr "Logotipo del correo"
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call"
msgstr "te invita a unirte a una videollamada en curso"
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "UNIRSE A LA LLAMADA"
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
msgid ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
msgstr "Si no puedes hacer clic en el botón, copia y pega la URL en tu navegador para unirte a la llamada."
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:"
msgstr "Consejos para una mejor experiencia:"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality"
msgstr "Usa Chrome o Firefox para una mejor calidad de llamada"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining"
msgstr "Prueba tu micrófono y tu cámara antes de unirte"
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Asegúrate de tener una conexión a internet estable"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:23
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Gracias por usar %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr "¡Tu grabación está lista!"
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
msgstr " Tu grabación de \"%(room_name)s\" del %(recording_date)s a las %(recording_time)s ya está lista para descargar. "
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid " The recording will expire in %(days)s days. "
msgstr " La grabación caducará dentro de %(days)s días. "
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr " Compartir la grabación mediante un enlace todavía no está disponible. Solo los organizadores pueden descargarla. "
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:"
msgstr "Para conservar esta grabación de forma permanente:"
#: core/templates/mail/html/screen_recording.html:208
#, python-format
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Haz clic en el enlace \"<a href=\"%(link)s\">Abrir</a>\" que aparece abajo "
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface "
msgstr "Usa el botón \"Descargar\" de la interfaz "
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location"
msgstr "Guarda el archivo en la ubicación que prefieras"
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
msgid "Open"
msgstr "Abrir"
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
msgstr " Si tienes alguna pregunta o necesitas ayuda, ponte en contacto con nuestro equipo de soporte en %(support_email)s. "
#: core/templates/mail/text/screen_recording.txt:13
#, python-format
msgid "Click the \"Open [%(link)s]\" link below "
msgstr "Haz clic en el enlace \"Abrir [%(link)s]\" que aparece abajo "
#: meet/settings.py:228
msgid "English"
msgstr "Inglés"
#: meet/settings.py:229
msgid "French"
msgstr "Francés"
#: meet/settings.py:230
msgid "Dutch"
msgstr "Neerlandés"
#: meet/settings.py:231
msgid "German"
msgstr "Alemán"
#: meet/settings.py:233
msgid "Spanish"
msgstr "Español"
@@ -678,7 +678,3 @@ msgstr "Néerlandais"
#: meet/settings.py:231
msgid "German"
msgstr "Allemand"
#: meet/settings.py:233
msgid "Spanish"
msgstr "Espagnol"
@@ -672,7 +672,3 @@ msgstr "Nederlands"
#: meet/settings.py:231
msgid "German"
msgstr "Duits"
#: meet/settings.py:233
msgid "Spanish"
msgstr "Spaans"
+78 -65
View File
@@ -230,7 +230,6 @@ class Base(Configuration):
("fr-fr", _("French")),
("nl-nl", _("Dutch")),
("de-de", _("German")),
("es-es", _("Spanish")),
)
)
@@ -325,6 +324,7 @@ class Base(Configuration):
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"core.authentication.user_token.UserAccessJWTAuthentication",
"core.authentication.backends.SessionAuthenticationWith401",
),
"DEFAULT_PARSER_CLASSES": [
@@ -345,21 +345,16 @@ class Base(Configuration):
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
environ_prefix=None,
),
"exchange_access_token": values.Value(
default="30/minute",
environ_name="EXCHANGE_ACCESS_TOKEN_THROTTLE_RATES",
environ_prefix=None,
),
"creation_callback": values.Value(
default="600/minute",
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 = (
@@ -666,30 +661,6 @@ 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
)
@@ -730,6 +701,20 @@ class Base(Configuration):
environ_name="RECORDING_WORKER_CLASSES",
environ_prefix=None,
)
RECORDING_EVENT_PARSER_CLASS = values.Value(
"core.recording.event.parsers.MinioParser",
environ_name="RECORDING_EVENT_PARSER_CLASS",
environ_prefix=None,
)
RECORDING_ENABLE_STORAGE_EVENT_AUTH = values.BooleanValue(
True, environ_name="RECORDING_ENABLE_STORAGE_EVENT_AUTH", environ_prefix=None
)
RECORDING_STORAGE_EVENT_ENABLE = values.BooleanValue(
False, environ_name="RECORDING_STORAGE_EVENT_ENABLE", environ_prefix=None
)
RECORDING_STORAGE_EVENT_TOKEN = SecretFileValue(
None, environ_name="RECORDING_STORAGE_EVENT_TOKEN", environ_prefix=None
)
# Number of days before recordings expire - must be synced with bucket lifecycle policy
# Set to None for no expiration
RECORDING_EXPIRATION_DAYS = values.IntegerValue(
@@ -843,15 +828,6 @@ class Base(Configuration):
)
# Lobby configurations
PRESENCE_KEY_PREFIX = values.Value(
"room_presence", environ_name="PRESENCE_KEY_PREFIX", environ_prefix=None
)
PRESENCE_CACHE_TIMEOUT = values.PositiveIntegerValue(
3600, environ_name="PRESENCE_CACHE_TIMEOUT", environ_prefix=None
)
PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = values.BooleanValue(
True, environ_name="PRESENCE_CLEAR_ON_PARTICIPANT_LEFT", environ_prefix=None
)
LOBBY_KEY_PREFIX = values.Value(
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
)
@@ -871,11 +847,6 @@ class Base(Configuration):
environ_name="LOBBY_NOTIFICATION_TYPE",
environ_prefix=None,
)
LOBBY_COOKIE_NAME = values.Value(
"lobbyParticipantId",
environ_name="LOBBY_COOKIE_NAME",
environ_prefix=None,
)
# Calendar integrations
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
@@ -911,21 +882,6 @@ 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
@@ -998,6 +954,61 @@ class Base(Configuration):
environ_name="APPLICATION_BASE_URL",
environ_prefix=None,
)
# User access tokens (embedded frontend / iframe support)
USER_ACCESS_TOKEN_ENABLED = values.BooleanValue(
False, environ_name="USER_ACCESS_TOKEN_ENABLED", environ_prefix=None
)
USER_ACCESS_TOKEN_SECRET_KEY = SecretFileValue(
None, environ_name="USER_ACCESS_TOKEN_SECRET_KEY", environ_prefix=None
)
USER_ACCESS_TOKEN_ALG = values.Value(
"HS256",
environ_name="USER_ACCESS_TOKEN_ALG",
environ_prefix=None,
)
USER_ACCESS_TOKEN_ISSUER = values.Value(
"lasuite-meet",
environ_name="USER_ACCESS_TOKEN_ISSUER",
environ_prefix=None,
)
USER_ACCESS_TOKEN_AUDIENCE = values.Value(
None,
environ_name="USER_ACCESS_TOKEN_AUDIENCE",
environ_prefix=None,
)
# Lifetime of the user access token obtained through the exchange
# endpoint. It never transits through a URL, so it can cover a full
# meeting (default: 2 hours).
USER_ACCESS_TOKEN_TTL = values.PositiveIntegerValue(
7200,
environ_name="USER_ACCESS_TOKEN_TTL",
environ_prefix=None,
)
# Lifetime of the single-use transit code handed to the frontend
# through a URL fragment. Kept very short by design: it must only
# survive the redirect and the exchange call.
TRANSIT_CODE_TTL = values.PositiveIntegerValue(
60,
environ_name="TRANSIT_CODE_TTL",
environ_prefix=None,
)
TRANSIT_CODE_CACHE_PREFIX = values.Value(
"transit-code",
environ_name="TRANSIT_CODE_CACHE_PREFIX",
environ_prefix=None,
)
# Number of random bytes per code (48 bytes -> 64 url-safe characters)
TRANSIT_CODE_NBYTES = values.PositiveIntegerValue(
48,
environ_name="TRANSIT_CODE_NBYTES",
environ_prefix=None,
)
USER_ACCESS_TOKEN_TYPE = values.Value(
"Bearer",
environ_name="USER_ACCESS_TOKEN_TYPE",
environ_prefix=None,
)
# Warning: EXTERNAL_API_ALLOW_PUBLIC_ACCESS is ignored when
# EXTERNAL_API_DEFAULT_ACCESS_LEVEL=public.
EXTERNAL_API_ALLOW_PUBLIC_ACCESS = values.BooleanValue(
@@ -1295,7 +1306,9 @@ 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
USER_ACCESS_TOKEN_ENABLED = True
USER_ACCESS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-room" # noqa:S105
USER_ACCESS_TOKEN_AUDIENCE = "Test inc." # noqa:S105
def __init__(self):
# pylint: disable=invalid-name
+16 -17
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.29.0"
version = "1.24.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -24,7 +24,7 @@ keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = "MIT"
requires-python = ">=3.13"
dependencies = [
"boto3==1.43.56",
"boto3==1.43.36",
"Brotli==1.2.0",
"brevo-python==1.2.0",
"celery[redis]==5.6.3",
@@ -32,7 +32,7 @@ dependencies = [
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==9.0.0",
"django-filter==26.1",
"django-filter==25.2",
"django-lasuite[all]==0.0.27",
"django-parler==2.4",
"redis==5.2.1",
@@ -40,9 +40,9 @@ dependencies = [
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django-pydantic-field==0.5.4",
"django==5.2.16",
"django==5.2.14",
"djangorestframework==3.17.1",
"drf_spectacular==0.30.0",
"drf_spectacular==0.29.0",
"dockerflow==2026.3.4",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
@@ -50,21 +50,20 @@ dependencies = [
"jsonschema==4.26.0",
"markdown==3.10.2",
"nested-multipart-parser==1.6.0",
"posthog==7.29.0",
"posthog==7.16.1",
"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.66.1",
"sentry-sdk==2.63.0",
"whitenoise==6.12.0",
"mozilla-django-oidc==5.0.2",
"livekit-api==1.2.0",
"aiohttp==3.14.3",
"livekit-api==1.1.1",
"aiohttp==3.14.1",
"urllib3==2.7.0",
"phonenumbers==9.0.34",
"cryptography==50.0.0", # CVE-2026-69247
"phonenumbers==9.0.33",
]
[project.urls]
@@ -76,21 +75,21 @@ dependencies = [
[dependency-groups]
dev = [
"django-extensions==4.1",
"drf-spectacular-sidecar==2026.7.1",
"drf-spectacular-sidecar==2026.6.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.15.0",
"ipython==9.14.1",
"pyfakefs==6.2.0",
"pylint-django==2.8.0",
"pylint-django==2.7.0",
"pylint<4.0.0",
"pytest-cov==7.1.0",
"pytest-django==4.12.0",
"pytest==9.1.1",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.26.2",
"ruff==0.16.0",
"types-requests==2.33.0.20260712",
"responses==0.26.1",
"ruff==0.15.19",
"types-requests==2.33.0.20260518",
]
[tool.uv.build-backend]
+190 -193
View File
@@ -13,7 +13,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.14.3"
version = "3.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -24,72 +24,72 @@ dependencies = [
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" },
{ url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" },
{ url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" },
{ url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" },
{ url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" },
{ url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" },
{ url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" },
{ url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" },
{ url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" },
{ url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" },
{ url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" },
{ url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" },
{ url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" },
{ url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" },
{ url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" },
{ url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" },
{ url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" },
{ url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" },
{ url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" },
{ url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" },
{ url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" },
{ url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" },
{ url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" },
{ url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" },
{ url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" },
{ url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" },
{ url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" },
{ url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" },
{ url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" },
{ url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" },
{ url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" },
{ url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" },
{ url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" },
{ url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" },
{ url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" },
{ url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" },
{ url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" },
{ url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" },
{ url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" },
{ url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" },
{ url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" },
{ url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" },
{ url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" },
{ url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" },
{ url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" },
{ url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" },
{ url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" },
{ url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" },
{ url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" },
{ url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" },
{ url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" },
{ url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" },
{ url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" },
{ url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" },
{ url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" },
{ url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" },
{ url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" },
{ url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" },
{ url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" },
{ url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" },
{ url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" },
{ url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" },
{ url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" },
{ url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" },
{ url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" },
{ url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" },
{ url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" },
{ url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" },
{ url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" },
{ url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" },
{ url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" },
{ url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" },
{ url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" },
{ url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" },
{ url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" },
{ url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" },
{ url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" },
{ url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" },
{ url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" },
{ url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" },
{ url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" },
{ url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" },
{ url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" },
{ url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" },
{ url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" },
{ url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" },
{ url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" },
{ url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" },
{ url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" },
{ url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" },
{ url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" },
{ url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" },
{ url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" },
{ url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" },
{ url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" },
{ url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" },
{ url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" },
{ url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" },
{ url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" },
{ url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" },
{ url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" },
{ url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" },
{ url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" },
{ url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" },
{ url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" },
{ url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" },
{ url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" },
{ url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" },
{ url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" },
{ url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" },
{ url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" },
{ url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" },
{ url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" },
{ url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" },
{ url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" },
{ url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" },
]
[[package]]
@@ -181,30 +181,30 @@ wheels = [
[[package]]
name = "boto3"
version = "1.43.56"
version = "1.43.36"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/05/23e1aa8c9e4b0399a61e7fd65c4f9cc0625121f24760e37471f776404abb/boto3-1.43.56.tar.gz", hash = "sha256:57c90df9fb026f2e6ae22530861198130203733c5c9ec4e5cca3a4037f5a8db4", size = 112673, upload-time = "2026-07-24T19:31:48.606Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ff/9f/897287e955db0f50b12fd69ef45956e4fd2c7ddb48c736872f7ea2314443/boto3-1.43.36.tar.gz", hash = "sha256:587d7ee92a12e440ad12b0e7f11f3358f0c4d65b19f64726efc94aaf194aff28", size = 112690, upload-time = "2026-06-23T02:47:14.561Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/57/3a960c9f581c00f2a591901b46e035ff79ab3956d16607f12306b3b8d483/boto3-1.43.56-py3-none-any.whl", hash = "sha256:feb699d4ab241ef5c1b80bb58277be2aaad365cd4b672d7817e0bc59ee45131b", size = 140026, upload-time = "2026-07-24T19:31:47.155Z" },
{ url = "https://files.pythonhosted.org/packages/9f/f1/274303f52483ecf199eae6f8d9b6f5951670397ee4d72c06cfd4eb644612/boto3-1.43.36-py3-none-any.whl", hash = "sha256:42942dde254673abcbc9e6e60017c88341a4f49d99d24e1f2e290fb38138c26f", size = 140031, upload-time = "2026-06-23T02:47:13.178Z" },
]
[[package]]
name = "botocore"
version = "1.43.62"
version = "1.43.40"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/36af6d99269a701f83809b87a01f4728699eb825ebdedee3a3d515b18f61/botocore-1.43.62.tar.gz", hash = "sha256:94efc419c9f0f41dc2415e4b6b62f04ae21b3ce3930fac47214c4d3f361ea8b8", size = 15818261, upload-time = "2026-07-31T19:35:06.235Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/50/269986277f852cc83029bccbdcdc0b343a685cfd570599e58029792808d8/botocore-1.43.40.tar.gz", hash = "sha256:2085a4314cfd2c8bc1d08ab8039f76c92e99278db0d2a0e2437010526d5d5d70", size = 15639899, upload-time = "2026-07-03T00:28:16.125Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/65/d5dae96de68ffc55acf87c3bae76e9dabeeca92aadf1223f20e9a7860aef/botocore-1.43.62-py3-none-any.whl", hash = "sha256:76de153de1ba3e242b2e6df6a13ab8a3fb35d17db562462969e661457b63166e", size = 15502622, upload-time = "2026-07-31T19:35:02.697Z" },
{ url = "https://files.pythonhosted.org/packages/dc/8c/5f7e73fd66b28f0705bc55d7060d41ef72328b656b86ca53e75765b3ba2c/botocore-1.43.40-py3-none-any.whl", hash = "sha256:0bc9d352267c9e48415c5d7bb61ff05c3f193eac2fc7e69cfd229a05fbab67d6", size = 15323870, upload-time = "2026-07-03T00:28:12.56Z" },
]
[[package]]
@@ -497,52 +497,52 @@ wheels = [
[[package]]
name = "cryptography"
version = "50.0.0"
version = "49.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" },
{ url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" },
{ url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" },
{ url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" },
{ url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" },
{ url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" },
{ url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" },
{ url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" },
{ url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" },
{ url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" },
{ url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" },
{ url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" },
{ url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" },
{ url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" },
{ url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" },
{ url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" },
{ url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" },
{ url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" },
{ url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" },
{ url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" },
{ url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" },
{ url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" },
{ url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" },
{ url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" },
{ url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" },
{ url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" },
{ url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" },
{ url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" },
{ url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" },
{ url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" },
{ url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" },
{ url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" },
{ url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" },
{ url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" },
{ url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" },
{ url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" },
{ url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" },
{ url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
{ url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
{ url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
{ url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
{ url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
{ url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
{ url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
{ url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
{ url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
{ url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
{ url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
{ url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
{ url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
{ url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
{ url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
{ url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
{ url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
{ url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
{ url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
{ url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
{ url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
{ url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
{ url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
{ url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
{ url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
{ url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
{ url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
{ url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
{ url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
{ url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
{ url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
]
[[package]]
@@ -586,16 +586,16 @@ wheels = [
[[package]]
name = "django"
version = "5.2.16"
version = "5.2.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" }
sdist = { url = "https://files.pythonhosted.org/packages/65/95/95f7faa0950867afaa0bef2460c6263afd6a2c78cc9434046ed28160b015/django-5.2.14.tar.gz", hash = "sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2", size = 10895118, upload-time = "2026-05-05T13:57:31.104Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/13/1e5e3e4c15dcecb04281b3cb2a46a4670e1cef131068e202f6040df19224/django-5.2.16-py3-none-any.whl", hash = "sha256:04f354bf9d807a86ad1a8392fe3808d362358a8eafc322848e0e43e59b24371d", size = 8311943, upload-time = "2026-07-07T13:52:11.223Z" },
{ url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" },
]
[[package]]
@@ -650,14 +650,14 @@ wheels = [
[[package]]
name = "django-filter"
version = "26.1"
version = "25.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/3e/563965173d4cbb5fc308087e7b3d11a115b7b67273d093622480b1e31f78/django_filter-26.1.tar.gz", hash = "sha256:66ea04031b068c77c86e1ac26ced7a3f8f13ce797f5795751707e3deefc58054", size = 144299, upload-time = "2026-07-11T09:27:02.767Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/e4/465d2699cd388c0005fb8d6ae6709f239917c6d8790ac35719676fffdcf3/django_filter-25.2.tar.gz", hash = "sha256:760e984a931f4468d096f5541787efb8998c61217b73006163bf2f9523fe8f23", size = 143818, upload-time = "2025-10-05T09:51:31.521Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/01/afffed1e3c4540fb75bf550a18b6176a9f6371b5f3e52b69a28995b6480c/django_filter-26.1-py3-none-any.whl", hash = "sha256:7d98ef2899218e6242619b532cb1b95af14e09dfcf74844aecb550ad27b59ff2", size = 94069, upload-time = "2026-07-11T09:27:01.012Z" },
{ url = "https://files.pythonhosted.org/packages/c1/40/6a02495c5658beb1f31eb09952d8aa12ef3c2a66342331ce3a35f7132439/django_filter-25.2-py3-none-any.whl", hash = "sha256:9c0f8609057309bba611062fe1b720b4a873652541192d232dd28970383633e3", size = 94145, upload-time = "2025-10-05T09:51:29.728Z" },
]
[[package]]
@@ -775,7 +775,7 @@ wheels = [
[[package]]
name = "drf-spectacular"
version = "0.30.0"
version = "0.29.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
@@ -785,21 +785,21 @@ dependencies = [
{ name = "pyyaml" },
{ name = "uritemplate" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/43/41d25039a6a53545420ebc98eb9f877ec9fe30c7bd03fefabcaf9b953af7/drf_spectacular-0.30.0.tar.gz", hash = "sha256:53e79e7ba00e240441b63c32273754a5368e4c2ab44a19f2595277cc1cd559c9", size = 252311, upload-time = "2026-07-06T11:29:46.264Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/0e/a4f50d83e76cbe797eda88fc0083c8ca970cfa362b5586359ef06ec6f70a/drf_spectacular-0.29.0.tar.gz", hash = "sha256:0a069339ea390ce7f14a75e8b5af4a0860a46e833fd4af027411a3e94fc1a0cc", size = 241722, upload-time = "2025-11-02T03:40:26.348Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/56/74dd7b45bbde6d24494220b98d6961cb1200b63a1800332b430daa2c4551/drf_spectacular-0.30.0-py3-none-any.whl", hash = "sha256:006cf5921ebe20a9bd24f7c846261ebbf78780be5961b0d6e87afaa82afd62ff", size = 111150, upload-time = "2026-07-06T11:29:45.12Z" },
{ url = "https://files.pythonhosted.org/packages/32/d9/502c56fc3ca960075d00956283f1c44e8cafe433dada03f9ed2821f3073b/drf_spectacular-0.29.0-py3-none-any.whl", hash = "sha256:d1ee7c9535d89848affb4427347f7c4a22c5d22530b8842ef133d7b72e19b41a", size = 105433, upload-time = "2025-11-02T03:40:24.823Z" },
]
[[package]]
name = "drf-spectacular-sidecar"
version = "2026.7.1"
version = "2026.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/51/9e038d14bf51a0bd051e8bcb690287349c908fa7ba69021d9f3e5d5ac51f/drf_spectacular_sidecar-2026.7.1.tar.gz", hash = "sha256:40113c4066c7bc3ef15a7ce1c40cda227a907a9986748024a813a9e0595eba25", size = 2593211, upload-time = "2026-07-01T13:39:06.084Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7f/d8/735b129e7d55c4f6c682ecedfcc0438816e1d859e5e1837f914ac4544f6d/drf_spectacular_sidecar-2026.6.1.tar.gz", hash = "sha256:e159874fa85ccee39b801e260f2a3585fbe36a0c79bf811824eef9010ab98ea9", size = 2589761, upload-time = "2026-06-01T16:45:30.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/76/d08f5c79f7643dbff4512605c28b75481966ed6c8cc9b397c9dd2ee91cd1/drf_spectacular_sidecar-2026.7.1-py3-none-any.whl", hash = "sha256:bc6d50c9b64660e45e09296d39553b3e759eedd825fc41d631ee5b3f88e0c5de", size = 2617384, upload-time = "2026-07-01T13:39:03.787Z" },
{ url = "https://files.pythonhosted.org/packages/4f/46/10bcaf965edcb70e7647e62b88b9e0de266f8bea7a7a3224e8f977f77eab/drf_spectacular_sidecar-2026.6.1-py3-none-any.whl", hash = "sha256:4560572773c7e5f636d36cd2903204e2c59560af0548da254e311f94458c51a2", size = 2613235, upload-time = "2026-06-01T16:45:29.031Z" },
]
[[package]]
@@ -1005,7 +1005,7 @@ wheels = [
[[package]]
name = "ipython"
version = "9.15.0"
version = "9.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -1015,14 +1015,14 @@ dependencies = [
{ name = "matplotlib-inline" },
{ name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "prompt-toolkit" },
{ name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" },
{ name = "psutil", marker = "sys_platform != 'emscripten'" },
{ name = "pygments" },
{ name = "stack-data" },
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e2/23/3a27530575643c8bb7bfc757a28e2e7ef80092afbf59a2bc5716320b6602/ipython-9.14.1.tar.gz", hash = "sha256:f913bf74df06d458e46ced84ca506c23797590d594b236fe60b14df213291e7b", size = 4433457, upload-time = "2026-06-05T08:12:34.921Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/3a/948263ca3b9d65bb2b1b0c521b3a49fad5d59ada58724bd87d2bd5ff3f36/ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e", size = 630895, upload-time = "2026-06-26T11:03:33.809Z" },
{ url = "https://files.pythonhosted.org/packages/9d/22/58818a63eaf8982b67632b1bc20585c811611b15a8da19d6012323dc76a5/ipython-9.14.1-py3-none-any.whl", hash = "sha256:5d4a9ecaa3b10e6e5f269dd0948bdb58ca9cb851899cd23e07c320d3eb11613c", size = 627770, upload-time = "2026-06-05T08:12:33.045Z" },
]
[[package]]
@@ -1128,7 +1128,7 @@ redis = [
[[package]]
name = "livekit-api"
version = "1.2.0"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -1137,22 +1137,22 @@ dependencies = [
{ name = "pyjwt" },
{ name = "types-protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/19/36ff6712ec638a4b7dad4d8f03795952e401dc31db0b04cddec7892650da/livekit_api-1.2.0.tar.gz", hash = "sha256:a89817b3bca9584873786ff07209839308217537a42f95ecb2609aafaa109ddc", size = 20778, upload-time = "2026-07-11T23:20:54.781Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/03/00e0ec173f247e1f7ea63cb5591d5680a64c7a74ea4d5d558e5aed6cc399/livekit_api-1.1.1.tar.gz", hash = "sha256:70c7b80eecbc297b40756ebd76e4f52d00b0348fb7d212a21c1f69cc57fd9c83", size = 15196, upload-time = "2026-06-24T01:36:19.686Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/e7/8926f16d4bc1b2e0ae46d4a507321bb899396d263a757f1adaabcd3b3867/livekit_api-1.2.0-py3-none-any.whl", hash = "sha256:307f8e5cfb0358c3ca091814ab768af55896022151bcd7f951954ccefa036a24", size = 26499, upload-time = "2026-07-11T23:20:53.736Z" },
{ url = "https://files.pythonhosted.org/packages/1e/c0/d5f3ff74ab5db2d06f173801ec934885d11a754b9fb9ad768c8ede0a6c89/livekit_api-1.1.1-py3-none-any.whl", hash = "sha256:ce8c327676c366e66cf68782934368dd0ba92b9d48f578275227e255c890fe88", size = 19471, upload-time = "2026-06-24T01:36:18.42Z" },
]
[[package]]
name = "livekit-protocol"
version = "1.1.21"
version = "1.1.18"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
{ name = "types-protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7f/ae/9d60fe37d85623e68a2e36ae31d18c671949db67d2a13a6438410d930466/livekit_protocol-1.1.21.tar.gz", hash = "sha256:8bb1ac1aba5d37d0af43e9d56d129a5d16295cbd91518b00fd157e258f20a6ef", size = 122363, upload-time = "2026-07-21T18:28:26.372Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/88/64f2be01a630e249f1dbd0d51876f109b53b7899ae41246d2ca5b647086d/livekit_protocol-1.1.18.tar.gz", hash = "sha256:187af32ebf75333a62117b0db9e551c99060bd4e1f57cfc0fce73bcd7a671da8", size = 115802, upload-time = "2026-06-27T15:31:04.102Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/d2/ec10b1cdf912235c2b07898be6ba2535e50f23e8980bb719f29decbd8034/livekit_protocol-1.1.21-py3-none-any.whl", hash = "sha256:ce0bb763327c91349ee8831843c4f8bd72132c4d06ac572171f2ae5c0217211d", size = 149245, upload-time = "2026-07-21T18:28:24.985Z" },
{ url = "https://files.pythonhosted.org/packages/14/80/9cc33e4d0280132538850aaf9559d6b8aa9e670c5917f75dab996400ab84/livekit_protocol-1.1.18-py3-none-any.whl", hash = "sha256:30c539410fd3cfc2e551ca3a193aaaaacaaec6dd57dabe2c9be7c7c7d15f0e01", size = 143134, upload-time = "2026-06-27T15:31:02.686Z" },
]
[[package]]
@@ -1187,7 +1187,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.29.0"
version = "1.24.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -1195,7 +1195,6 @@ dependencies = [
{ name = "brevo-python" },
{ name = "brotli" },
{ name = "celery", extra = ["redis"] },
{ name = "cryptography" },
{ name = "dj-database-url" },
{ name = "django" },
{ name = "django-configurations" },
@@ -1255,18 +1254,17 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = "==3.14.3" },
{ name = "boto3", specifier = "==1.43.56" },
{ name = "aiohttp", specifier = "==3.14.1" },
{ name = "boto3", specifier = "==1.43.36" },
{ name = "brevo-python", specifier = "==1.2.0" },
{ name = "brotli", specifier = "==1.2.0" },
{ name = "celery", extras = ["redis"], specifier = "==5.6.3" },
{ name = "cryptography", specifier = "==50.0.0" },
{ name = "dj-database-url", specifier = "==3.1.2" },
{ name = "django", specifier = "==5.2.16" },
{ name = "django", specifier = "==5.2.14" },
{ name = "django-configurations", specifier = "==2.5.1" },
{ name = "django-cors-headers", specifier = "==4.9.0" },
{ name = "django-countries", specifier = "==9.0.0" },
{ name = "django-filter", specifier = "==26.1" },
{ name = "django-filter", specifier = "==25.2" },
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.27" },
{ name = "django-parler", specifier = "==2.4" },
{ name = "django-pydantic-field", specifier = "==0.5.4" },
@@ -1275,17 +1273,17 @@ requires-dist = [
{ name = "django-timezone-field", specifier = ">=5.1" },
{ name = "djangorestframework", specifier = "==3.17.1" },
{ name = "dockerflow", specifier = "==2026.3.4" },
{ name = "drf-spectacular", specifier = "==0.30.0" },
{ name = "drf-spectacular", specifier = "==0.29.0" },
{ name = "easy-thumbnails", specifier = "==2.10.1" },
{ name = "factory-boy", specifier = "==3.3.3" },
{ name = "gunicorn", specifier = "==26.0.0" },
{ name = "jsonschema", specifier = "==4.26.0" },
{ name = "livekit-api", specifier = "==1.2.0" },
{ name = "livekit-api", specifier = "==1.1.1" },
{ name = "markdown", specifier = "==3.10.2" },
{ name = "mozilla-django-oidc", specifier = "==5.0.2" },
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
{ name = "phonenumbers", specifier = "==9.0.34" },
{ name = "posthog", specifier = "==7.29.0" },
{ name = "phonenumbers", specifier = "==9.0.33" },
{ name = "posthog", specifier = "==7.16.1" },
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.4" },
{ name = "pydantic", specifier = "==2.13.4" },
{ name = "pyjwt", specifier = "==2.13.0" },
@@ -1293,7 +1291,7 @@ requires-dist = [
{ name = "python-magic", specifier = "==0.4.27" },
{ name = "redis", specifier = "==5.2.1" },
{ name = "requests", specifier = "==2.34.2" },
{ name = "sentry-sdk", specifier = "==2.66.1" },
{ name = "sentry-sdk", specifier = "==2.63.0" },
{ name = "urllib3", specifier = "==2.7.0" },
{ name = "whitenoise", specifier = "==6.12.0" },
]
@@ -1301,21 +1299,21 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "django-extensions", specifier = "==4.1" },
{ name = "drf-spectacular-sidecar", specifier = "==2026.7.1" },
{ name = "drf-spectacular-sidecar", specifier = "==2026.6.1" },
{ name = "freezegun", specifier = "==1.5.5" },
{ name = "ipdb", specifier = "==0.13.13" },
{ name = "ipython", specifier = "==9.15.0" },
{ name = "ipython", specifier = "==9.14.1" },
{ name = "pyfakefs", specifier = "==6.2.0" },
{ name = "pylint", specifier = "<4.0.0" },
{ name = "pylint-django", specifier = "==2.8.0" },
{ name = "pylint-django", specifier = "==2.7.0" },
{ name = "pytest", specifier = "==9.1.1" },
{ name = "pytest-cov", specifier = "==7.1.0" },
{ name = "pytest-django", specifier = "==4.12.0" },
{ name = "pytest-icdiff", specifier = "==0.9" },
{ name = "pytest-xdist", specifier = "==3.8.0" },
{ name = "responses", specifier = "==0.26.2" },
{ name = "ruff", specifier = "==0.16.0" },
{ name = "types-requests", specifier = "==2.33.0.20260712" },
{ name = "responses", specifier = "==0.26.1" },
{ name = "ruff", specifier = "==0.15.19" },
{ name = "types-requests", specifier = "==2.33.0.20260518" },
]
[[package]]
@@ -1452,11 +1450,11 @@ wheels = [
[[package]]
name = "phonenumbers"
version = "9.0.34"
version = "9.0.33"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/86/c3/e154829a50679c38ae28ec9c4f151f2c425db5e70fd445266e76f6d6cd65/phonenumbers-9.0.34.tar.gz", hash = "sha256:00751c75d1166485ca80ce02ec15b6a61a2628e9b313381579330bc70c934075", size = 2306776, upload-time = "2026-07-03T06:30:37.358Z" }
sdist = { url = "https://files.pythonhosted.org/packages/75/37/dfc4cf24169f1a7169ebaedaf896c818f0add8603409d1e748e3085ccdc0/phonenumbers-9.0.33.tar.gz", hash = "sha256:9ab8a02b940b90c64f3866c0b25a30e567ddf7bb9836a3e11efdb0478f65fc1c", size = 2306756, upload-time = "2026-06-22T10:23:33.428Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/0a/3a7980f3b071dde9a297d85cf6b18ba4bc2e5024e1c453b3da8a1dc14268/phonenumbers-9.0.34-py2.py3-none-any.whl", hash = "sha256:1221bf8e65bd2c02770226488af806d4636814bc997104d3a1f7de6ed6410bd2", size = 2595344, upload-time = "2026-07-03T06:30:33.913Z" },
{ url = "https://files.pythonhosted.org/packages/e6/29/f7e30e3dbd3c7e3d9c4a55006112c04ee62b4765a31f21bcc28c253ac3f1/phonenumbers-9.0.33-py2.py3-none-any.whl", hash = "sha256:ba1d0da52711d5fdda6b2b673b2621fe80774fc5d1b2e5a6ef783396b0343186", size = 2595422, upload-time = "2026-06-22T10:23:29.925Z" },
]
[[package]]
@@ -1541,7 +1539,7 @@ wheels = [
[[package]]
name = "posthog"
version = "7.29.0"
version = "7.16.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backoff" },
@@ -1549,9 +1547,9 @@ dependencies = [
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/09/43ae0e27bafe031d307921cdeadcd3af8a7370c887177df7c43cfd903adf/posthog-7.29.0.tar.gz", hash = "sha256:673f201e2d204f0664bb0cec86f8adfca97ba3a94488fb7857bb11c4a35fb017", size = 360483, upload-time = "2026-07-23T15:26:28.225Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/4f/a954175c862a3565d02c3f627874d85f18313472a0c4b08f45d84aaf3315/posthog-7.16.1.tar.gz", hash = "sha256:3619d3c619ad01f36c6d465e084950882417c63021eb3cfacacb23f900ec52d4", size = 226343, upload-time = "2026-05-27T18:46:20.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/4a/01c3e9f44a2f167977b5fafc9ba5b4ed5a027881da4b2ee1f4987a621758/posthog-7.29.0-py3-none-any.whl", hash = "sha256:8269afec8439e0177fd9d660b88d0a580497b617ee6aa213850ae9244ad46111", size = 429592, upload-time = "2026-07-23T15:26:26.343Z" },
{ url = "https://files.pythonhosted.org/packages/3e/28/0f840699a1d0db3c1e5483c6208f0804a51f21ccfa34e6aa356161606adc/posthog-7.16.1-py3-none-any.whl", hash = "sha256:fd5aa4510033f3b039fda2fbfce45f493d140d4782f681e69639793dda317d67", size = 264231, upload-time = "2026-05-27T18:46:17.933Z" },
]
[[package]]
@@ -1886,15 +1884,14 @@ wheels = [
[[package]]
name = "pylint-django"
version = "2.8.0"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pylint" },
{ name = "pylint-plugin-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/a1/b92e5d5cf320b603c9bcc5174da7e9ba4c6ce71087354322a2b83536df13/pylint_django-2.8.0.tar.gz", hash = "sha256:42accea9098e4a3298b4bfbae0e4da81f909f8bff0deda9485efbd6035a86d6a", size = 32038, upload-time = "2026-07-11T10:19:14.844Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/4a/3dae8a09e12a28ccf3d7204cc4fb69f28dfb30d2b19567eb5ff094fe1265/pylint_django-2.8.0-py3-none-any.whl", hash = "sha256:706eb2cc8d7692236be9fd033a341042afe3bbbf99df9234a659db931016ef5d", size = 44672, upload-time = "2026-07-11T10:05:29.281Z" },
{ url = "https://files.pythonhosted.org/packages/e5/0d/d775fec0dde8ca5d20e9170a2ca332dfa21b77f7e7e47fc3ab9b2261773c/pylint_django-2.7.0-py3-none-any.whl", hash = "sha256:76ef7e7bbbcf7ee86adbb2beac0ffaa7232509a17bf4a488d81467a1bbaa215b", size = 42892, upload-time = "2026-01-01T11:17:04.292Z" },
]
[[package]]
@@ -2098,16 +2095,16 @@ wheels = [
[[package]]
name = "responses"
version = "0.26.2"
version = "0.26.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyyaml" },
{ name = "requests" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/1a/4af3e6d659394b809838490b144e4ab8d7ed3b9fecc7ca78f5d2f79b1a3d/responses-0.26.2.tar.gz", hash = "sha256:9c9259b46a8349197edebf43cfa68a87e1a2802ef503ff8b2fecbabc0b45afd8", size = 84030, upload-time = "2026-07-03T16:44:50.325Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/58/1fb6de3503428196df78638f991ec8095274f1ee9723e272ee4d9ff0092b/responses-0.26.1.tar.gz", hash = "sha256:2eb3218553cc8f79b57d257bac23af5e1bf381f5b9390b1767816f0843e01dc2", size = 83088, upload-time = "2026-05-21T19:56:39.747Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/28/693e1d9ebf72baa062ded80d837a035b86ce75eda5a269379e9e2b1008a8/responses-0.26.2-py3-none-any.whl", hash = "sha256:6fdfeabd58e5ec473b98dfe02e6d46d3173bd8dd573eff2ccccf1a05a5135364", size = 35609, upload-time = "2026-07-03T16:44:49.1Z" },
{ url = "https://files.pythonhosted.org/packages/3a/31/6a620b4427d546b9e7cca8b3b8c5f0559d9cef2bb9eedcda7f73c1473c19/responses-0.26.1-py3-none-any.whl", hash = "sha256:8aacc4586eb08fb2208ef64a9eb4258d9b0c6e6f4260845f2f018ab847495345", size = 35502, upload-time = "2026-05-21T19:56:38.046Z" },
]
[[package]]
@@ -2193,27 +2190,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.16.0"
version = "0.15.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d5/e6/15800dfde183a1a106594016c912b4c12d050a301989d1aca6cb63759fe8/ruff-0.15.19.tar.gz", hash = "sha256:edc27f7172a93b32b102687009d6a588508815072141543ae603a8b9b0823063", size = 4772071, upload-time = "2026-06-24T01:10:46.942Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" },
{ url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" },
{ url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" },
{ url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" },
{ url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" },
{ url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" },
{ url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" },
{ url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" },
{ url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" },
{ url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" },
{ url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" },
{ url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" },
{ url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" },
{ url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" },
{ url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
{ url = "https://files.pythonhosted.org/packages/88/4c/9ded7626c39a0440c575bf69e2bf500d443388272c842662c59852ee7fcd/ruff-0.15.19-py3-none-linux_armv6l.whl", hash = "sha256:922d1eb283161564759bd49f507e91dc6112c15da8bd5b84ed714e086243cf86", size = 10950859, upload-time = "2026-06-24T01:10:38.491Z" },
{ url = "https://files.pythonhosted.org/packages/fb/ef/c211505ece1d00ef493d58e54e3b6383c946a21e9874774eb531f2512cf3/ruff-0.15.19-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4d190d8f62a0b94aba8f721116538a9ee29b1e74d26650846ba9b99f0ae21c40", size = 11294529, upload-time = "2026-06-24T01:10:36.481Z" },
{ url = "https://files.pythonhosted.org/packages/fe/93/78d462e7d39968e58094dc57be7d09ffb14ce37da5b68ed70338a35a1f21/ruff-0.15.19-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5a2c86ba6870dd415a9d9eb8be94d7924ebec6a26ffc7958ec7ca29d4bff967d", size = 10641416, upload-time = "2026-06-24T01:10:48.923Z" },
{ url = "https://files.pythonhosted.org/packages/76/c4/5cb66cfd1f865d5cca908b86c93ac785e7f572193d3c7426079ca6643e24/ruff-0.15.19-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b432bc087264aea70fd25ac198918b70bd9e2aa0db4297b0bb91bbfbbc63ce", size = 11015582, upload-time = "2026-06-24T01:10:30.089Z" },
{ url = "https://files.pythonhosted.org/packages/51/9f/8ecfaec10cf5eecd28fbc00ff4fb867db90a1be54bf3d39ebf93f893cd52/ruff-0.15.19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8530a09d03b3a8c994f8b559a7dcdabc690bcd3f78ef276c38c83166798ebf56", size = 10744059, upload-time = "2026-06-24T01:10:32.48Z" },
{ url = "https://files.pythonhosted.org/packages/35/6b/983249d04562bc2d590edd75f32455cdb473affb3ba4bc8d883e939c697d/ruff-0.15.19-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87bf21fb3875fe69f0eacc825411657e2e85589cce633c35c0adf1113649c62b", size = 11568461, upload-time = "2026-06-24T01:10:17.435Z" },
{ url = "https://files.pythonhosted.org/packages/eb/39/bc7794f127b18f492a3b4ee82bba5a900c985ff13b72b46f46e3c171ba34/ruff-0.15.19-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f9b229cb3ef56ecc2c1c8ebeca64b7a7740ccaef40a9eb097e78dde5a8560b83", size = 12429690, upload-time = "2026-06-24T01:10:40.638Z" },
{ url = "https://files.pythonhosted.org/packages/0a/3b/0de6859e698ed11c8a49e765196c8d333599b6a546c0715df39b6ba1aa2e/ruff-0.15.19-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c754515be7b76afe6e7e62df7776709571bcfc1631183828afcf3bafa869e3", size = 11693067, upload-time = "2026-06-24T01:10:25.681Z" },
{ url = "https://files.pythonhosted.org/packages/89/3d/0b1f30f84bee9ae6ae8d349c2ba8b6f4b040966744efdd3acc804ae7c024/ruff-0.15.19-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a498f82e0f4d8904c4e0aea5139cdfac1f39d19a3c51d491292f63a36e83b2e", size = 11616911, upload-time = "2026-06-24T01:10:44.809Z" },
{ url = "https://files.pythonhosted.org/packages/4d/eb/c90bd3dfc12eed9032c2c1bfe05105b93a1b2c8bce555db6308315b853ce/ruff-0.15.19-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:d48caa34488fb521fd0ef4aea2b0e8fe758298df044138f0d67b687a6a0d07ed", size = 11649343, upload-time = "2026-06-24T01:10:23.472Z" },
{ url = "https://files.pythonhosted.org/packages/82/91/01caa13602a2f12fae5edbe8caf78b3c1e6db1293132aee6959eecce095c/ruff-0.15.19-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4171b6613effa9363cd46dd4f75bd1827b6d1b946b5e278ed0c600d305379445", size = 10977610, upload-time = "2026-06-24T01:10:50.892Z" },
{ url = "https://files.pythonhosted.org/packages/3c/51/acb817922feab9ecbb3201377d4dbe7a25f1395e46545820061973f03468/ruff-0.15.19-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27c15b2a241dd4d995557949a094fe78b8ad99122a38ccae1595849bcc947b3f", size = 10744900, upload-time = "2026-06-24T01:10:42.726Z" },
{ url = "https://files.pythonhosted.org/packages/84/bc/5c8ca46b8a7a3f2b16cfbec88721d772b1c93912904e8f8c2e49470fea63/ruff-0.15.19-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ed03b7862d68f0a8771d50ee129980cbf1b113f96e250b73954bc292f689e0bb", size = 11293560, upload-time = "2026-06-24T01:10:21.262Z" },
{ url = "https://files.pythonhosted.org/packages/81/e0/4a888cbe4d5523b3f77a2b1fa043f46cfeba1b32eac35dcfadee0578fa8a/ruff-0.15.19-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:08143f0685ae278b30727ea72e90c61e5bd9c31b91aac4f5bb989538f73d24b8", size = 11696533, upload-time = "2026-06-24T01:10:53.046Z" },
{ url = "https://files.pythonhosted.org/packages/98/43/c34b2fcd79262a85161764a97aaca89c3e4f574340ab61430cefa2bdd2c1/ruff-0.15.19-py3-none-win32.whl", hash = "sha256:8f47f0f92952af2557212bb10cf3e695cd4cf28b2c6e42cdb18ec6c9ebfa19da", size = 10986299, upload-time = "2026-06-24T01:10:55.185Z" },
{ url = "https://files.pythonhosted.org/packages/22/e8/15fd23e02b2442b56b2026b455977bc3057aa34b26e6323d1e99e8531a9f/ruff-0.15.19-py3-none-win_amd64.whl", hash = "sha256:efeca47ee3f9d4a7162655a3b8e6ee4a878646044233978d4d2c1ff8cdd914f0", size = 12123473, upload-time = "2026-06-24T01:10:27.74Z" },
{ url = "https://files.pythonhosted.org/packages/30/66/9a73695e31eaee04f35d8475998bf8ab354465f9c638936d76111603dcc5/ruff-0.15.19-py3-none-win_arm64.whl", hash = "sha256:6c6b607466e47349332eb1d9be52fb1467423fc07c217341af41cd0f3f0573be", size = 11376779, upload-time = "2026-06-24T01:10:34.465Z" },
]
[[package]]
@@ -2230,15 +2227,15 @@ wheels = [
[[package]]
name = "sentry-sdk"
version = "2.66.1"
version = "2.63.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7f/6f/d59cad0889d15fde85254cf58e701484de3f3f0406003b3197746910b19b/sentry_sdk-2.66.1.tar.gz", hash = "sha256:f882fb08710c5f8bfc603aafa3e901b384009a19cc3f76a572b863392ee81cdc", size = 940543, upload-time = "2026-07-22T12:26:54.553Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/c8/b3c970a5b186722d276cd40a05b3254e03bccc0208560aff20f612e018e8/sentry_sdk-2.63.0.tar.gz", hash = "sha256:2a1502bf864769275dbc8c2c9fc7a0f7f5e18358180b615d262d13a31ffba216", size = 912449, upload-time = "2026-06-16T12:45:57.553Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/d3/726bd88f0eece09ddf431bea4c9191c18e7a8d070b854eb0014d447712ee/sentry_sdk-2.66.1-py3-none-any.whl", hash = "sha256:86002793161d9a95ef04bdd8d442e9bfece5d989b755f05d6360215094a7aff6", size = 505555, upload-time = "2026-07-22T12:26:52.71Z" },
{ url = "https://files.pythonhosted.org/packages/7b/57/cb205f7d93373120f666b9c5736dc0815524d96a9b278e7a728f018dc22a/sentry_sdk-2.63.0-py3-none-any.whl", hash = "sha256:3a9b5ddd403f79eb73bd670f75f04485819db53d28f76ced7bc09041cb0dfd6a", size = 495950, upload-time = "2026-06-16T12:45:55.819Z" },
]
[[package]]
@@ -2252,11 +2249,11 @@ wheels = [
[[package]]
name = "sqlparse"
version = "0.6.0"
version = "0.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" }
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" },
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
]
[[package]]
@@ -2302,14 +2299,14 @@ wheels = [
[[package]]
name = "types-requests"
version = "2.33.0.20260712"
version = "2.33.0.20260518"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/51/703318f7b7be8bee126ec13bf615050f932d0179b8784420f3a0199cc769/types_requests-2.33.0.20260712.tar.gz", hash = "sha256:2141b67ab534a5c5cd2dac5034f2a35f42e699c5bf185eee608c5246a069d7fb", size = 25084, upload-time = "2026-07-12T05:14:20.455Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/01/c5a19253fe1ac159159ddf9a3a07cec8bb5e486ec4d9002ad2821da0e5d2/types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e", size = 24752, upload-time = "2026-05-18T06:07:37.966Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/e7/010c87f559e216d83f9dc51e939633fd0d0ead3377340181ab0e223cd3b5/types_requests-2.33.0.20260712-py3-none-any.whl", hash = "sha256:de027e28c171d3da529689cbfa023b0b4eab188c8dfa22fd834eebd2cee6e7bb", size = 21392, upload-time = "2026-07-12T05:14:19.616Z" },
{ url = "https://files.pythonhosted.org/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0", size = 21391, upload-time = "2026-05-18T06:07:37.044Z" },
]
[[package]]
-3
View File
@@ -36,9 +36,6 @@ 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 ----
-5
View File
@@ -4,11 +4,6 @@ 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)$ {
+1 -1
View File
@@ -3,6 +3,6 @@
"input": ["src/**/*.{ts,tsx}", "!src/styled-system/**/*", "!src/**/*.d.ts"],
"output": "src/locales/$LOCALE/$NAMESPACE.json",
"createOldCatalogs": false,
"locales": ["en", "fr", "de", "nl", "es"],
"locales": ["en", "fr", "de", "nl"],
"sort": true
}
+129 -133
View File
@@ -1,40 +1,41 @@
{
"name": "meet",
"version": "1.29.0",
"version": "1.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.29.0",
"version": "1.24.0",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
"@fontsource-variable/lexend": "5.3.0",
"@fontsource/opendyslexic": "5.3.0",
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11",
"@fontsource/opendyslexic": "5.2.5",
"@libreaudio/la-call": "0.1.4",
"@livekit/components-react": "2.9.23",
"@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.12.0",
"@mediapipe/tasks-vision": "0.10.35",
"@pandacss/preset-panda": "1.11.3",
"@react-types/overlays": "3.10.0",
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "5.101.4",
"@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.3.6",
"i18next": "26.3.1",
"i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.3",
"livekit-client": "2.21.0",
"posthog-js": "1.414.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.20.0",
"posthog-js": "1.395.0",
"react": "18.3.1",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
"react-dom": "18.3.1",
"react-i18next": "17.0.10",
"react-i18next": "17.0.8",
"react-stately": "3.48.0",
"use-sound": "5.0.0",
"valtio": "2.3.2",
@@ -742,52 +743,52 @@
}
},
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@fontsource-variable/atkinson-hyperlegible-next": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/atkinson-hyperlegible-next/-/atkinson-hyperlegible-next-5.3.0.tgz",
"integrity": "sha512-UnLzNUy1RWoLDfydAatP4NaRm99X/WbQEph7q/PymJnCFIOOwKOx3pBmp5ys8trO0dM+CKGuJgeZP9fAR4mQpA==",
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/@fontsource-variable/atkinson-hyperlegible-next/-/atkinson-hyperlegible-next-5.2.6.tgz",
"integrity": "sha512-D0Z8peFkRGYACvmWp8Sl8YPGYLBEJbzqapugtsMEYSUUtpQDiZVDHjk9s7bqB4hSCRYCrKuW6V1V+mnHybVnPw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/lexend": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/lexend/-/lexend-5.3.0.tgz",
"integrity": "sha512-3SXtiZ8rFbT0oaPzEboCG5RM3jtyhpNxandh1jWNLqvrJRfV/eP3R+GGw+o5e3hS1uqmAGutKxSvaWG5oyrimA==",
"version": "5.2.11",
"resolved": "https://registry.npmjs.org/@fontsource-variable/lexend/-/lexend-5.2.11.tgz",
"integrity": "sha512-0hgEQ4O7Nh8fxL/WWmspJf0BErbocRkZwtLRGey/V4mUUqxfF7QUwqhcdzwpjom3NYCniY4uzQ5wYD7r9/92tQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/opendyslexic": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/opendyslexic/-/opendyslexic-5.3.0.tgz",
"integrity": "sha512-BVYIW/ghc1U2iAghhYTN+GFWiZ3lnCy8Jf2KkZC23aHQHBNcMqKT718zckfqVxRx4qsXJmIMPWvczirRuhmtsg==",
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/@fontsource/opendyslexic/-/opendyslexic-5.2.5.tgz",
"integrity": "sha512-NNS9aaPQx2TlaTvb3vTEjw3xz8lKj23mBc+6rM00mSNFDygdoll0/nLMHFtDKKrBT6sMfY6TFFPOR0D9ktdspg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
@@ -966,12 +967,12 @@
"license": "GPL-3.0+"
},
"node_modules/@livekit/components-core": {
"version": "0.12.14",
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.14.tgz",
"integrity": "sha512-6OKP/1Ok2fCZewDLKd3SzaTb7KvfZl/6hjggI+TgUdhp0Y7HBg3+tHA7YmhZRc0BO8wIyVy4VgTPn7qkLHt2nQ==",
"version": "0.12.13",
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.13.tgz",
"integrity": "sha512-DQmi84afHoHjZ62wm8y+XPNIDHTwFHAltjd3lmyXj8UZHOY7wcza4vFt1xnghJOD5wLRY58L1dkAgAw59MgWvw==",
"license": "Apache-2.0",
"dependencies": {
"@floating-ui/dom": "1.7.6",
"@floating-ui/dom": "1.7.4",
"loglevel": "1.9.1",
"rxjs": "7.8.2"
},
@@ -979,17 +980,17 @@
"node": ">=18"
},
"peerDependencies": {
"livekit-client": "^2.20.1",
"livekit-client": "^2.17.2",
"tslib": "^2.6.2"
}
},
"node_modules/@livekit/components-react": {
"version": "2.9.23",
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.23.tgz",
"integrity": "sha512-clO+0g/u3YBpuOvnAjUSAJBH/o7w+RpUAseswjiSML9rHrXlTUhwBNm8LPk+qN5GOU3A6lzsNnFYVpUHUBVOkg==",
"version": "2.9.21",
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.21.tgz",
"integrity": "sha512-6hU9VucJJL+gAhilNGe4MBCDCZVk64qyjP9Ck86krvOIdVU76WeWksddg1MYUP10AlUwwrfD7davz41pJTcMJw==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/components-core": "0.12.14",
"@livekit/components-core": "0.12.13",
"clsx": "2.1.1",
"events": "^3.3.0",
"jose": "^6.0.12",
@@ -999,8 +1000,8 @@
"node": ">=18"
},
"peerDependencies": {
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0 || ^0.4.0",
"livekit-client": "^2.20.1",
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0",
"livekit-client": "^2.18.2",
"react": ">=18",
"react-dom": ">=18",
"tslib": "^2.6.2"
@@ -1027,9 +1028,9 @@
"license": "Apache-2.0"
},
"node_modules/@livekit/protocol": {
"version": "1.50.4",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz",
"integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==",
"version": "1.46.6",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz",
"integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
@@ -1048,12 +1049,18 @@
"livekit-client": "^1.12.0 || ^2.1.0"
}
},
"node_modules/@mediapipe/tasks-vision": {
"node_modules/@livekit/track-processors/node_modules/@mediapipe/tasks-vision": {
"version": "0.10.14",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
"license": "Apache-2.0"
},
"node_modules/@mediapipe/tasks-vision": {
"version": "0.10.35",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
"license": "Apache-2.0"
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -1642,21 +1649,21 @@
}
},
"node_modules/@pandacss/preset-panda": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/@pandacss/preset-panda/-/preset-panda-1.12.0.tgz",
"integrity": "sha512-jhzhAmtGY34b1FlMJGEAh/IkfU4WyTNT2mpJfcBUPBi1rZLHSTcs0aqedRmMdwM4tTT43XFOXMiazbztKME60A==",
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@pandacss/preset-panda/-/preset-panda-1.11.3.tgz",
"integrity": "sha512-IHeRQyIYeGKpQl78A/oI9fGg8MHQiOX/HttH+iQNaRU/MbGN6JsPe/N0ekQC6QY8iiKtLhqR+SRc455SN6JVWw==",
"license": "MIT",
"dependencies": {
"@pandacss/types": "1.12.0"
"@pandacss/types": "1.11.3"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@pandacss/preset-panda/node_modules/@pandacss/types": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/@pandacss/types/-/types-1.12.0.tgz",
"integrity": "sha512-PtlEIOBEV3RzWffeu1zH+/YFmP+fn24M63STGEbgTwjEBMhyo/FfT2sfZGB8oWAbWUBKBp69OMg25E+/rY3klQ==",
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@pandacss/types/-/types-1.11.3.tgz",
"integrity": "sha512-tUbxa3WzoCuulP6kcMn7f0uNgLBvu81JAg4sfoUpz6PZE36VHA3k9n6ROEwziTbr7vqgeCj5DYQCUldrVNbEGQ==",
"license": "MIT",
"engines": {
"node": ">=20"
@@ -1719,29 +1726,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/@posthog/browser-common": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.4.0.tgz",
"integrity": "sha512-W9DCGVks15docUMPvJ2nd8NS16Gn74bsGWuaeg31beEKFSjdW8wvnQ1ETY6WSql5pYxZb3GdJmEUZVVstKSrBQ==",
"license": "MIT",
"dependencies": {
"@posthog/core": "^1.46.8",
"@posthog/types": "^1.402.0"
}
},
"node_modules/@posthog/core": {
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.49.1.tgz",
"integrity": "sha512-jdZh85tG56OXLH881CVwBZyiXCPPaZasfYeWwm9kVUvxC/Rb+lz7wYN9GuqSEmNPJgVnK4v8wS0bCaFc3OmVEA==",
"version": "1.39.5",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.39.5.tgz",
"integrity": "sha512-M8Imv7ZmmrT6derMOFXhX3c5VKKO2oG6OX70ozLnNpKJV73sNIhYmRX/raa90saF7OA0YmWWJlpKje3irGAyQQ==",
"license": "MIT",
"dependencies": {
"@posthog/types": "^1.407.0"
"@posthog/types": "^1.392.0"
}
},
"node_modules/@posthog/types": {
"version": "1.407.1",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.407.1.tgz",
"integrity": "sha512-WhbkXPC2rgylXqmxHqv70ffI3k+KxyR6s7DBIfr5NvIqHkxp6v0pk31D/jbz0DNVbzwkLjyll2pxr4FNbJiYzg==",
"version": "1.392.0",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.392.0.tgz",
"integrity": "sha512-nctNujXL3FC1v99FktaTMSugSD9ZOZekEpahUSafkU2TSvW+XGKNkQZbokuJtiWvPBK208dwMJva8UfBkChqpw==",
"license": "MIT"
},
"node_modules/@react-aria/overlays": {
@@ -2424,9 +2421,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.101.4",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz",
"integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==",
"version": "5.101.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz",
"integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2445,12 +2442,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.101.4",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz",
"integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==",
"version": "5.101.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz",
"integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.101.4"
"@tanstack/query-core": "5.101.1"
},
"funding": {
"type": "github",
@@ -4403,14 +4400,11 @@
}
},
"node_modules/core-js": {
"version": "3.50.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
"version": "3.39.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz",
"integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==",
"hasInstallScript": true,
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
@@ -4722,6 +4716,15 @@
"node": ">= 0.8"
}
},
"node_modules/derive-valtio": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/derive-valtio/-/derive-valtio-0.2.0.tgz",
"integrity": "sha512-6slhaFHtfaL3t5dLYaQt6s4G2xZymhu0Ktdl7OMeVk8+46RgR8ft6FL0Tr4F31W+yPH03nJe1SSP4JFy2hSMRA==",
"license": "MIT",
"peerDependencies": {
"valtio": ">=2.0.0-rc.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -4794,10 +4797,13 @@
}
},
"node_modules/dompurify": {
"version": "3.4.14",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz",
"integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==",
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -6410,9 +6416,9 @@
}
},
"node_modules/i18next": {
"version": "26.3.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
"version": "26.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
"funding": [
{
"type": "individual",
@@ -6429,7 +6435,7 @@
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6 || ^7"
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
@@ -6982,10 +6988,9 @@
}
},
"node_modules/i18next-resources-to-backend": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.3.tgz",
"integrity": "sha512-8Y/LLAm5fqZc2ckQxtTWbu75ndjNLzF7mcYl6rhc4g+WBopIsG3YhdYqF5qhJy64tlYhlU2KSSnEgW3gGHLqwg==",
"license": "MIT",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.1.tgz",
"integrity": "sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
@@ -8120,20 +8125,20 @@
"license": "MIT"
},
"node_modules/livekit-client": {
"version": "2.21.0",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.21.0.tgz",
"integrity": "sha512-RBUhPkV/sl1nzl8lokVlK5uATPwn0AlsudCBZXissw/kDl9yz8ac4pNJ43iPpMVoHOeBYH/BZ3vUC1adqa/zFQ==",
"version": "2.20.0",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz",
"integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.50.4",
"@livekit/protocol": "1.46.6",
"events": "^3.3.0",
"jose": "^6.1.0",
"loglevel": "^1.9.2",
"sdp-transform": "^2.15.0",
"tslib": "2.8.1",
"typed-emitter": "^2.1.0",
"webrtc-adapter": "9.0.6"
"webrtc-adapter": "9.0.5"
},
"peerDependencies": {
"@types/dom-mediacapture-record": "^1"
@@ -9131,21 +9136,19 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.414.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.414.0.tgz",
"integrity": "sha512-dtZd4asdskr8lNyltAEX6zyn48uO1pO0EMvx6AXJU65PFhu6yn2LPbKtQcyLysjcN57FJPNT9QYL6St5SBJHqw==",
"license": "(Apache-2.0 AND MIT)",
"version": "1.395.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz",
"integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@posthog/browser-common": "^0.4.0",
"@posthog/core": "^1.46.9",
"@posthog/types": "^1.402.2",
"core-js": "^3.49.0",
"dompurify": "^3.4.12",
"@posthog/core": "^1.38.0",
"@posthog/types": "^1.391.1",
"core-js": "^3.38.1",
"dompurify": "^3.3.2",
"fflate": "^0.4.8",
"preact": "^10.29.3",
"preact": "^10.29.2",
"query-selector-shadow-dom": "^1.0.1",
"web-vitals": "^5.3.0",
"web-vitals-soft-navs": "npm:web-vitals@6.0.0"
"web-vitals": "^5.3.0"
}
},
"node_modules/powershell-utils": {
@@ -9435,9 +9438,9 @@
}
},
"node_modules/react-i18next": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
"version": "17.0.8",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
@@ -9447,7 +9450,7 @@
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6 || ^7"
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"react-dom": {
@@ -11657,17 +11660,10 @@
"integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
"license": "Apache-2.0"
},
"node_modules/web-vitals-soft-navs": {
"name": "web-vitals",
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.0.0.tgz",
"integrity": "sha512-Guaibvy/+uNtL6Bsu4jmMJGzuSl91oeRH5iO9pPRbYftnFUr3yqT1TUNX/OE4o9HexuEMU3Kb/Wg7iKhlffZUA==",
"license": "Apache-2.0"
},
"node_modules/webrtc-adapter": {
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
"integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==",
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz",
"integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"

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