Compare commits

...

304 Commits

Author SHA1 Message Date
lebaudantoine d91f343ba9 🔖(minor) bump release to 0.1.28
telephony frontend + backend
source map
enhance analytics on the summary microservice
2025-07-10 22:25:07 +02:00
lebaudantoine 4fc0744433 🐛(summary) fix minor Python typing issue
Correct type annotation to resolve static type checking warnings.
2025-07-10 18:13:32 +02:00
lebaudantoine 93e3f05348 🔧(backend) make PostHog event names configurable for transcript tracking
Add setting to customize PostHog event names when tracking transcript
results for flexible analytics configuration.
2025-07-10 18:13:32 +02:00
lebaudantoine cbabcb877b 🐛(summary) add audio duration limit to prevent long-running jobs
Set default 1h30 limit for audio processing to prevent Whisper from
running excessively long on large recordings. Improves resource
management and job completion times.
2025-07-10 18:13:32 +02:00
lebaudantoine 30cd6573ef 🔧(backend) refine Celery retry policy to HTTP errors only
Only retry jobs on HTTP errors (docs push failures) rather than all
errors. Skip retries for Whisper processing failures as they won't
succeed on retry.
2025-07-10 18:13:32 +02:00
lebaudantoine 921d69031e 🔊(summary) increase logging level to info for Celery worker insights
Change logging levels to provide more visibility into Celery worker
operations and task processing for better monitoring.
2025-07-10 18:13:32 +02:00
lebaudantoine 1716e11900 🔧(summary) configure OpenAI max retries via FastAPI settings
Set default OpenAI retries to 0 and add configurable setting to control
retry behavior for API requests in summary service.
2025-07-10 18:13:32 +02:00
lebaudantoine 6e55013b15 📈(summary) kickstart analytics tracking in summary microservice
Add product analytics to understand summary feature usage and behavior.
Track transcript and task metadata for insights without exposing sensitive
content or speaker data.

Hacky but functional PostHog usage - fully optional for self-hosting.
Extensive tracking approach works for current needs despite not being
PostHog's typical use case.
2025-07-10 18:13:32 +02:00
lebaudantoine 21bed40484 (summary) add mutagen library for audio file duration detection
Install mutagen to analyze audio file metadata and determine recording
length for summary processing.
2025-07-10 18:13:32 +02:00
lebaudantoine 5959e82bf0 (summary) install PostHog in summary microservice
Add PostHog analytics integration to summary service for error
tracking and usage monitoring capabilities.
2025-07-10 18:13:32 +02:00
lebaudantoine b5113580b3 📸(frontend) inject PostHog sourcemap ID in chunks during build
Add PostHog CLI step to inject proper IDs into chunks, enabling error
tracking to map exceptions back to original source code locations
via sourcemaps.
2025-07-08 15:56:26 +02:00
lebaudantoine 053e4bc7b9 🔧(frontend) add conditional sourcemap generation for error tracking
Enable sourcemaps via env variable to link Sentry/PostHog exceptions to
source code. Enable by default for DINUM frontend image to improve
debugging capabilities.
2025-07-08 15:56:26 +02:00
lebaudantoine 9e57c25a25 🌐(frontend) fix wrong plural in footer
Accessibility should be singular.
2025-07-08 15:30:21 +02:00
lebaudantoine dfbcbed485 🐛(tilt) enable Keycloak in DINUM Tilt stack configuration
Fix missing Keycloak service in tilt-dinum stack. Error went unnoticed
when switching from tilt-keycloak due to pods not being cleaned between
stack changes.
2025-07-08 14:39:01 +02:00
lebaudantoine 9c28e1b266 📝(readme) document BrowserStack free license usage in README
Add note about using BrowserStack's free open-source license for
cross-browser testing to clarify testing infrastructure.
2025-07-07 19:45:43 +02:00
lebaudantoine 2a586445b5 📝(readme) update feature status in README with recent developments
Reflect current implementation status of features that have been completed
or are in progress to keep documentation accurate.
2025-07-07 19:45:43 +02:00
lebaudantoine 75ffb7f5f7 (frontend) add phone number and pin display in info side panel
Create minimal UI to show telephony access details for testing SIP
functionality in staging deployment. Basic implementation for feature
validation.
2025-07-07 19:21:39 +02:00
lebaudantoine 6a1c85809d (frontend) add libphonenumber-js for phone number formatting
Install JavaScript implementation of Google's libphonenumber for proper
international phone number validation and formatting.
2025-07-07 19:21:39 +02:00
lebaudantoine 70d250cc9c 🔧(frontend) add telephony feature configuration from backend
Pass telephony service availability and settings to frontend to enable
conditional UI rendering based on SIP functionality status.
2025-07-07 19:21:39 +02:00
lebaudantoine 988e5aa256 (backend) add telephony service for automatic SIP dispatch rules
Implemented a service that automatically creates a SIP dispatch rule when
the first WebRTC participant joins a room and removes it when the room
becomes empty.

Why? I don’t want a SIP participant to join an empty room.
The PIN code could be easily leaked, and there is currently no lobby
mechanism available for SIP participants.

A WebRTC participant is still required to create a room.
This behavior is inspired by a proprietary tool. The service uses LiveKit’s
webhook notification system to react to room lifecycle events. This is
a naive implementation that currently supports only a single SIP trunk and
will require refactoring to support multiple trunks. When no trunk is
specified, rules are created by default on a fallback trunk.

@rouja wrote a minimal Helm chart for LiveKit SIP with Asterisk, which
couldn’t be versioned yet due to embedded credentials. I deployed it
locally and successfully tested the integration with a remote
OVH SIP trunk.

One point to note: LiveKit lacks advanced filtering capabilities when
listing dispatch rules. Their recommendation is to fetch all rules and
filter them within your backend logic. I’ve opened a feature request asking
for at least the ability to filter dispatch rules by room, since filtering
by trunk is already supported, room-based filtering feels like a natural
addition.

Until there's an update, I prefer to keep the implementation simple.
It works well at our current scale, and can be refactored when higher load
or multi-trunk support becomes necessary.

While caching dispatch rule IDs could be a performance optimization,
I feel it would be premature and potentially error-prone due to the complexity
of invalidation. If performance becomes an issue, I’ll consider introducing
caching at that point. To handle the edge case where multiple dispatch rules
with different PIN codes are present, the service performs an extensive
cleanup during room creation to ensure SIP routing remains clean and
predictable. This edge case should not happen.

In the 'delete_dispatch_rule' if deleting one rule fails, method would exit
without deleting the other rules. It's okay IMO for a first iteration.
If multiple dispatch rules are often found for room, I would enhance this part.
2025-07-07 19:21:39 +02:00
lebaudantoine d3178eff5d (backend) serialize room pin code for frontend access
Add pin code to API response to enable frontend display of room access
codes. UI implementation will follow in upcoming commits.
2025-07-07 19:21:39 +02:00
lebaudantoine 9c840a4e06 🐛(frontend) revert || to ?? operator for metadata parsing
Fix broken parsing when metadata contains empty strings. Nullish coalescing
operator (??) doesn't handle empty strings like logical OR (||) does.
2025-07-07 19:21:39 +02:00
renovate[bot] e6754b49e0 ⬆️(dependencies) update js dependencies 2025-07-07 15:14:15 +02:00
lebaudantoine ec586eaab4 🔥(backend) remove outdated GitHub workflow
Clean up CI/CD by deleting obsolete workflow file that is no longer
needed or maintained since we deploy project through private repo.
2025-07-07 11:02:50 +02:00
lebaudantoine e4e4fcbbfc 🔖(minor) bump release to 0.1.27
White label frontend. Docs is missing, will be
written shortly after the v0.1.27 release.
2025-07-04 23:41:47 +02:00
lebaudantoine 8929ec5714 ♻️(frontend) use LiveKit mobile detection in noise reduction hook
Replace custom mobile browser detection with livekit-client's built-in
isMobile() function for consistency and better reliability.
2025-07-02 11:07:53 +02:00
soyouzpanda 26045bbffa 🔧(backend) support _FILE for secret environment variables
Allow configuration variables that handles secrets, like
`DJANGO_SECRET_KEY` to be able to read from a file which is given
through an environment file.

For example, if `DJANGO_SECRET_KEY_FILE` is set to
`/var/lib/meet/django-secret-key`, the value of `DJANGO_SECRET_KEY` will
be the content of `/var/lib/meet/django-secret-key`.
2025-07-01 13:41:02 +02:00
lebaudantoine 641a311bca ️(frontend) add accessible label to "clear effect" button
Add missing aria-label or tooltip to clear effect button to provide context
for screen-reader users.
2025-06-30 19:10:41 +02:00
lebaudantoine 0d84b1c9ad 🎨(frontend) use regex literals instead of RegExp constructor
Replace `new RegExp()` constructor calls with regex literal
notation (`/pattern/flags`) following TypeScript best practices.
Improves readability and performance while maintaining
equivalent functionality.
2025-06-30 19:10:41 +02:00
lebaudantoine 4255308010 🎨(frontend) mark non-reassigned private attributes as readonly
Add readonly modifier to private class attributes that are never reassigned
after initialization. Improves code safety and clearly communicates immutable
intent to other developers.
2025-06-30 19:10:41 +02:00
lebaudantoine e20619d9a2 🔥(frontend) delete empty constructor from noise reduction class
Remove unnecessary empty constructor that provides no functionality beyond
default constructor behavior. Simplifies class definition and reduces
boilerplate code.
2025-06-30 19:10:41 +02:00
lebaudantoine 4a6b84ed50 🎨(frontend) remove redundant React fragments
Remove unnecessary React fragments that contain only one child or are direct
children of HTML elements. Simplifies JSX structure by eliminating redundant
wrapper elements that don't add functional value.
2025-06-30 19:10:41 +02:00
lebaudantoine b058e6add9 🎨(frontend) replace logical OR with nullish coalescing operator
Replace `||` operators and conditional checks with `??` where appropriate
for safer null/undefined handling. Nullish coalescing only triggers for
null/undefined values, preventing unexpected behavior with falsy values
like 0 or empty strings.
2025-06-30 19:10:41 +02:00
lebaudantoine b265c3c7cb 🔥(frontend) delete duplicated frontend imports
Remove duplicate import statements to improve code clarity and potentially
reduce bundle size through better tree-shaking optimization.
2025-06-30 19:10:41 +02:00
lebaudantoine 459280b384 ️(backend) clean package manager cache in Docker builds
Remove package manager index caches in Dockerfile to reduce image size and
improve deployment speed.

Even in multi-stage builds, ensure no unnecessary cache data is stored.
Package indexes are redundant for runtime operation and only increase storage,
bandwidth, and deployment time.
2025-06-30 17:55:55 +02:00
lebaudantoine 9d01dde9e4 🧪(backend) fix unreachable assertion after expected exception
Remove assertion statement that was placed after code expected to raise an
exception. The assertion was never evaluated due to the exception flow,
making the test ineffective.
2025-06-30 17:55:55 +02:00
lebaudantoine de92d7d5ac 🐛(backend) prevent regex from matching empty string
Rework regex pattern to exclude empty string matches since
url_encoded_folder_path is optional.

Add additional test cases covering edge cases and failure
scenarios to improve validation coverage
and prevent false positives.
2025-06-30 17:55:55 +02:00
lebaudantoine 077d38f5e3 ⚰️(backend) remove apps.py
Legacy code, never used.
2025-06-30 17:55:55 +02:00
lebaudantoine 3e315e92fa 🎨(backend) simplify boolean comparisons by using opposite operators
Replace inverted boolean comparisons (not ... ==) with direct opposite
operators (!=) to improve code readability and reduce unnecessary
complexity in conditional statements.
2025-06-30 17:55:55 +02:00
lebaudantoine 5ef38fcba6 🎨(docker) sort package arguments alphabetically in Dockerfiles
Arrange system package installation arguments in alphabetical order within
RUN instructions to improve readability and maintainability. Enables easier
tracking of changes and helps prevent potential installation errors.
2025-06-30 17:55:55 +02:00
lebaudantoine ea754e96c9 📝(doc) add missing documentation for new backend env variables
Complete installation guide by documenting previously undocumented
environment variables for proper configuration.
2025-06-26 20:19:41 +02:00
lebaudantoine e692a0cf8a 📝(doc) format env variable table with even column widths
Improve documentation readability by standardizing table column spacing
in installation guide.
2025-06-26 20:19:41 +02:00
lebaudantoine 46eee4ce9d ✏️(frontend) fix typo and capitalize sentence start
Correct capitalization error in text to follow proper grammar conventions.
2025-06-26 20:19:41 +02:00
lebaudantoine 5c2305d710 👷(frontend) add temporary CI workflow for DINUM frontend image
Create build and push pipeline for custom DINUM image to test white-label
deployment process. Will be moved to separate repo later.
2025-06-26 20:19:41 +02:00
lebaudantoine fb6b6f2b03 (tilt) add Tilt environment for testing DINUM frontend image
Introduce new development environment to test custom DINUM image locally
and validate white-label customizations.
2025-06-26 20:19:41 +02:00
lebaudantoine 77e7e9cdd4 (frontend) build specialized DINUM image from generic frontend
Create DINUM-specific frontend build from generic white-label base to
validate recent white-labeling work. Sources will eventually be extracted
to separate repo and pulled as submodule.
2025-06-26 20:19:41 +02:00
lebaudantoine ac2451e327 🔧(frontend) set LaSuite Meet as default white-label logo
Update default branding to use LaSuite Meet logo for white-label
deployments before custom overrides are applied.
2025-06-26 20:19:41 +02:00
lebaudantoine e356dcb4c3 ♻️(frontend) rename VisioIcon to CameraIcon for clarity
Replace misleading component name that suggested brand-specific usage.
Generic camera icon is reusable across white-label versions.
2025-06-26 20:19:41 +02:00
lebaudantoine 2699edeaad 🚚(frontend) move logo to public folder for deployment customization
Relocate logo asset to enable easy branding override when rebuilding
images for different deployments.
2025-06-26 20:19:41 +02:00
lebaudantoine d417c21d77 🔥(frontend) remove unused play icon asset
Clean up codebase by removing unnecessary icon file to reduce bundle size
and eliminate dead assets.
2025-06-26 20:19:41 +02:00
lebaudantoine 830da365c4 🚚(frontend) move slider assets to public folder with simplified names
Relocate and rename slider assets for easier customization when rebuilding
images. Enables deployments to override branding elements more simply.
2025-06-26 20:19:41 +02:00
lebaudantoine b33df66158 🔧(frontend) make web app manifest optional
Add configuration to conditionally include manifest article,
allowing deployments to customize or disable MoreLink component.
2025-06-26 20:19:41 +02:00
lebaudantoine 211e05a0cb 🌐(frontend) remove hardcoded "visio" references from translations
Rewrite copy to avoid direct product name mentions where possible. Use
env variable for unavoidable brand references to enable proper
customization for different deployments.
2025-06-26 20:19:41 +02:00
lebaudantoine 018eec8a46 🔧(frontend) make app title customizable with env variable override
Replace default "visio" with "LaSuite Meet" and allow env variable
customization. Default Docker image uses "LaSuite Meet", but deployments
can override with custom values by setting env vars and rebuilding.
2025-06-26 20:19:41 +02:00
lebaudantoine e01b1713c4 🔧(frontend) make transcription beta registration form optional
Add configuration to disable transcription beta form for self-hosted
deployments that don't offer this feature. Quick implementation, needs
refinement.
2025-06-26 20:19:41 +02:00
lebaudantoine 1b9af5bf6d 🎨(frontend) replace hardcoded hex values with design tokens
Clean up fast-shipped features that broke design system by using proper
primary/semantic tokens instead of hardcoded colors. Enables better theme
customization for all implementations.
2025-06-26 20:19:41 +02:00
Emmanuel Pelletier 260eab23be 💄(proconnect) replace all proconnect buttons when env is off
The home button was already updated but I missed a few places where it
was also used.
2025-06-26 20:19:41 +02:00
lebaudantoine 98de4f9145 ♻️(frontend) extract hardcoded domain to use dynamic window origin
Replace hardcoded visio.numerique.gouv.fr with automatic detection based on
window.location.origin for better environment flexibility.
2025-06-26 20:19:41 +02:00
lebaudantoine c82168b6c0 🌐(frontend) remove hardcoded DINUM-specific URLs from support forms
Replace instance-specific URLs with configurable values to make the
application more generic and reusable for other deployments.
2025-06-26 20:19:41 +02:00
Emmanuel Pelletier ba286724f3 💄(fonts) use system fonts as default
We don't really need Source Sans as default, system font is enough
2025-06-26 20:19:41 +02:00
Emmanuel Pelletier bbd700270f 🔧(homepage) let people use a simple login button instead of proconnect
This is done to have people self-hosting meet be able to show a simple
"login" button instead of having the ProConnect branding
2025-06-26 20:19:41 +02:00
Emmanuel Pelletier 15330ad4e1 🔧(env) disable the DINUM-specific footer by default
the footer used is very specific to the DINUM/French gov instance so it
should not be enabled by default for everyone.

it's still a bit weird to keep this footer in the code here but at least
it removes the issue easily. any PR to clean the code is appreciated :)
2025-06-26 20:19:41 +02:00
Emmanuel Pelletier 4db3e205d2 💄(frontend) remove Marianne font
The Marianne font is pretty specific to the DINUM instance and shouldn't
really be there as default in the repo. We can use a custom CSS file to
load Marianne if needed on a specific instance.
2025-06-26 20:19:41 +02:00
Emmanuel Pelletier dd0b570fea 💄(header) remove the French gov motto + marianne from header
This shouldn't have been in the repo really. Instead of this, add some
css classes, that kinda act as hooks for people using a custom css file
(for example, DINUM) in case they need to
2025-06-26 20:19:41 +02:00
Emmanuel Pelletier 3088299c0b 🔧(env) add a way to load a custom css file to ease up UI customization
This is the first step in removing DINUM-related styling of the default
meet install
2025-06-26 20:19:41 +02:00
lebaudantoine d1bb414cb4 🔖(minor) bump release to 0.1.26
Add noise reduction with RNNoise.
New feature needs to be battle tested,
it's protected with a feature flag.
2025-06-25 22:40:01 +02:00
lebaudantoine 513f3c588a 🚩(frontend) add noise reduction availability hook with feature flag
Encapsulate noise reduction availability logic in hook and add feature flag
for quick production disable if issues arise. Gives product owners emergency
control over the feature.
2025-06-25 22:21:23 +02:00
lebaudantoine b407bfda07 🐛(frontend) default noise reduction to disabled for existing users
Prevent React warnings about uncontrolled/controlled components by ensuring
lk-user-choice store initializes with default value when noise reduction
setting is missing from existing localStorage.
2025-06-25 22:21:23 +02:00
lebaudantoine 3de4cc01dc (frontend) disable noise reduction on mobile devices
Prevent enabling noise reduction on mobile to avoid performance issues
from resource-intensive audio processing.
2025-06-25 22:21:23 +02:00
lebaudantoine 1fe1a557ad (frontend) add hook to manage noise reduction processor toggling
Create concise hook that listens to audio track status and user noise
reduction preference to automatically handle processor state changes.

Note: Logging could be improved in future iterations.
2025-06-25 22:21:23 +02:00
lebaudantoine fb92e5b79f (frontend) add noise reduction toggle setting with beta label
Implement settings option to enable/disable noise suppression with clear
beta indicator. Label will be removed after production battle-testing.

Note: Settings styling needs polish, will address in future commits.
2025-06-25 22:21:23 +02:00
lebaudantoine e71bc093bd (frontend) add RNNoise processor for meeting noise suppression
Implement noise reduction copying Jitsi's approach. RNNoise isn't optimal
but chosen for first draft. Needs production battle-testing for CPU/RAM.

Use global audio context with pause/resume instead of deletion to avoid
WASM resource leak issues in @timephy/rnnoise-wasm dependency. Audio context
deletion may not properly release WASM resources.

Requires discussion with senior devs on resource management approach.
2025-06-25 22:21:23 +02:00
lebaudantoine 43df855461 (frontend) add @timephy/rnnoise-wasm for noise suppression
Install wrapper around Jitsi's RNNoise implementation for easier reuse.
Note: Library may not properly release WebAssembly resources based on
code review.
2025-06-25 22:21:23 +02:00
lebaudantoine 12fc33d30a 🔖(minor) bump release to 0.1.25
Fix chat issue livekit/components-js#1173
Bump backend dependencies
Switch to python v13
2025-06-25 19:59:34 +02:00
lebaudantoine 892a98193d 🎨(backend) format sources and clean up ruff configuration
Apply formatting changes from recent ruff upgrade and remove obsolete
ignored error rules that are no longer needed.
2025-06-25 15:02:44 +02:00
lebaudantoine c5379f29e7 (backend) remove unused june-analytics-python dependency
Clean up dependencies by removing unused analytics package to reduce
bundle size and eliminate unnecessary maintenance overhead.
2025-06-25 15:02:44 +02:00
lebaudantoine 2fddc82333 🔧(backend) fix Redis dependency conflict by downgrading to v5.2.1
Resolve ResolutionImpossible error where meet requires redis==6.2.0
but kombu[redis] 5.5.x requires redis<=5.2.1. Downgrade to redis==5.2.1
satisfies overlapping constraints and testing confirms compatibility.

Pin the redis dependency.
2025-06-25 15:02:44 +02:00
renovate[bot] c9ba6cbc05 ⬆️(dependencies) update python dependencies 2025-06-25 15:02:44 +02:00
lebaudantoine 76807a54f2 🚑️(frontend) bump livekit-react-components to fix critical chat issue
Urgent update to resolve chat functionality problems.

Refs: livekit/components-js#1173, #1174
2025-06-25 14:31:43 +02:00
lebaudantoine 15aff4db8e 🌐(frontend) add missing internationalization for "micro désactivé"
Fix overlooked French text that wasn't properly internationalized in
the microphone status display.
2025-06-24 20:09:02 +02:00
lebaudantoine 27a0128b2a 🐛(frontend) fix audio device ID persistence from settings panel
Ensure user's audio device selection is properly saved to localStorage
when changed through settings to maintain preference across sessions.
2025-06-24 20:09:02 +02:00
lebaudantoine 4a18e188e4 ♻️(frontend) refactor usePersistentUserChoice to fix state sync issues
I may have introduced a misusage of the usePersistentUserChoice hook.
I ended using it while expecting it to be a global state, it wasn't.

Fix broken global state that caused user choice desync. Use LiveKit default
persistence functions similar to notification store approach. Carefully
handles existing localStorage data to prevent regressions.

Note: Audio output persistence will be added in future commits.
2025-06-24 20:09:02 +02:00
lebaudantoine 1cd8fd2fc6 🔒️(backend) enhance participant ID serialization in lobby per audit
Improve participant ID handling in lobby serialization following security
auditor recommendations to prevent potential data exposure.
2025-06-24 13:57:53 +02:00
lebaudantoine 64eadadaef 🔒️(backend) clarify administrator role checking function names
Rename vague functions to explicitly indicate administrator permission checks,
or owner ones. Prevents developer confusion and potential security misuse
per auditor recommendations.
2025-06-24 13:57:53 +02:00
lebaudantoine 6e48f8f222 🔒️(backend) remove realistic password data from test fixtures
Replace test fixture passwords that resembled real credentials to avoid
confusion during security audits and follow security best practices.
2025-06-24 13:57:53 +02:00
lebaudantoine 866a2cea20 🔒️(backend) specify explicit imports to limit security exposure
Replace wildcard imports with specific function imports, particularly for
OS package which could expose dangerous functions. Follows security audit
recommendations to minimize attack surface.
2025-06-24 13:57:53 +02:00
lebaudantoine 17b1dde050 ♻️(summary) dry docker image summary by extracting base image name
Eliminate duplication by using variables for base image references to
improve maintainability and reduce configuration redundancy.
2025-06-23 16:36:02 +02:00
lebaudantoine 0b25374cef ⬆️(docker) upgrade backend image to python 3.13
Python 3.13 is now stable, our libraries are compatible with it. We also
upgrade the alpine version and node one used in the backend.
2025-06-23 16:36:02 +02:00
lebaudantoine fb8b2d752b 🔒️(backend) upgrade Django to 5.2.3 for security compliance
Update Django and related libraries per security auditor recommendations
as current version is aging. Django 5.2.3+ changed email validation per

Remove failing test cases affected by stricter validation.

Refs:
- https://code.djangoproject.com/ticket/36014
- https://github.com/django/django/commit/c068f000
2025-06-23 14:59:01 +02:00
lebaudantoine 3c0a712f45 🔖(minor) bump release to 0.1.24
Fix blurry screenshare issue.
See livekit/client-sdk-js#1556
2025-06-22 19:54:01 +02:00
lebaudantoine 17795c69d6 🩹(frontend) add temp workaround for LiveKit useChat breaking changes
Implement brittle message count tracking to handle chat emissions after
useChat API changes. Temporary fix until refactoring to new text stream
approach recommended by LiveKit team.

Ref: https://github.com/livekit/components-js/issues/1158
2025-06-20 23:20:47 +02:00
lebaudantoine d1b0378a45 ⬆️(frontend) bump livekit-client from 2.11 to 2.13
Fix critical Chrome screenshare bug and other issues. See:
https://github.com/livekit/client-sdk-js/pull/1556

Breaking change: Chat functionality affected by behavior changes.
Discussion: https://github.com/livekit/components-js/issues/1158
2025-06-20 23:20:47 +02:00
lebaudantoine ef4dcf57b0 📈(frontend) add analytics tracking for connection events
Implement connection event tracking to monitor user connectivity
patterns and identify potential issues.
2025-06-12 14:54:51 +02:00
lebaudantoine bbb6e4f317 ♻️(frontend) refactor useConnectionObserver to track session time
Add session duration tracking and consolidate all disconnect events
(including client-initiated) with timing data for comprehensive
connection analytics.
2025-06-12 14:54:51 +02:00
lebaudantoine 77d2365a61 🐛(backend) fix OIDC returnTo parameter validation in Docker compose
Correct OIDC_REDIRECT_ALLOWED_HOSTS configuration that was preventing proper
URL validation. Thanks to @nathanvss for identifying and fixing the issue.

Note: Update your common env file with corrected values.
2025-06-11 18:34:41 +02:00
lebaudantoine e6caa0a2fd 📈(frontend) add analytics for disconnect/reconnect events
Track connection issues to identify user problems. Skip client-initiated
disconnects (normal flow). Disconnect events provide richer data than
reconnect events which lack reason details.

Next: Add error screen for JOIN_FAILURE disconnects to trigger support
workflow for users experiencing connection problems.
2025-06-11 18:28:29 +02:00
lebaudantoine f9614fc108 🩹(backend) default CORS_ALLOW_ALL_ORIGINS to False
The settings CORS_ALLOW_ALL_ORIGINS was set to True by default.

This error is inherited from a old mistake made back in the days
while working on the initial impress demo.

I wrongly configured the settings. This error was propagated when
@sampaccoud copied impress code to kickstart LaSuite Meet.

This is not something we want, this should be only allowed in
development. We change the value in all the manifests in order to have
the desired behavior in non development environments.
2025-06-10 16:16:55 +02:00
Jacques ROUSSEL 9d516bf638 🚸(helm) improve helm chart
Our Helm chart wasn't suitable for use with Helm alone because jobs
remained after deployment. We chose to configure ttlSecondsAfterFinished
to clean up jobs after a period of time.
2025-06-06 16:52:30 +02:00
lebaudantoine 5bac9a1d59 🔒️(frontend) hide Nginx server version in error responses
Remove version disclosure in /assets/ error pages identified by security
auditor to prevent information leakage vulnerability.
2025-06-03 15:17:21 +02:00
lebaudantoine 61aa3c79c5 🩹(backend) replace requests exception with urllib3 ones
My bad, I caught the wrong exception, issue is still raising in Sentry.
It fixes commit #2a7d963f
2025-05-28 10:49:03 +02:00
lebaudantoine 0c6cd8223d 🔖(minor) bump release to 0.1.23
Misc updates and fixes.
2025-05-27 21:50:21 +02:00
lebaudantoine b692314646 🐛(backend) handle empty transcription files gracefully
Add handling for when users forget to activate microphones resulting in
empty transcripts. User message not yet internationalized, planned for
next version.

Add friendly instructions to give them hint about the situation.
2025-05-27 21:27:09 +02:00
lebaudantoine e816475981 🚨(summary) lint backend sources
Run ruff format to fix linter warnings.
2025-05-27 19:12:37 +02:00
lebaudantoine 8d1f01645a 🚸(frontend) add notification with recording handling details
Display notification clarifying recording is processing and show which email
will receive completion notification. Reduces user mental load per
@sampaccoud's feedback.
2025-05-27 19:12:37 +02:00
lebaudantoine 29a46a413e 🚸(frontend) clarify transcription/recording access restrictions in UI
Make it explicit that non-owner/admin users cannot enable transcription.
Beta feature behavior that may change in future versions.
2025-05-27 19:12:37 +02:00
lebaudantoine b618b2347f 🚸(frontend) clarify automatic recording save process in UI
Add explicit messaging that recording save is automatic and continues
even after leaving meeting. Reduces user anxiety based on feedback
from Samuel Paccoud.
2025-05-27 19:12:37 +02:00
lebaudantoine 7038f2a85d 🐛(backend) fix KeyError crash when role is undefined in request data
Use dict.get() instead of direct key access to prevent server crashes when
role field is missing. Fix inherited from magnify project codebase.
2025-05-27 16:20:36 +02:00
lebaudantoine b7dfafaf47 🔇(backend) downgrade marketing exceptions from error to warning level
Replace logger.exception with logger.warning to reduce Sentry noise for
unavoidable timeout errors that developers cannot act upon.
2025-05-27 15:15:46 +02:00
lebaudantoine 2a7d963f50 🥅(backend) catch request timeout and Brevo contact addition errors
Handle unhandled exceptions to prevent UX impact. Marketing email operations
are optional and should not disrupt core functionality.

My first implementation was imperfect, raising error in sentry.
2025-05-27 15:15:46 +02:00
lebaudantoine 980117132f (backend) add 'sandbox' configuration class for demo environment
Create dedicated config for sandbox environment used for external demos,
bug bounties, and security auditing purposes.
2025-05-27 15:06:02 +02:00
lebaudantoine 7a8b50b5f0 ♻️(backend) use sentry tags instead of extra scope
To ease filtering issues on sentry, we want to use tags instead of extra
scope. Tags are indexed and searchable, it's not the case with extra
scope. Moreover using set_extra to add additional data is deprecated.

Commit #ebf6d46 on docs.
2025-05-27 15:06:02 +02:00
lebaudantoine 409e403581 🚸(backend) display recording owners directly in admin list view
Show recording owner(s) directly in admin list interface to speed up
troubleshooting. Previously required clicking into each object to identify
owner. Handles multiple owners (rare) by displaying a default message.
2025-05-26 14:45:57 +02:00
lebaudantoine 0c0eed6f59 🚸(backend) enhance recording search UX in admin debugging interface
Improve user experience when searching for recording to streamline admin
troubleshooting workflows.
2025-05-26 14:45:57 +02:00
lebaudantoine 750b7f86b4 🚸(backend) replace user select with autocomplete in admin interface
Convert basic select to autocomplete for adding users to rooms. Improves
admin UX when managing 10k+ users
2025-05-26 14:45:57 +02:00
lebaudantoine 9569c58315 🚸(backend) enhance room search UX in admin debugging interface
Improve user experience when searching for rooms to streamline admin
troubleshooting workflows.
2025-05-26 14:45:57 +02:00
Baptiste Massemin 922a968418 🔧(helm) fix the path prefix of the backend ingress
The current path is `/api/v`, and it doesn't work with `ingress-nginx`.
I'm not sure if other ingress controllers work with this prefix,
but changing it to `/api/` will work for `ingress-nginx`
and likely for others as well.
2025-05-23 19:15:33 +02:00
lebaudantoine 48e6cef763 ⬇️(frontend) rollback LiveKit component library from 2.9.0 to 2.8.1
Downgrade @livekit/components-js due to bug introduced in version 2.9.0.
Issue has been reported to upstream maintainers at:
https://github.com/livekit/components-js/issues/1158

Reverting to last known stable version until fix is available.
2025-05-23 19:05:51 +02:00
lebaudantoine ac4ec6c752 📝(backend) update documentation on backend options
Add 19 missing env variables and correct typo in description. Used
Claude to generate the list, please feel free to correct any of these
values/descriptions through PR.
2025-05-23 15:24:01 +02:00
lebaudantoine 7454d44329 🔥(ci) remove unused Crowdin i18n steps
We're not using Crowdin yet and failing CI steps confuse external
contributors. Clean up pipeline to remove unnecessary complexity.
2025-05-23 14:37:16 +02:00
Jacques ROUSSEL 1e3e7de753 🔒️(front) improve docker image security
Cyberwatch reported security issues with the frontend Docker image.
2025-05-23 14:25:06 +02:00
lebaudantoine 4c2eb31a6a ✏️(backend) fix typo in FRONTEND_IS_SILENT_LOGIN_ENABLED env var
Correct spelling in environment variable name as identified by @K900. This
is technically a breaking change for existing deployments using this setting,
but acceptable as we haven't released an official version yet. Will personally
notify known users of this setting about the change to minimize disruption.
2025-05-20 22:54:51 +02:00
lebaudantoine a2a44a6546 🌐(frontend) rename "appliquer des effets" to "effets d'arrière plan"
Update French translation of effects feature based on user feedback to better
reflect its purpose as background effects rather than generic effects.
2025-05-20 22:54:34 +02:00
lebaudantoine f1fa99f918 (backend) allow setting session cookie age via env var
We want to be able to increase the duration of the cookie session
by setting an environment variable.
2025-05-20 13:56:22 +02:00
lebaudantoine 4a6e65d4be 🐛(frontend) restore user language synchronization between front and back
Fix issue where user language preferences stopped properly syncing between
frontend and backend, causing inconsistent language experience. Issue was
reported by user and affected localization settings persistence.
2025-05-20 13:49:54 +02:00
lebaudantoine c8772bb1ad 🐛(backend) update pin code tests after increasing max retry limit
Fix test cases for room PIN code generation that were not updated when
max retry limit was increased during code review. Aligns test expectations
with actual implementation to prevent false failures.
2025-05-19 11:13:59 +02:00
lebaudantoine d54925bd97 🔧(helm) update ASR model name after switch to WhisperX
Correct Automatic Speech Recognition model naming configuration to reflect
the transition from insanely-fast-whisper to WhisperX implementation.
2025-05-19 11:13:59 +02:00
lebaudantoine e9f3e27058 🌐(backend) improve French translations for technical terminology
Refine French translations to be less literal and preserve English terms for
standard technical concepts. Enhances clarity and maintains industry
terminology conventions.
2025-05-19 11:13:59 +02:00
lebaudantoine 0abbe4a26f 🌐(backend) add missing translations for room PIN functionality
Add internationalization support for previously untranslated strings related
to room PIN code logic. Ensures consistent localization across all user-
facing room access features.
2025-05-19 11:13:59 +02:00
lebaudantoine 3e93f5924c (backend) add 10-digit PIN codes on rooms for telephony
Enable users to join rooms via SIP telephony by:
- Dialing the SIP trunk number
- Entering the room's PIN followed by '#'

The PIN code needs to be generated before the LiveKit room is created,
allowing the owner to send invites to participants in advance.

With 10-digit PINs (10^10 combinations) and a large number of rooms
(e.g., 1M), collisions become statistically inevitable. A retry mechanism
helps reduce the chance of repeated collisions but doesn't eliminate
the overall risk.

With 100K generated PINs, the probability of at least one collision exceeds
39%, due to the birthday paradox.

To scale safely, we’ll later propose using multiple trunks. Each trunk
will handle a separate PIN namespace, and the combination of trunk_id and PIN
will ensure uniqueness. Room assignment will be evenly distributed across
trunks to balance load and minimize collisions.

Following XP principles, we’ll ship the simplest working version of this
feature. The goal is to deliver value quickly without over-engineering.

We’re not solving scaling challenges we don’t currently face.
Our production load is around 10,000 rooms — well within safe limits for
the initial implementation.

Discussion points:
- The `while` loop should be reviewed. Should we add rate limiting
  for failed attempts?
- A systematic existence check before `INSERT` is more costly for a rare
  event and doesn't prevent race conditions, whereas retrying on integrity
  errors is more efficient overall.
- Should we add logging or monitoring to track and analyze collisions?

I tried to balance performance and simplicity while ensuring the
robustness of the PIN generation process.
2025-05-15 17:17:55 +02:00
Jacques ROUSSEL d70dc41643 ️(tilt) fix cp for linux users
Fix the cp permission issue for linux users
2025-05-15 17:17:07 +02:00
lebaudantoine 6e81b55403 (frontend) add prayer hands emoji reaction
Implement new 🙏 reaction in response to user requests for more diverse
emotional expression options.

Requested by a beta user.
2025-05-15 15:44:06 +02:00
keda82 b8cc21debc 📝(backend) improve deployment documentation with missing prerequisites
Add critical setup requirements including kubectl installation, script download
instructions, executable permissions for mkcert and Docker, and clarify
local-only access limitation.
2025-05-15 15:20:57 +02:00
lebaudantoine 36ddb84982 🐛(backend) fix ingress path to use specific API path
Replace generic '/api' path with versioned '/api/v' pattern in Helm
ingress template to ensure proper routing for backend requests.

It closes #539
2025-05-15 14:57:50 +02:00
lebaudantoine 496ae12fa9 ♻️(backend) remove lazy from languages field on User model
The idea behind wrapping choices in `lazy` function was to allow
overriding the list of languages in tests with `override_settings`.
This was causing makemigrations to keep on including the field in
migrations when it is not needed. Since we finally don't override
the LANGUAGES setting in tests, we can remove it to fix the problem.

Taken from docs #c882f13
2025-05-15 13:50:25 +02:00
lebaudantoine ae4ef48d05 ♻️(backend) remove internationalization from non-user-facing strings
Remove translation markers from backend strings that are never displayed to
users. Streamlines localization process by focusing only on user-visible
content that requires actual translation.
2025-05-15 12:08:41 +02:00
lebaudantoine 952104fd82 🌐(i18n) add German language support
Implement German translations throughout the application to better serve
German-speaking users. Expands language options beyond existing French,
English, and Dutch to improve accessibility for German counterparts.
2025-05-15 12:04:17 +02:00
lebaudantoine 60dc8bf174 🌐(backend) update translation files after authentication refactoring
Refresh Django translation files to remove strings from authentication
system refactoring and recent minor backend changes.
2025-05-15 12:04:17 +02:00
renovate[bot] d54a61cbcc ⬆️(dependencies) update vite [SECURITY] 2025-05-12 16:33:05 +02:00
lebaudantoine f90a1e3549 🚨(frontend) resolve TypeScript build errors after dependencies upgrade
Fix type checking failures caused by recent dependency updates.
2025-05-12 16:00:23 +02:00
renovate[bot] 572b80d3fe ⬆️(dependencies) update js dependencies 2025-05-12 16:00:23 +02:00
Ghislain LE MEUR 82d840a15f 🔧(helm) remove affinity for jobs
Affinity isn't necessary for jobs.
Please have a look to PR #509
2025-05-12 14:34:40 +02:00
renovate[bot] 500c690fa0 ⬆️(dependencies) update django to v5.1.9 [SECURITY] 2025-05-12 14:18:52 +02:00
lebaudantoine 577111d864 🔒️(frontend) prevent disconnected users from accessing recording tools
Block recording and transcript features when user isn't connected to prevent
database state corruption. Users were previously able to trigger these
actions despite being disconnected.
2025-05-12 11:46:32 +02:00
lebaudantoine 563f1e4c0f (frontend) improve meeting code input accessibility for touch devices
Enhance meeting code input to accept codes without hyphens and make input
case-insensitive. Addresses usability issue observed with touch screen and
virtual keyboard users who struggled with precise formatting. Improves
accessibility for users with pointing pens and limited input precision.

requested by @spaccoud
2025-05-12 10:34:49 +02:00
lebaudantoine 2e8407ac7c 🚸(frontend) visually differentiate user's reactions from others
Implement light color variant for reactions triggered by current user versus
standard color for other participants' reactions. Provides visual cue to help
users easily identify their own emoji reactions in the conversation flow.
2025-05-05 23:27:22 +02:00
lebaudantoine 2af9ec0d85 ♻️(frontend) replace UTF emoji characters with designer-created images
Replace UTF character-based emoji with custom image assets designed by our
UX/UI team. Enhances cross-platform compatibility of reactions that were
previously inconsistent between operating systems. Specifically addresses
issue where emoji sent from Mac weren't properly displayed on all client
systems.
2025-05-05 23:27:22 +02:00
lebaudantoine b70799c2db 🔖(minor) bump release to 0.1.22
Upgrade track processor to benefits from webgl.
2025-05-05 23:26:49 +02:00
lebaudantoine 0facfc11be 🐛(frontend) fix video distortion when stopping processors
Prevent layout shift in vertical menu by adapting video element height based on
orientation. Eliminates glitchy effect where stopping a processor doubled
video height and pushed menu options downward for a few ms.
2025-05-03 19:13:29 +02:00
lebaudantoine 8023e44f71 🥚(frontend) add Konami code detector to unlock April Fool's effects
Implement easter egg that reveals fun features originally created for
April Fool's Day when users enter the Konami sequence.
2025-05-03 19:13:29 +02:00
lebaudantoine b27c5e9b92 🏗️(frontend) decouple landmark processor from background processors
Separate landmark processor logic to avoid entanglement with background
processing. Ensures future refactoring can replace custom background
implementation without affecting landmark functionality.
2025-05-03 19:13:29 +02:00
lebaudantoine 3e5c4c32e9 🔥(frontend) remove version upgrade warning for LiveKit WebGL update
Delete obsolete user notification now that the stable WebGL-powered version
with improved performance has been officially released.
2025-05-03 19:13:29 +02:00
lebaudantoine 551207ab86 🩹(frontend) add conditional stopProcessor call for cross-browser compat
Implement browser detection to explicitly stop processors in Firefox and other
browsers that lack full support for modern web APIs, before switching from
one processor to another.

This issue was introduced by recent upgrade of track processor.
An issue has been opened.
2025-05-03 19:13:29 +02:00
lebaudantoine 0aa47fcd1e ⬆️ (frontend) upgrade track processor to enable WebGL support
Update dependency to gain improved browser compatibility and WebGL
acceleration for better video processing performance.
2025-05-03 19:13:29 +02:00
virgile-deville c289f79d8e 📝(readme) add matrix room to readme
So that community members can gather and chat about meet

Signed-off-by: virgile-deville <virgile.deville@beta.gouv.fr>
2025-05-03 19:05:05 +02:00
lebaudantoine b79fa14919 🔖(minor) bump release to 0.1.21
Refactor authentication.
Address few bug bounties.
2025-05-01 16:49:30 +02:00
lebaudantoine c7c0df5b6d 🚸(frontend) add alert dialog for recording start failures
Implement modal alert dialog when recording initialization fails. Provides
clear error feedback to users when API cannot start recording process,
improving error state communication.
2025-04-30 14:54:41 +02:00
lebaudantoine cb00347be6 🚸(frontend) show spinner immediately on recording request initiation
Display loading spinner when recording request is sent instead of waiting
for API confirmation. Provides immediate feedback during slow server
responses to improve perceived responsiveness.
2025-04-30 14:54:41 +02:00
lebaudantoine bcb004ab4b 🥅(backend) add broad exception handling for non-twirp error in recording
Implement broad exception handling to catch any non-twirp errors
during recording operations. Ensures recording status is properly reset to
"failed to start" when errors occur, allowing users to retry the recording
while still logging errors to Sentry for investigation.

It's generally a bad practice, however in this case it's fine, I am
catching exception beforehand and it only acts as a fallback.
2025-04-30 14:54:41 +02:00
lebaudantoine 422f838899 🔒️(backend) remove accesses list from room serializer for non-admins
Restrict access to room user permissions data by excluding this information
from room serializer response for non-admin/owner users. Previously all
members could see complete access lists. Change enforces stricter information
access control based on user role.

Spotted in #YWH-PGM14336-5.
2025-04-30 14:13:30 +02:00
lebaudantoine 462c6c50e5 🔒️(backend) disable BrowsableAPIRenderer to prevent information leakage
Remove BrowsableAPIRenderer from API options, restricting output to JSON
format only. Prevents leakage of sensitive information like resource IDs and
user identifiers that were previously exposed in renderer dropdown options.

Issue identified in #YWH-PGM14336-4 report.
These information was considered as a critical disclosure by hackers.
2025-04-30 14:13:30 +02:00
lebaudantoine 63565b38c3 ♻️(backend) simplify ResourceAccess viewset implementation
Restructure ResourceAccess viewset to align with Room and Recording viewset
patterns. Clean up implementation while preserving identical behavior and
API contract. Improves code consistency and maintainability across related
viewsets.

ResourceAccessPermission inherits from IsAuthenticated.
2025-04-30 14:13:30 +02:00
Quentin BEY 10d759bdbb (backend) add django-lasuite dependency
Use the OIDC backend from the `django-lasuite` library
2025-04-28 23:38:45 +02:00
lebaudantoine 51f1f0ebbf 🔖(minor) bump release to 0.1.20
Misc fixes.
2025-04-28 23:13:31 +02:00
lebaudantoine 9e27d0f345 🚑️(frontend) throttle emoji reaction sending to prevent DoS attacks
Apply rate limiting to emoji reactions after discovering malicious users using
auto-clickers to flood the system and crash other participants' apps.
2025-04-28 20:08:47 +02:00
lebaudantoine 978d931bd7 (frontend) create hook for rate-limiting functions that could spam UI
Implement custom hook to throttle JavaScript function calls and prevent
interface performance degradation.
2025-04-28 20:08:47 +02:00
lebaudantoine 0c811222d4 ♻️(frontend) change Panel keepAlive default to false for all side panels
Reverse default behavior for Panel component to unmount content from DOM when
closed instead of keeping it alive. Makes DOM updates more lightweight by
removing unused panel content. Improves performance particularly in complex
room with hundred of participants.

Exception made for chat panel which retains keepAlive=true to preserve
unsent messages that users may want to submit later.
2025-04-28 18:04:26 +02:00
lebaudantoine 94171dcb82 (frontend) add keepAlive option to Panel component
Implement new keepAlive property for Panel component to control DOM retention
when panel is closed. When false, panel content is unmounted from DOM on
close, resetting scroll position and input states. Provides finer control
over panel behavior and memory management.
2025-04-28 18:04:26 +02:00
lebaudantoine 56c1cd98fa 🔧(frontend) make feedback form configurable via backend settings
Implement conditional rendering that hides all feedback-related UI components
when feedback is disabled in backend configuration.

Also, feedback URL is now customizable.
2025-04-28 17:37:31 +02:00
lebaudantoine f2e6edb90d 🚩(frontend) disable meeting rating when analytics is not configured
Hide the rating module in the feedback route when analytics service is
unavailable to self host La Suite Meet without analytics.
2025-04-28 17:37:31 +02:00
lebaudantoine bc76c44fe9 ♻️(frontend) refactor ParticipantName component for internationalization
Component now supports i18n. The participant tile needs further refactoring as
it still mixes LiveKit CSS with custom styling.
2025-04-28 16:49:06 +02:00
lebaudantoine e519f00342 🔥(frontend) remove duplicated aria-label from join screen username input
Eliminate redundant accessibility attribute that wasn't providing relevant
information to screen readers.
2025-04-28 16:48:52 +02:00
lebaudantoine 2246bb7782 ️(frontend) lower silent login retry interval from 5 min to 30 sec
This change enhances user experience by making automatic login more responsive.
Only occurs on app navigation/refresh, not internal navigation.

Testing in production, can revert if needed. Will later refactor to use
backend configuration.
2025-04-28 13:21:17 +02:00
lebaudantoine e210f26f9c 🐛(frontend) prevent silent login in webmail iframe integration
Fix calendar integration by preventing silent login triggers within webmail
iframes. Refactor code to only initialize app components when not in SDK
context. Resolves unexpected behavior in Firefox where iframes were
incorrectly rendering the homepage instead of intended calendar content.
2025-04-28 13:21:17 +02:00
lebaudantoine 888dfe76c7 🐛(backend) resolve backchannel calls to LiveKit in docker-compose
Fix container networking issue where app-dev container couldn't resolve
localhost address when calling LiveKit API. Update configuration to use
proper container network addressing for backchannel communication between
services.
2025-04-25 12:52:14 +02:00
lebaudantoine ae17fbdaa8 ♻️(backend) extract livekit API client creation to reusable utility
Create dedicated utility function for livekit API client initialization.
Centralizes configuration logic including custom session handling for SSL
verification. Improves code reuse across backend components that interact
with LiveKit.
2025-04-24 18:05:52 +02:00
lebaudantoine 2ef95aa835 ♻️(backend) update BaseEgress to use custom session from livekit-api
Refactor BaseEgress class to leverage latest livekit-api client's custom
session support. Simplifies code by using built-in capability to disable SSL
verification in development environments instead of previous workaround.
2025-04-24 18:05:52 +02:00
lebaudantoine a83e5c4b1c 🔥(backend) delete overly complex BaseEgress tests
Remove BaseEgress tests that were overly complicated and had excessive
mocking, making them unrealistic and difficult to maintain. Will replace with
more straightforward tests in future commits that better reflect actual code
behavior.
2025-04-24 18:05:52 +02:00
lebaudantoine 9cc79ba159 🩹(backend) correct typo in WorkerConfig parameter name
Fix minor spelling error in WorkerConfig parameter that had no functional
impact but improves code clarity and consistency.
2025-04-24 18:05:52 +02:00
lebaudantoine c63adf9c8c ⬆️(backend) upgrade livekit-api to latest version
Update livekit-api dependency to most recent release, enabling custom session
configuration. New version allows disabling SSL verification in local
development environment through session parameter support.
2025-04-24 18:05:52 +02:00
lebaudantoine 9366c8c4dd 🔖(minor) bump release to 0.1.19
Introduce screen recording.
2025-04-23 23:45:20 +02:00
lebaudantoine 3636bfa703 (frontend) display recording expiration status in frontend
Add recording expiration information to frontend interface, showing number
of days until expiration or indicating permanent status. For expired
recordings, display the date since which content has been unavailable.
Improves transparency about content lifecycle.
2025-04-23 19:52:29 +02:00
lebaudantoine 34c14cc516 (mail) include expiration information in recording notification emails
Add validity duration (number of days valid) to email
notifications for recordings. Informs users about their recording's lifespan,
providing important context about content availability.
2025-04-23 19:52:29 +02:00
lebaudantoine 1a0051a90b (backend) implement recording expiration mechanism
Add expiration system for recordings.

Include option for users to set recordings as permanent (no expiration)
which is the default behavior.

System only calculates expiration dates and tracks status - actual deletion
is handled by Minio bucket lifecycle policies, not by application code.
2025-04-23 18:53:42 +02:00
renovate[bot] af21478143 ⬆️(dependencies) update vite [SECURITY] 2025-04-23 15:36:44 +02:00
lebaudantoine 986b75ba39 (backend) personalize recording notification emails by user preferences
Customize email notifications for recording availability based on each user's
language and timezone settings. Improves user experience through localized
communications.

Prioritize simple, maintainable implementation over complex code that would
form subgroups based on user preferences. Note: Changes individual email
sending instead of batch processing, which may impact performance for large
groups but is acceptable for typical recording access patterns.
2025-04-23 15:36:44 +02:00
lebaudantoine df55fb2424 🐛(backend) rename copy-pasted unit tests
Fix inconsistent test naming resulting from copy-pasted examples. Rename
tests to properly reflect their actual testing purpose and improve code
maintainability.
2025-04-23 14:17:09 +02:00
lebaudantoine ec114808b2 (frontend) add hook to sync user preferences
Implement new hook that synchronizes user language and timezone preferences
from frontend to backend. Ensures consistent localization across and
notifications. Note: Current implementation works but auth code requires
future refactoring.
2025-04-23 14:17:09 +02:00
lebaudantoine 7f0e866eac ♻️(frontend) rename isLoading to isConfigLoading for clarity
Rename generic "isLoading" variable to more specific "isConfigLoading" to
accurately reflect its purpose. Improves code readability by making state
variable names more explicit.
2025-04-23 14:17:09 +02:00
lebaudantoine 8e0e286bc4 (backend) serialize user language and timezone for frontend use
Add user language and timezone to serialized user data to enable frontend
customization. Allows backend email notifications to respect user's
localization preferences for improved communication relevance.
2025-04-23 14:17:09 +02:00
lebaudantoine ee9148fe9f 🌐(backend) add and compile Dutch translations for backend
Add Dutch language translations for backend text strings and compile
translation files for production use. Improves localization support for
Dutch-speaking users.
2025-04-23 14:17:09 +02:00
lebaudantoine c863bc7456 🌐(backend) add and compile English translations for backend
Add English language translations for backend text strings and compile
translation files for production use. Improves localization support for
English-speaking users.
2025-04-23 14:17:09 +02:00
lebaudantoine eab361326f 🌐(backend) add and compile French translations for backend
Add French language translations for backend text strings and compile
translation files for production use. Improves localization support for
French-speaking users.
2025-04-23 14:17:09 +02:00
lebaudantoine 86c82f584c 🌐(backend) convert translation tags to blocktrans for quoted strings
Replace simple trans tags with blocktrans tags in download instructions
to properly handle quoted text in translations. Ensures quotes within
translated strings are correctly preserved during localization.
2025-04-23 14:17:09 +02:00
lebaudantoine af6ac954e9 🌐(backend) add Dutch language support to backend
Add Dutch (nl) language configuration to backend to match available frontend
languages. Ensures consistent language options across the entire application.
2025-04-23 14:17:09 +02:00
lebaudantoine 35cc6cd902 🌐(backend) regenerate translation files including email templates
Update translation files to include previously missed strings from email
templates. Ensures complete localization coverage across all backend
components including notification emails.
2025-04-23 14:17:09 +02:00
lebaudantoine 9cb7b0f154 ️(frontend) improve mic button accessibility in participant list
Add aria-label to microphone buttons in participant list to clearly indicate
mute functionality for current user and other participants. Set
aria-hidden on SVG icons to prevent redundant screen reader announcements.
2025-04-23 10:02:41 +02:00
lebaudantoine d105603a9b 🔖(helm) bump Helm chart version after job name modifications
Increment Helm chart version to reflect changes to backend job component
naming. Ensures proper versioning of configuration changes in deployment
pipeline.
2025-04-22 18:26:40 +02:00
lebaudantoine d2da1e37b9 🚚(helm) specify unique component names for all backend jobs
Update backend job configurations to use distinct component names instead of
sharing names with deployments. Prevents conflicts during cluster updates
and migrations that were causing unexpected behavior. Improves deployment
reliability and resource identification.
2025-04-22 18:26:40 +02:00
lebaudantoine 0206762e6d 🚚(helm) rename migration job to more explicit 'backend_job_migrate'
Rename backend migration job to a more descriptive name that clearly
indicates its purpose. Improves code clarity and makes deployment
configuration more self-documenting.
2025-04-22 18:26:40 +02:00
lebaudantoine c4fe341a74 🔧(backend) make data directory location configurable via env var
Add environment variable to control data directory location when building
outside of Docker. Improves flexibility for non-containerized deployments
where storing data at filesystem root is inappropriate or undesirable.
2025-04-22 16:25:59 +02:00
lebaudantoine bc53916c68 🚸(frontend) prevent auth screen flashing during user data fetch
Fix UI flickering where authentication screen briefly appeared for logged-in
users during initial data loading. Address issue caused by increased request
delay from waterfall cascade introduced by configurable silent auth setting.
2025-04-18 11:05:47 +02:00
lebaudantoine 886919c23d 🐛(backend) update media auth endpoint to check correct recording status
Modify media auth endpoint to properly handle recordings with "Notification
succeeded" status alongside "Saved" status. Previous code incorrectly
expected only "Saved" status, causing access issues after email notifications
were sent and status was updated.
2025-04-18 10:48:44 +02:00
lebaudantoine 4afbd9ba7f 🔐(helm) bump chart version
Bump chart version to publish a new one with media related
logic (ingress, service, etc…)
2025-04-18 10:01:05 +02:00
lebaudantoine a5fb3b910f (frontend) add meeting info side panel with copy functionality
Implement new side panel that provides easy access to meeting information
with copy/paste capabilities. Introduces xs text size to accommodate longer
URLs. Panel includes space for future documentation links about meeting
functionality. Addresses direct user requests for simpler sharing of meeting
details.
2025-04-18 09:42:09 +02:00
lebaudantoine d1a17d2aa9 🚸(frontend) add error handling for unsaved recording states
Implement proper error message display when recordings are unavailable due
to being active, pending webhook notification, or other transitional states.
Improves user experience by clearly communicating why content cannot be
accessed and setting appropriate expectations.
2025-04-17 16:58:33 +02:00
lebaudantoine e5af74685e (frontend) introduce dedicated recording download page
Create initial version of dedicated page for recording downloads, linked
directly from email notifications sent to users. Implementation is basic
but functional, serving as temporary solution until files can be stored in
drive storage. Enables recipients to access recordings through direct links.
2025-04-17 16:58:33 +02:00
lebaudantoine 06462a55b0 (frontend) create utility for media download URL generation
Encapsulate URL base generation logic for media downloads into a reusable
utility function, mirroring the approach used in the API. Improves code
consistency and reduces duplication across frontend components.
2025-04-17 16:58:33 +02:00
lebaudantoine 67967f00b5 (frontend) add date formatting utility function
Create reusable utility function to format dates into specified patterns.
Provides consistent date formatting across the application.
2025-04-17 16:58:33 +02:00
lebaudantoine a8053b46cd (frontend) implement API endpoint for recording details
Add new API call to retrieve detailed information about recordings.
Enables frontend to access metadata and status information needed for
download interfaces.
2025-04-17 16:58:33 +02:00
lebaudantoine b927be9f16 (frontend) serialize recording key for frontend download links
Add recording key to serialized API response to enable frontend to generate
proper download links without additional backend calls. Simplifies media
access workflow across the application.
2025-04-17 16:58:33 +02:00
lebaudantoine 2092d586ab 🎨(backend) harmonize decorator actions to lowercase format
Standardize all method decorator actions to lowercase format following DRF
documentation conventions. Creates consistent style across codebase.
2025-04-17 11:22:34 +02:00
lebaudantoine 132a1fbac7 🩹(backend) update HasPrivilegesOnRoom permission error message
Generalize error message in HasPrivilegesOnRoom permission class to reflect
its broader usage beyond just recording contexts. Improves clarity when
this permission check fails in various application scenarios.
2025-04-17 11:22:34 +02:00
lebaudantoine 90b4449040 (backend) add email invitation endpoint for meeting participants
Implement new endpoint allowing admin/owner to invite participants via email.
Provides explicit way to search users and send meeting invitations with
direct links.

In upcoming commits, frontend will call ResourceAccess endpoint to add
invited people as members if they exist in visio, bypassing waiting room
for a smoother experience.
2025-04-17 11:22:34 +02:00
lebaudantoine 205bb3aac1 (backend) introduce configuration for app base URL
Add new application base URL configuration setting. While somewhat redundant
with existing domain setting, these serve different purposes in the
application. Base URL will be used for constructing complete URLs in
notifications and external references.
2025-04-17 11:22:34 +02:00
lebaudantoine d5c9ee79f4 🩹(backend) fix email domain environment variable
Value wasn't properly set to a domain, but to an URL. Fix it.
2025-04-17 11:22:34 +02:00
lebaudantoine 1ef2bd99d8 🩹(backend) fix wrong environment variable
Oopsie, wrongly copy-pasted a Django settings leading in
using the wrong environment variable for a new setting.
I should have read my code;
2025-04-17 11:22:34 +02:00
lebaudantoine acbc5dba73 🩹(backend) fix exception messages
Oopsie, badly copy-pasted code when creating new serializer.
Not critical.
2025-04-17 11:22:34 +02:00
lebaudantoine 3438b4608c 🚸(frontend) make recording state toast clickable to reopen side panel
Enable recording status toast to function as clickable element that reopens
the side panel for admins and owners. Provides convenient way to access
recording controls when side panel has been previously closed.
2025-04-16 23:41:34 +02:00
lebaudantoine b4484540f7 🚸(frontend) add subtle indicator for active tools
Implement discreet visual notification that appears when tools are active.
Helps users locate and return to active tools they may have closed
during their session.
2025-04-16 23:41:34 +02:00
lebaudantoine 7021272075 🔒️(backend) remove personal data from email failure logs
Fix code that accidentally exposed personal email addresses in logs during
email sending failures. Modify logging to remove identifying information
to protect user privacy while still providing useful debugging context.

Original code was inspired by Docs.
2025-04-16 23:41:34 +02:00
lebaudantoine d92adb7435 🚸(frontend) differentiate loading and active recording states in toast
Add visual distinction between recording initialization and active recording
phases in status toast. Clearly communicates to users when recording becomes
active versus when it's still in the loading/preparation phase.
2025-04-16 23:41:34 +02:00
lebaudantoine d92c13d85a 🚸(frontend) increase toast visibility for recording status
Change recording toast color to red following standard recording conventions.
Improves visibility based on user feedback to make recording status more
apparent during active sessions.
2025-04-16 23:41:34 +02:00
lebaudantoine 46d60661f1 ♻️(frontend) align RecordingStateToast i18n keys with component name
Update translation keys to match component naming convention for improved
code maintainability and clarity.
2025-04-16 23:41:34 +02:00
lebaudantoine ba20fbe3a5 🚸(frontend) add beta tags to transcript and screen recording features
Add explicit beta tags to transcript and screen recording functionality to
clearly indicate these features are still in development. Helps set proper
user expectations by communicating that these capabilities may be unstable.
2025-04-16 23:41:34 +02:00
lebaudantoine f1b45af7d7 🚸(frontend) add loading spinner to recording start button
Add visual spinner indication to start button when initializing transcript
or screen recording. Provides clear feedback that recording process is
starting rather than leaving users uncertain about system status.
2025-04-16 23:41:34 +02:00
lebaudantoine 0b6869a4dc 🚸(frontend) add loading spinner for recording save process
Display loading spinner in side panel during transcript and screen recording
save operations. Provides visual feedback about ongoing processing that was
previously only indicated by title text, making the save status more explicit
to users.
2025-04-16 23:41:34 +02:00
lebaudantoine 7278061a80 🔧(backend) update egress worker layout for screen recording
Modify screen recording layout to focus on active speaker or shared screen
content. Provides better recording quality by prioritizing relevant visual
elements. Temporary solution until custom visio template is implemented.
2025-04-16 23:41:34 +02:00
lebaudantoine e5eb1e9916 🔧(backend) add backend toggle for silent login feature
Implement configuration option in backend to enable or disable silent login
functionality. Provides flexibility to control this authentication behavior
through server settings.

Requested by user self-hosting the project. Not all OIDC provider support
prompt=none param.
2025-04-16 23:37:04 +02:00
lebaudantoine 4060e891f2 🚸(frontend) add reset button during meeting creation process
Implement cancel/reset functionality that appears while meeting creation is
processing. Allows users to abort the operation if it stalls or encounters
issues, improving recovery from error states.
2025-04-16 23:35:47 +02:00
lebaudantoine 314468c68d 🚸(frontend) clarify "create a link" button text
Update button text based on user feedback to more clearly communicate that
it creates a link with Visio tool.
Improves user understanding of the feature's purpose.
2025-04-16 23:35:47 +02:00
lebaudantoine 52a7a6efab 🚸(frontend) add sound notification for lobby
Add a sound notification while a participant is waiting in the lobby.
KISS, use the same notification as the one when participant join
the room, thus, without any extra works, user can already toggle the
notification in settings.

In a v2, a dedicated notification could be added.
Requested by a user.
2025-04-16 23:34:55 +02:00
lebaudantoine 83d04c499b ⬆️ (dependencies) upgrade boto3 to more recent version
Update boto3 dependency to latest stable release to benefit from bug fixes,
performance improvements, and expanded AWS service support.
2025-04-16 12:13:42 +02:00
lebaudantoine 41c1f41ed2 (backend) add authenticated recording file access method
Implement secure recording file access through authentication instead of
exposing S3 bucket or using temporary signed links with loose permissions.
Inspired by docs and @spaccoud's implementation, with comprehensive
viewset checks to prevent unauthorized recording downloads.

The ingress reserved to media intercept the original request, and thanks to
Nginx annotations, check with the backend if the user is allowed to donwload
this recording file. This might introduce a dependency to Nginx in the project
by the way.

Note: Tests are integration-based rather than unit tests, requiring minio in
the compose stack and CI environment. Implementation includes known botocore
deprecation warnings that per GitHub issues won't be resolved for months.
2025-04-16 12:13:42 +02:00
lebaudantoine dc06b55693 (backend) enable retrieve viewset on recording model
Add Django built-in mixins to recording viewset to support individual record
retrieval. Enables frontend to access single recording details needed for
the upcoming download page implementation.
2025-04-16 12:13:42 +02:00
lebaudantoine 20aceb1932 (backend) add property to check if recording file is saved
Introduce new property that verifies if a recording file has a saved
status. While the implementation is straightforward, it improves code
readability and provides a clear, semantic way to check file status.
2025-04-16 12:13:42 +02:00
lebaudantoine 3671f2a0dd ♻️(backend) encapsulate recording key and extension logic as properties
Move logic for calculating recording keys and file extensions into proper
properties on recording objects. Simplifies access to Minio storage keys
and clearly documents expected behavior when saving recordings across the
application.
2025-04-16 12:13:42 +02:00
lebaudantoine d74dd967af ♻️(backend) replace hardcoded file extensions with Python enum
Convert hardcoded string file extensions into a well-defined Python enum.
Improves type safety and centralizes extension definitions for better
maintainability and consistency across the codebase.

It was dirty manipulating literals for file extension validation …
2025-04-16 12:13:42 +02:00
lebaudantoine 3a4f4e7016 (backend) add recording mode to serialized fields
Include recording mode in serialized data to enable conditional UI elements
in frontend. Allows download controls to be dynamically enabled or disabled
based on the specific recording type being used.

Screen recording will be downloadable when transcript won't.
2025-04-16 12:13:42 +02:00
lebaudantoine b7d964db56 (backend) add email notifications for screen recordings
Implement backend method to send email notifications when screen recordings
are ready for download. Enables users to be alerted when their recordings are
available. Frontend implementation to follow in upcoming commits.

This service is triggered by the storage hook from Minio.

Add minimal unit test coverage for notification service, addressing previous
lack of tests in this area. The notification service was responsible for
calling the unstable summary service feature, which was developped way too
quickly.

The email template has been reviewed by a LLM, to make it user-friendly and
crystal clear.
2025-04-15 13:46:57 +02:00
lebaudantoine 88b7a7dc58 🌐(backend) generate missing translations for backend updates
Regenerate translation files to include all recent backend string changes.
Address backlog of untranslated content that accumulated during recent
development cycles.
2025-04-14 21:00:46 +02:00
lebaudantoine baca9fc001 🌐(frontend) add missing german translations
Oopsie. I forgot some keys for german, add them.
2025-04-11 11:38:21 +02:00
lebaudantoine c432524f2a 🚸(frontend) display option to user with screen recording access
Introduce a feature flag for screen recording to allow gradual
rollout of the feature.
2025-04-11 11:38:21 +02:00
lebaudantoine a079ceef71 🚸(frontend) inform user when recording is saving
Saving a recording can take a bit of time, display a clear message to
inform user it can takes few minutes.
2025-04-11 11:38:21 +02:00
lebaudantoine f0742a0978 📈(frontend) add analytics on screen recording and transcript features
Use posthog to track whether user starts transcript or screen recording.
2025-04-11 11:38:21 +02:00
lebaudantoine fc1b4d7fa7 💄(frontend) replace recording side panel title with proper header
Update recording side panel to use semantic header element instead of plain
text. Improves accessibility by providing proper document structure and
enhances visual hierarchy in the user interface.
2025-04-11 11:38:21 +02:00
lebaudantoine 46f26eb493 ♻️(frontend) extract recording side panel into a dedicated folder
Extract code elements related to recording into a dedicated folder.
2025-04-11 11:38:21 +02:00
lebaudantoine ff09c3d969 ♻️(frontend) factorize notifyParticipants
Extract notifyParticipants in a proper hook to avoid duplication.
2025-04-11 11:38:21 +02:00
lebaudantoine ba9d22f6c8 ♻️(frontend) extract feature flag into a dedicated enum
Extract code elements related to feature flag into a proper enum
to avoid hardcoded literals.
2025-04-11 11:38:21 +02:00
lebaudantoine 94e71ba15d ♻️(frontend) extract recording api into a dedicated folder
Extract code elements related to recording into a dedicated folder.
2025-04-11 11:38:21 +02:00
lebaudantoine 9a1384b188 ♻️(frontend) rename RecordingStateBadge to RecordingStateToast
Rename component to match existing LiveKit naming conventions like
connectionStateToast. Improves naming consistency.
2025-04-11 11:38:21 +02:00
lebaudantoine 695ac47014 ♻️(frontend) extract recording hooks into a dedicated folder
Extract code elements related to recording into a dedicated folder.
2025-04-11 11:38:21 +02:00
lebaudantoine b3c1deeb9c 🍱(frontend) add La Suite logo
Will be necessary for email notifications, when notifying users their
recording are ready for download.
2025-04-11 11:38:21 +02:00
lebaudantoine c08c3efdbb 🩹(frontend) avoid having the two recording modes concurrently
Implement mutual exclusivity between transcript and screen recording modes
to prevent both from being active simultaneously. Add validation logic to
ensure users can only enable one recording type at a time.
2025-04-11 11:38:21 +02:00
lebaudantoine 468d09dc3b (frontend) introde the Screen Recording side panel
Offer one new tool to user, the screen recording side panel.
2025-04-11 11:38:21 +02:00
lebaudantoine a22d052f46 ♻️(frontend) generalize recording availability hook for all types
Extend recording availability and access hook to work with all recording
types instead of being transcript-specific. Create flexible implementation
that determines availability and permissions for screen recordings and
future recording formats.
2025-04-11 11:38:21 +02:00
lebaudantoine 9734df9d5d ♻️(frontend) generalize recording state badge for all recording types
Extend recording state badge component to work with all types of recordings
instead of just transcripts. Create flexible implementation that supports
screen recordings and future recording formats while maintaining consistent
visual indicators.
2025-04-11 11:38:21 +02:00
lebaudantoine f596aae1e8 ♻️(frontend) generalize transcript notification for all recording types
Convert transcript-specific toast notification into a flexible component
that works with any recording type. Create extensible design that can
accommodate screen recordings and future recording formats. Implementation
is functional though not perfect, with room for future enhancement.
2025-04-11 11:38:21 +02:00
lebaudantoine 32956f495f ⚰️(frontend) remove dead code
Traces of debugging, never been useful.
2025-04-11 11:38:21 +02:00
lebaudantoine 69381a6c4b 🌐(frontend) prepare translation keys for expanded recording types
Restructure translation keys to support a more extensive set
of recording types in the future.
2025-04-11 11:38:21 +02:00
lebaudantoine d537a4449a 🔖(minor) bump release to 0.1.18
Refactoring of transcription ui + SDK
2025-04-08 23:04:24 +02:00
lebaudantoine 50c9304afe (back) remove url-normalize dependency
We have the dependency url-normalize installed but we don't use it in
our codebase.

(from @lunika on docs)
2025-04-07 21:51:54 +02:00
lebaudantoine 37fe23c0f7 🚸(frontend) enhance transcript copywritting
Simplify the instructions. Also offer a way to user to discover
the feature and register as beta users.
2025-04-04 19:11:33 +02:00
lebaudantoine 255da4bf60 ♻️(frontend) refactor the transcription side panel into a sub menu
Add a new level of nesting while handling side panel.
Code quality is quite poor, we need a better interface.
2025-04-04 19:11:33 +02:00
lebaudantoine 6dccb507d2 🎨(frontend) remove duplicated forms literals
Use a proper constant, which already exists.
2025-04-04 19:11:33 +02:00
lebaudantoine d202a025e7 🚸(frontend) share more information about transcription state
Previous toast state was too naive. Enhance message deliver to
participants.
2025-04-04 19:11:33 +02:00
lebaudantoine ac9ba0df62 💩(frontend) introduce transcription store
Introduce a dedicated store for transcription to better manage its status
independently of meeting recording status. This lays the groundwork for
future improvements, especially as we plan to support additional recording
options beyond the current setup.

This isn't perfect and still coupled with room recording status
2025-04-04 19:11:33 +02:00
lebaudantoine 91562d049c ♻️(frontend) introduce a reusable tools sidepanel
tools will be added in the future, let's generalize the sidepanel
previously reserved to transcription.
2025-04-04 19:11:33 +02:00
lebaudantoine 60321296e5 (frontend) introduce a notification for transcription
Main goal: notify participant when the meeting is being recorded.
It helps understand the whole system behavior with clear
notification states.
2025-04-04 19:11:33 +02:00
lebaudantoine ae06873ff5 👔(frontend) update feedback form's URL
Gregoire reworked our framework on user feedbacks.
Update the Grist form's URL.
2025-04-04 15:57:45 +02:00
lebaudantoine 70ffb758c7 ♻️(frontend) refactor Grist forms urls in a single constants file
Consolidate all Grist forms into a single file to improve code organization
and enhance maintainability.
2025-04-04 15:57:45 +02:00
lebaudantoine 33a94da636 🩹(frontend) add a disabled style on tertiary button
Was lacking, necessary in my next jobs working on
transcript side pannel v2.
2025-04-04 15:57:45 +02:00
lebaudantoine aa6757601d 💄(frontend) force having medium font weight buttons
Improve the readability of the buttons by increasing their thickness.
2025-04-04 15:57:45 +02:00
lebaudantoine c26b1b711c (frontend) add external link support to LinkButton component
Enhanced LinkButton with target props for external link usage.
2025-04-04 15:57:45 +02:00
lebaudantoine 3ee33fc2ec ♻️(frontend) parametrize spinner size
Allow passing custom spinner size.
Needed to align the spinner height with an inline button.
2025-04-04 14:31:31 +02:00
lebaudantoine d43b2857c1 🩹(sdk) notify iframe parent when room is gathered through callback
On the first room creation, when the authentication redirection takes
place, the iframe wasn't properly messaging the parent.
Fixed it. Send the room data in any of the two methods to acquire it.

Actually, the code when gathering data through the callback endpoint is
a bit dirty.
2025-04-04 13:22:12 +02:00
lebaudantoine 3b1b644ef9 ⬆️(frontend) upgrade vite dependency
Necessary for security reasons, CVE-2025-30208.
2025-04-04 11:35:38 +02:00
lebaudantoine ab7a0e1ec2 ⬆️(sdk) upgrade vite dependency in consumer and library
Necessary for security reasons.
2025-04-04 11:35:38 +02:00
renovate[bot] 86a4d68daa ⬆️(dependencies) update django to v5.1.8 [SECURITY] 2025-04-04 10:44:07 +02:00
lebaudantoine de3df889ba (frontend) move support options to menu for a clearer UI
Relocated support options to the menu to keep the interface concise
and reduce visual clutter.
2025-04-04 10:43:51 +02:00
lebaudantoine 7c9fc15359 🚸(frontend) delay tooltip appearance in lateral menu
Improve UX by adding a delay before showing tooltips in the lateral menu.
Users often hover from bottom to top, causing tooltips to overlap other
options too quickly.
2025-04-04 10:43:51 +02:00
lebaudantoine a6a67f2752 🍱(readme) update readme to rename Visio to La Suite Meet
Visio is a MS product. In the official documentation call
our product La Suite Meet.
2025-04-03 23:34:49 +02:00
lebaudantoine 00eb52cd3f 🩹(summary) fix bold markdown syntax
Wrong syntaxt to handle bold, instead was italic.
2025-04-03 23:34:36 +02:00
lebaudantoine 6324be9fd3 🩹(frontend) fix missing notification when state is clear
Notify iframe's parent while integrating Visio when state
is cleared. Requested by Eleonore.
2025-04-03 23:33:23 +02:00
lebaudantoine 66f307b7e8 🐛(frontend) rework the calendar integration approach
Use a totally different strategy, which hopefully works in prod env.
Actually broadcast channel cannot be shared between two different
browsing context, even on a same domain, an iFrame and a pop up
cannot communicate.

Moreover, in an iFrame session cookie are unavailable.
Rely on a newly created backend endpoint to pass room data.
2025-04-02 12:31:04 +02:00
lebaudantoine 506b3978e1 🧑‍💻(frontend) move dev tool to the bottom left corner
Nothing happens in the bottom left corner.
Avoid hiding features while developing.
2025-04-02 12:31:04 +02:00
lebaudantoine db65aef56e 💄(frontend) introduc smNote text variant
Necessary for room information in calendar iframe.
2025-04-02 12:31:04 +02:00
lebaudantoine 4e1a4be650 (backend) introduce a creation callback endpoint
Necessary in cross browser context situation, where we need to
pass data of a room newly created between two different windows.

This happens in Calendar integration.
2025-04-02 12:31:04 +02:00
lebaudantoine 1f603ce17b 🔖(minor) bump release to 0.1.17
April fool by @arnaud-robin.
2025-03-31 23:49:04 +02:00
Arnaud Robin 672ca7dfe4 🚨(frontend) fix linting errors
- Fix linting errors in FaceLandmarksProcessor
- Fix linting errors in EffectsConfiguration
- Fix linting errors in useHasFaceLandmarksAccess
2025-03-31 22:57:02 +02:00
Arnaud Robin 7405011cd2 (frontend) add french touch effect
Enhanced the FaceLandmarksProcessor to include
a new 'french' effect, allowing users to add
a beret image to detected faces. Updated the
EffectsConfiguration component to toggle this
effect and modified localization files to
reflect the change from 'mustache' to 'french'.
2025-03-31 22:57:02 +02:00
Arnaud Robin 1d5aebcfdc (frontend) add mustache effects
Added functionality to display mustache effects
on detected faces. Enhanced the EffectsConfiguration
component to toggle these effects and updated
localization files for this effects.
2025-03-31 22:57:02 +02:00
Arnaud Robin 0d6881382b (frontend) add face landmarks flag
Introduced a new hook, useHasFaceLandmarksAccess
to manage access to face landmark features
based on posthog feature flags.
2025-03-31 22:57:02 +02:00
Arnaud Robin c428f39456 (frontend) add thug glasses
Added functionality to draw glasses on detected
faces by calculating eye positions and scaling
the glasses image accordingly. Initialized glasses
image in the constructor for improved visual
effects during face tracking.
2025-03-31 22:57:02 +02:00
Arnaud Robin cd9b80b966 (frontend) add face tracking
Implement face landmark processor
to be able to track face live.
2025-03-31 22:57:02 +02:00
Florent f80603b4bb 🐛(licence) fix the copyright years in licence
The project has started in 2024 and it is still active in 2025.
2025-03-29 20:30:32 +01:00
Berry den Hartog 3299ae7c39 📝(docs) describe environmental options for meet backend 2025-03-28 20:14:32 +01:00
Jacques ROUSSEL 93ca4f2bf4 🐛(ci) use github action for argocd webhook notification
In order to refactor this notification between alls projetcs, we
chooseto use a custom github action
2025-03-28 16:24:17 +01:00
lebaudantoine 19a4e442c5 🔖(minor) bump release to 0.1.16
Misc fixes, release diarization.
2025-03-25 23:27:41 +01:00
lebaudantoine 53f1c9c1d4 💥(summary) replace Whisper with WhisperX for transcription
Switch from insanely-fast-whisper to WhisperX for transcription services,
gaining word-level granularity not available with FlashAttention in previous
API. Whisper was packaged in FastAPI image with basic diarization and
speaker-segment association capabilities.

Implementation prioritized speed to market over testing. Future iterations
will enhance diarization quality and add proper test coverage. API consumers
should expect behavioral differences and new capabilities.
2025-03-25 17:09:13 +01:00
lebaudantoine 28538b63da (backend) add env variable to configure marketing service timeout
This change allows the marketing service timeout to be easily adjusted
via an environment variable, eliminating the need for a new software release.
Additionally, the update makes the code more explicit and easier to maintain.
2025-03-25 15:41:47 +01:00
renovate[bot] 94ae5d52c2 ⬆️(dependencies) update js dependencies
Updated the useUser hook to avoid unwrapping the query object directly,
which leads to excessive re-rendering.
2025-03-25 14:53:15 +01:00
lebaudantoine 6b38a3c996 💄(frontend) center titles in prejoin components
In non-French languages, the title may be long enough
to span two lines. Ensure the text is centered.
2025-03-25 13:38:10 +01:00
lebaudantoine 0af30ec366 (backend) notify participants only if the room exists
Improves sendData reliability by preventing execution when the room
doesn’t exist.

This change addresses errors in staging and production where waiting
participants arrive before the room owner creates the room.

In remote environments, the LiveKit Python SDK doesn’t return a clean
Twirp error when the room is missing; instead of a proper "server unknown"
response, it raises a ContentTypeError, as if the LiveKit server weren’t
responding with a JSON payload, even though the code specifies otherwise.

While the issue cannot be reproduced locally,
this should help mitigate production errors.

Part of a broader effort to enhance data transmission reliability.

Importantly, a participant requesting entry to a room before the owner
arrives should not be considered an exception.
2025-03-25 13:33:47 +01:00
lebaudantoine 33e017464f 📌(livekit) pin livekit dev version
Pin the LiveKit server version to use the same as the staging
and production environment. It's a good practice.
2025-03-25 13:18:44 +01:00
342 changed files with 15139 additions and 7751 deletions
-35
View File
@@ -1,35 +0,0 @@
name: Deploy
on:
push:
tags:
- 'preprod'
- 'production'
jobs:
notify-argocd:
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_WEBHOOK_URL }}
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_URL }}
start-test-on-preprod:
needs:
- notify-argocd
runs-on: ubuntu-latest
if: startsWith(github.event.ref, 'refs/tags/preprod')
steps:
-
name: Debug
run: |
echo "Start test when preprod is ready"
+43 -11
View File
@@ -49,7 +49,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-frontend:
build-and-push-frontend-generic:
runs-on: ubuntu-latest
steps:
-
@@ -83,6 +83,40 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-frontend-dinum:
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/meet-frontend-dinum
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/meet-frontend-dinum:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/dinum-frontend/Dockerfile
target: frontend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-summary:
runs-on: ubuntu-latest
steps:
@@ -113,18 +147,16 @@ jobs:
notify-argocd:
needs:
- build-and-push-frontend
- build-and-push-frontend-generic
- build-and-push-frontend-dinum
- build-and-push-backend
- build-and-push-summary
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/numerique-gouv/lasuite-deploiement"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}" | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}
- uses: numerique-gouv/action-argocd-webhook-notification@main
id: notify
with:
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
argocd_webhook_secret: "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}"
argocd_url: "${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}"
+32 -66
View File
@@ -81,7 +81,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.13"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
@@ -135,6 +135,9 @@ jobs:
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
LIVEKIT_API_SECRET: secret
LIVEKIT_API_KEY: devkey
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
steps:
- name: Checkout repository
@@ -152,10 +155,37 @@ jobs:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Start MinIO
run: |
docker pull minio/minio
docker run -d --name minio \
-p 9000:9000 \
-e "MINIO_ACCESS_KEY=meet" \
-e "MINIO_SECRET_KEY=password" \
-v /data/media:/data \
minio/minio server --console-address :9001 /data
# Tool to wait for a service to be ready
- name: Install Dockerize
run: |
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
- name: Wait for MinIO to be ready
run: |
dockerize -wait tcp://localhost:9000 -timeout 10s
- name: Configure MinIO
run: |
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
docker exec ${MINIO} sh -c \
"mc alias set meet http://localhost:9000 meet password && \
mc alias ls && \
mc mb meet/meet-media-storage"
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.13"
- name: Install development dependencies
run: pip install --user .[dev]
@@ -219,67 +249,3 @@ jobs:
- name: Build SDK
run: npm run build
i18n-crowdin:
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "infrastructure,secrets"
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
- name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
- name: Install gettext (required to make messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install development dependencies
working-directory: src/backend
run: pip install --user .[dev]
- name: Generate the translation base file
run: ~/.local/bin/django-admin makemessages --keep-pot --all
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18.x"
cache: "npm"
cache-dependency-path: src/frontend/package-lock.json
- name: Install dependencies
run: cd src/frontend/ && npm ci
- name: Extract the frontend translation
run: make frontend-i18n-extract
- name: Upload files to Crowdin
uses: crowdin/github-action@v2
with:
config: crowdin/config.yml
upload_sources: true
upload_translations: true
download_translations: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_BASE_PATH: ${{ github.workspace }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
+2
View File
@@ -8,3 +8,5 @@ and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
- 🔧(backend) support `_FILE` for secret environment variables #566
+4 -4
View File
@@ -1,7 +1,7 @@
# Django Meet
# ---- base image to inherit from ----
FROM python:3.12.6-alpine3.20 AS base
FROM python:3.13.5-alpine3.21 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
@@ -62,11 +62,11 @@ FROM base AS core
ENV PYTHONUNBUFFERED=1
RUN apk add \
gettext \
RUN apk --no-cache add \
cairo \
libffi-dev \
gdk-pixbuf \
gettext \
libffi-dev \
pango \
shared-mime-info
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2021-2023 DINUM/Etalab
Copyright (c) 2024-2025 DINUM/Etalab
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2021-2023 DINUM/Etalab
Copyright (c) 2024-2025 DINUM/Etalab
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+4
View File
@@ -347,3 +347,7 @@ start-tilt: ## start the kubernetes cluster using kind
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles
DEV_ENV=dev-dinum tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
+17 -16
View File
@@ -1,5 +1,5 @@
<p align="center">
<img alt="posthoglogo" src="./docs/assets/visio-logo.png" maxWidth="100%">
<img alt="meet logo" src="./docs/assets/banner-meet-fr.png" maxWidth="100%">
</p>
@@ -16,26 +16,26 @@
</p>
<p align="center">
<a href="https://livekit.io/">LiveKit</a> - <a href="https://go.crisp.chat/chat/embed/?website_id=58ea6697-8eba-4492-bc59-ad6562585041">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
<a href="https://livekit.io/">LiveKit</a> - <a href="https://matrix.to/#/#meet-official:matrix.org">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
</p>
<p align="center">
<a href="https://visio.numerique.gouv.fr/">
<img src="https://github.com/user-attachments/assets/09c1faa1-de88-4848-af3a-6fbe793999bf" alt="Visio Demonstration">
<img src="https://github.com/user-attachments/assets/09c1faa1-de88-4848-af3a-6fbe793999bf" alt="La Suite Meet Demonstration">
</a>
</p>
## Visio: Simple Video Conferencing
## La Suite Meet: Simple Video Conferencing
Powered by [LiveKit](https://livekit.io/), Visio offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
### Features
- Optimized for stability in large meetings (+100 p.)
- Support for multiple screen sharing streams
- Non-persistent, secure chat
- End-to-end encryption (coming soon)
- Meeting recording (coming soon)
- Meeting recording
- Meeting transcription (currently in beta)
- Telephony integration (in development)
- Telephony integration
- Secure participation with robust authentication and access control
- LiveKit Advances features including :
- speaker detection
@@ -45,7 +45,7 @@ Powered by [LiveKit](https://livekit.io/), Visio offers Zoom-level performance w
- SVC codecs (VP9, AV1)
Visio is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
Were continuously adding new features to enhance your experience, with the latest updates coming soon!
@@ -61,31 +61,31 @@ Were continuously adding new features to enhance your experience, with the la
## Get started
### Visio Cloud (Recommended)
Sign up for Visio Cloud, designed for European public servants. Hosted on SecNumCloud-compliant providers and accessible via government SSO, [ProConnect](https://www.proconnect.gouv.fr/). The easiest way to try our product. Reach out if your entity isn't connected yet to our sso.
### La Suite Meet Cloud (Recommended)
Sign up for La Suite Meet Cloud, designed for french public servants. Hosted on SecNumCloud-compliant providers and accessible via government SSO, [ProConnect](https://www.proconnect.gouv.fr/). The easiest way to try our product. Reach out if your entity isn't connected yet to our sso.
### Open-source deployment (Advanced)
Deploy Visio on your own infrastructure using [our self-hosting guide](https://github.com/suitenumerique/meet/blob/main/docs/installation.md). Our open-source deployment is optimized for Kubernetes, and we're working on supporting additional deployment options. Keycloak integration and any SSO are supported. We offer customer support for open-source setups—just reach out for assistance.
Deploy La Suite Meet on your own infrastructure using [our self-hosting guide](https://github.com/suitenumerique/meet/blob/main/docs/installation.md). Our open-source deployment is optimized for Kubernetes, and we're working on supporting additional deployment options. Keycloak integration and any SSO are supported. We offer customer support for open-source setups—just reach out for assistance.
## Docs
We're currently working on both technical and user documentation for Visio. In the meantime, many of the essential aspects are already well covered by the [LiveKit documentation](https://docs.livekit.io/home/) and their [self-hosting guide](https://docs.livekit.io/home/self-hosting/deployment/). Stay tuned for more updates!
We're currently working on both technical and user documentation for La Suite Meet. In the meantime, many of the essential aspects are already well covered by the [LiveKit documentation](https://docs.livekit.io/home/) and their [self-hosting guide](https://docs.livekit.io/home/self-hosting/deployment/). Stay tuned for more updates!
## Contributing
We <3 contributions of any kind, big and small:
- Vote on features or get early access to beta functionality in our [roadmap](https://github.com/orgs/suitenumerique/projects/3/views/2)
- Open a PR (see our instructions on [developing Visio locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
- Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
## Philosophy
Were relentlessly focused on building the best open-source video conferencing product—Visio. Growth comes from creating something people truly need, not just from chasing metrics.
Were relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics.
Our users come first. Were committed to making Visio as accessible and easy to use as proprietary solutions, ensuring it meets the highest standards.
Our users come first. Were committed to making La Suite Meet as accessible and easy to use as proprietary solutions, ensuring it meets the highest standards.
Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority.
@@ -99,7 +99,7 @@ To learn more, don't hesitate to [reach out](mailto:visio@numerique.gouv.fr).
### Help us!
Come help us make Visio even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr).
Come help us make La Suite Meet even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr).
## Contributors 🧞
@@ -111,6 +111,7 @@ Come help us make Visio even better. We're growing fast and [would love some hel
## Credits
We're using the awesome [LiveKit](https://livekit.io/) implementation. We're also thankful to the teams behind [Django Rest Framework](https://www.django-rest-framework.org/), [Vite.js](https://vite.dev/), and [React Aria](https://github.com/adobe/react-spectrum) — Thanks for your amazing work!
This project is tested with BrowserStack.
## License
+24 -3
View File
@@ -2,6 +2,15 @@ load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
load('ext://namespace', 'namespace_create', 'namespace_inject')
namespace_create('meet')
DEV_ENV = os.getenv('DEV_ENV', 'dev')
if DEV_ENV == 'dev-dinum':
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-generic:latest"])
if DEV_ENV == 'dev-keycloak':
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-dinum:latest"])
def clean_old_images(image_name):
local('docker images -q %s | tail -n +2 | xargs -r docker rmi' % image_name)
@@ -22,7 +31,19 @@ docker_build(
clean_old_images('localhost:5001/meet-backend')
docker_build(
'localhost:5001/meet-frontend:latest',
'localhost:5001/meet-frontend-dinum:latest',
context='..',
dockerfile='../docker/dinum-frontend/Dockerfile',
only=['./src/frontend', './docker', './.dockerignore'],
target = 'frontend-production',
live_update=[
sync('../src/frontend', '/home/frontend'),
]
)
clean_old_images('localhost:5001/meet-frontend-dinum')
docker_build(
'localhost:5001/meet-frontend-generic:latest',
context='..',
dockerfile='../src/frontend/Dockerfile',
only=['./src/frontend', './docker', './.dockerignore'],
@@ -31,7 +52,7 @@ docker_build(
sync('../src/frontend', '/home/frontend'),
]
)
clean_old_images('localhost:5001/meet-frontend')
clean_old_images('localhost:5001/meet-frontend-generic')
docker_build(
'localhost:5001/meet-summary:latest',
@@ -49,7 +70,7 @@ clean_old_images('localhost:5001/meet-summary')
# This is necessary because we need to inject the certificate into our LiveKit container
local_resource(
'copy-root-ca',
cmd='cp "$(mkcert -CAROOT)/rootCA.pem" ../docker/livekit/rootCA.pem',
cmd='cp -f "$(mkcert -CAROOT)/rootCA.pem" ../docker/livekit/rootCA.pem',
deps=[], # No dependencies needed
)
# Build a custom LiveKit Docker image that includes our root CA certificate
+36
View File
@@ -15,6 +15,37 @@ services:
ports:
- "1081:1080"
minio:
user: ${DOCKER_USER:-1000}
image: minio/minio
environment:
- MINIO_ROOT_USER=meet
- MINIO_ROOT_PASSWORD=password
ports:
- '9000:9000'
- '9001:9001'
healthcheck:
test: [ "CMD", "mc", "ready", "local" ]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server --console-address :9001 /data
volumes:
- ./data/media:/data
createbuckets:
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 mb meet/meet-media-storage && \
exit 0;"
app-dev:
build:
context: .
@@ -40,6 +71,9 @@ services:
- redis
- nginx
- livekit
- createbuckets
extra_hosts:
- "127.0.0.1.nip.io:host-gateway"
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -73,6 +107,7 @@ services:
- postgresql
- redis
- livekit
- minio
celery:
user: ${DOCKER_USER:-1000}
@@ -103,6 +138,7 @@ services:
target: frontend-production
args:
VITE_API_BASE_URL: "http://localhost:8071"
VITE_APP_TITLE: "LaSuite Meet"
image: meet:frontend-development
ports:
- "3000:8080"
+62
View File
@@ -0,0 +1,62 @@
# ---- Front-end image ----
FROM node:20-alpine AS frontend-deps
WORKDIR /home/frontend/
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/package-lock.json ./package-lock.json
RUN npm ci
COPY .dockerignore ./.dockerignore
COPY ./src/frontend/ .
# ---- Front-end builder image ----
FROM frontend-deps AS meet-builder
WORKDIR /home/frontend
ENV VITE_APP_TITLE="Visio"
ENV VITE_BUILD_SOURCEMAP="true"
RUN npm run build
# Inject PostHog sourcemap metadata into the built assets
# This metadata is essential for correctly mapping errors to source maps in production
RUN set -e && \
npx @posthog/cli sourcemap inject --directory ./dist/assets
COPY ./docker/dinum-frontend/dinum-styles.css \
./dist/assets/
COPY ./docker/dinum-frontend/logo.svg \
./dist/assets/logo.svg
COPY ./docker/dinum-frontend/assets/ \
./dist/assets/
COPY ./docker/dinum-frontend/fonts/ \
./dist/assets/fonts/
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
USER root
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2
USER nginx
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
COPY --from=meet-builder \
/home/frontend/dist \
/usr/share/nginx/html
COPY ./src/frontend/default.conf /etc/nginx/conf.d
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["nginx", "-g", "daemon off;"]

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="38.194309mm"
height="3.4877367mm"
viewBox="0 0 38.194309 3.4877367"
version="1.1"
id="svg1"
sodipodi:docname="gouvernement.svg"
inkscape:version="1.4.1 (93de688d07, 2025-03-30)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="6.1918409"
inkscape:cx="79.620909"
inkscape:cy="44.332534"
inkscape:window-width="1901"
inkscape:window-height="1037"
inkscape:window-x="5"
inkscape:window-y="5"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1">
<rect
x="111.07374"
y="333.22122"
width="541.4845"
height="297.51895"
id="rect2" />
<rect
x="158.10136"
y="358.3631"
width="425.55618"
height="204.21426"
id="rect1" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-53.656101,-100.08408)">
<path
d="m 55.435527,103.08144 c -0.72898,0 -1.249045,-0.56007 -1.249045,-1.25349 0,-0.69342 0.502285,-1.25349 1.204595,-1.25349 0.41783,0 0.760095,0.20447 0.96901,0.50673 l 0.333375,-0.25781 c -0.28448,-0.38227 -0.742315,-0.64008 -1.302385,-0.64008 -0.973455,0 -1.63576,0.75565 -1.63576,1.64465 0,0.889 0.680085,1.64465 1.68021,1.64465 0.582295,0 1.05791,-0.24892 1.346835,-0.64008 v -1.21349 h -1.31572 v 0.36005 h 0.89789 v 0.70231 c -0.217805,0.24447 -0.54229,0.40005 -0.929005,0.40005 z m 3.569334,-2.89814 c -0.973455,0 -1.63576,0.75565 -1.63576,1.64465 0,0.889 0.662305,1.64465 1.63576,1.64465 0.96901,0 1.631315,-0.75565 1.631315,-1.64465 0,-0.889 -0.662305,-1.64465 -1.631315,-1.64465 z m 0,2.89814 c -0.70231,0 -1.204595,-0.56007 -1.204595,-1.25349 0,-0.69342 0.502285,-1.25349 1.204595,-1.25349 0.697865,0 1.20015,0.56007 1.20015,1.25349 0,0.69342 -0.502285,1.25349 -1.20015,1.25349 z m 4.276088,-0.84455 c 0,0.53784 -0.31115,0.84455 -0.786765,0.84455 -0.48006,0 -0.79121,-0.30671 -0.79121,-0.84455 v -1.96469 h -0.41783 v 1.93802 c 0,0.8001 0.48006,1.26238 1.20904,1.26238 0.72898,0 1.204595,-0.46228 1.204595,-1.26238 v -1.93802 h -0.41783 z m 0.942341,-1.96469 1.2446,3.1115 h 0.55118 l 1.2446,-3.1115 h -0.4445 l -1.07569,2.68922 -1.07569,-2.68922 z m 3.653795,3.1115 h 1.67132 v -0.3556 h -1.25349 v -1.05347 h 1.07569 v -0.35115 h -1.07569 v -0.99568 h 1.25349 v -0.3556 h -1.67132 z m 2.502533,0 h 0.41783 v -1.36462 h 0.40894 c 0.03111,0 0.06667,0 0.09779,-0.004 l 0.902335,1.36906 h 0.493395 l -1.00457,-1.44907 c 0.333375,-0.13335 0.52451,-0.41339 0.52451,-0.78677 0,-0.5334 -0.386715,-0.87566 -1.01346,-0.87566 h -0.82677 z m 0.84455,-2.7559 c 0.3556,0 0.564515,0.19558 0.564515,0.51117 0,0.33338 -0.208915,0.52451 -0.564515,0.52451 h -0.42672 v -1.03568 z m 2.004699,2.7559 h 0.41783 v -2.60033 l 1.76022,2.60033 h 0.546735 v -3.1115 h -0.41783 v 2.60032 l -1.76022,-2.60032 h -0.546735 z m 3.71158,0 h 1.67132 v -0.3556 h -1.25349 v -1.05347 h 1.07569 v -0.35115 h -1.07569 v -0.99568 h 1.25349 v -0.3556 h -1.67132 z m 2.502533,0 h 0.41783 v -2.55143 l 0.90678,1.48463 h 0.32004 l 0.90678,-1.48463 v 2.55143 h 0.41783 v -3.1115 h -0.5334 l -0.95123,1.56908 -0.95123,-1.56908 h -0.5334 z m 3.951605,0 h 1.67132 v -0.3556 h -1.25349 v -1.05347 h 1.07569 v -0.35115 h -1.07569 v -0.99568 h 1.25349 v -0.3556 h -1.67132 z m 2.502535,0 h 0.41783 v -2.60033 l 1.76022,2.60033 h 0.546735 v -3.1115 h -0.41783 v 2.60032 l -1.76022,-2.60032 H 85.89712 Z m 3.373758,-2.72923 h 1.03124 v 2.72923 h 0.41783 v -2.72923 h 1.03124 v -0.38227 h -2.48031 z"
id="text3"
style="font-size:4.445px;line-height:0.661464px;font-family:Marianne;-inkscape-font-specification:'Marianne, Normal';letter-spacing:0px;word-spacing:0px;display:inline;stroke:#000000;stroke-width:0.198437"
aria-label="GOUVERNEMENT" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

+70
View File
@@ -0,0 +1,70 @@
:root {
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
}
.Header-beforeLogo {
display: block;
}
.Header-beforeLogo::before {
content: '';
display: block;
background-image: url(/assets/marianne.svg);
background-position: 0 -0.046875rem;
background-size: 2.0625rem 0.84375rem;
height: 0.75rem;
margin-bottom: 0.1rem;
width: 2.0625rem;
}
.Header-beforeLogo::after {
content: '';
display: block;
background-image: url(/assets/gouvernement.svg), url(/assets/devise.svg);
background-repeat: no-repeat, no-repeat;
background-size: 108.8px 10px, 40px 29px;
background-position: 0 3px, 0 18.9px;
width: 108.8px;
height: 48px;
margin-top: 0.25rem;
}
@font-face {
font-family: 'Marianne';
src: url('/assets/fonts/Marianne-Regular-subset.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Marianne';
src: url('/assets/fonts/Marianne-Regular_Italic-subset.woff2') format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Marianne';
src: url('/assets/fonts/Marianne-Medium-subset.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Marianne';
src: url('/assets/fonts/Marianne-Bold-subset.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Marianne';
src: url('/assets/fonts/Marianne-ExtraBold-subset.woff2') format('woff2');
font-weight: 800;
font-style: normal;
font-display: swap;
}

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
FROM livekit/livekit-server:latest
FROM livekit/livekit-server:v1.8.0
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
COPY rootCA.pem /etc/ssl/certs/
Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

+147 -15
View File
@@ -2,8 +2,7 @@
This document is a step-by-step guide that describes how to install Visio on a k8s cluster without AI features.
## Prerequisites
## Prerequisites for a kubernetes setup
- k8s cluster with an nginx-ingress controller
- an OIDC provider (if you don't have one, we will provide an example)
@@ -13,14 +12,25 @@ This document is a step-by-step guide that describes how to install Visio on a k
### Test cluster
If you do not have a test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
If you do not have a kubernetes test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script located in this repo under **bin/start-kind.sh**.
To be able to use the script, you will need to install:
IMPORTANT: The kind method will only deploy meet as a local instance(127.0.0.1) that can only be accessed from the device where it has been deployed.
To be able to use the script, you will need to install the following components:
- Docker (https://docs.docker.com/desktop/)
- Kind (https://kind.sigs.k8s.io/docs/user/quick-start/#installation)
- Mkcert (https://github.com/FiloSottile/mkcert#installation)
- Helm (https://helm.sh/docs/intro/quickstart/#install-helm)
- kubectl (https://kubernetes.io/docs/tasks/tools/)
In order to initiate the local kind installation via **start-kind.sh** do the following:
1) Make sure administrator/root user context is able to execute mkcert, docker, kind etc. commands or the script might fail
2) Download the script to the device where the above components are installed
3) Make the script executable
4) Run the script with proper permissions (administrator/sudo etc.)
The output of the script will resemble the below example:
```
$ ./bin/start-kind.sh
@@ -43,11 +53,11 @@ It will expire on 23 March 2027 🗓
2. Create kind cluster with containerd registry config dir enabled
Creating cluster "visio" ...
✓ Ensuring node image (kindest/node:v1.27.3) 🖼
✓ Preparing nodes 📦
✓ Writing configuration 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
✓ Preparing nodes 📦
✓ Writing configuration 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
Set kubectl context to "kind-visio"
You can now use your cluster with:
@@ -95,13 +105,14 @@ ingress-nginx-admission-create-jgnc9 0/1 Completed 0 2m44s
ingress-nginx-admission-patch-wrt47 0/1 Completed 0 2m44s
ingress-nginx-controller-57c548c4cd-9xwt6 1/1 Running 0 2m44s
```
When your k8s cluster is ready, you can start the deployment. This cluster is special because it uses the *.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
Please remember that *.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
When your k8s cluster is ready, you can start the deployment. This cluster is special because it uses the \*.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
## Preparation
Please remember that \*.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
### What will you use to authenticate your users ?
## Preparation of components
### What will you use to authenticate your users ?
Visio uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Visio) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
@@ -111,7 +122,7 @@ If you haven't run the script **bin/start-kind.sh**, you'll need to manually cre
$ kubectl create namespace meet
```
If you have already run the script, you can skip this step and proceed to the next instruction.
If you have already run the script, you can skip this step and proceed to the next instruction. NOTE: Before you proceed, and is using the kind method, make sure you download this repo examples/ directory and its contents to the location where you will be executing the helm command. Helm will look for "examples/<name>values.yaml" from based on the path it is being executed.
```
$ kubectl config set-context --current --namespace=meet
@@ -153,6 +164,7 @@ keycloak-0 1/1 Running 0 26m
keycloak-postgresql-0 1/1 Running 0 26m
redis-master-0 1/1 Running 0 35s
```
When the redis is ready we can deploy livekit-server.
```
@@ -194,6 +206,7 @@ livekit-livekit-server-5c5fb87f7f-ct6x5 1/1 Running 0 15m
postgresql-0 1/1 Running 0 50s
redis-master-0 1/1 Running 0 19
```
From here important information you will need are :
```
@@ -230,4 +243,123 @@ meet <none> meet.127.0.0.1.nip.io localhost 80, 44
meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 443 52m
```
You can use Visio on https://meet.127.0.0.1.nip.io. The provisioning user in keycloak is meet/meet.
You can use Visio on https://meet.127.0.0.1.nip.io from the local device. The provisioning user in keycloak is meet/meet.
## All options
These are the environmental options available on meet backend.
| Option | Description | default |
| ----------------------------------------------- |------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
| DATA_DIR | Data directory location | /data |
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
| DJANGO_SECRET_KEY | Secret key used for Django security | |
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
| DB_NAME | Name of the database | meet |
| DB_USER | User used to connect to database | dinum |
| DB_PASSWORD | Password used to connect to the database | pass |
| DB_HOST | Hostname of the database | localhost |
| DB_PORT | Port to connect to database | 5432 |
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
| AWS_S3_SECRET_ACCESS_KEY | S3 secret key | |
| AWS_S3_REGION_NAME | S3 region | |
| AWS_STORAGE_BUCKET_NAME | S3 bucket name | meet-media-storage |
| DJANGO_LANGUAGE_CODE | Default language | en-us |
| REDIS_URL | Redis endpoint | redis://redis:6379/1 |
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
| CSRF_TRUSTED_ORIGINS | CSRF trusted origins list | [] |
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
| FRONTEND_ANALYTICS | Analytics information | {} |
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
| FRONTEND_TRANSCRIPT | Frontend transcription configuration, you can pass a beta form, with `form_beta_users` | {} |
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_HOST | Host of the email server | |
| DJANGO_EMAIL_HOST_USER | User to connect to the email server | |
| DJANGO_EMAIL_HOST_PASSWORD | Password to connect to the email server | |
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
| EMAIL_BRAND_NAME | Email branding name | |
| EMAIL_SUPPORT_EMAIL | Support email address | |
| EMAIL_LOGO_IMG | Email logo image | |
| EMAIL_DOMAIN | Email domain | |
| EMAIL_APP_BASE_URL | Email app base URL | |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
| DJANGO_CORS_ALLOWED_ORIGINS | Origins to allow (string list) | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | Origins to allow (regex patterns) | [] |
| SENTRY_DSN | Sentry server DSN | |
| DJANGO_CELERY_BROKER_URL | Celery broker host | redis://redis:6379/0 |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
| OIDC_CREATE_USER | Create OIDC user if not exists | true |
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | false |
| OIDC_RP_SIGN_ALGO | Token verification algorithm used by OIDC | RS256 |
| OIDC_RP_CLIENT_ID | OIDC client ID | meet |
| OIDC_RP_CLIENT_SECRET | OIDC client secret | |
| OIDC_OP_JWKS_ENDPOINT | OIDC endpoint for JWKS | |
| OIDC_OP_AUTHORIZATION_ENDPOINT | OIDC endpoint for authorization | |
| OIDC_OP_TOKEN_ENDPOINT | OIDC endpoint for token | |
| OIDC_OP_USER_ENDPOINT | OIDC endpoint for user | |
| OIDC_OP_USER_ENDPOINT_FORMAT | OIDC endpoint format (AUTO, JWT, JSON) | AUTO |
| OIDC_OP_LOGOUT_ENDPOINT | OIDC endpoint for logout | |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters for OIDC request | {} |
| OIDC_RP_SCOPES | OIDC scopes | openid email |
| OIDC_USE_NONCE | Use nonce for OIDC | true |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC | false |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed redirect hosts for OIDC | [] |
| OIDC_STORE_ID_TOKEN | Store OIDC ID token | true |
| OIDC_REDIRECT_FIELD_NAME | Redirect field for OIDC | returnTo |
| OIDC_USERINFO_FULLNAME_FIELDS | Full name claim from OIDC token | ["given_name", "usual_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | Short name claim from OIDC token | given_name |
| OIDC_USERINFO_ESSENTIAL_CLAIMS | Required claims from OIDC token | [] |
| LOGIN_REDIRECT_URL | Login redirect URL | |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect URL for failure | |
| LOGOUT_REDIRECT_URL | URL to redirect to on logout | |
| ALLOW_LOGOUT_GET_METHOD | Allow logout through GET method | true |
| LIVEKIT_API_KEY | LiveKit API key | |
| LIVEKIT_API_SECRET | LiveKit API secret | |
| LIVEKIT_API_URL | LiveKit API URL | |
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| 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 | false |
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
| BREVO_API_KEY | Brevo API key for marketing emails | |
| BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] |
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} |
| BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 |
| LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby |
| LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 |
| LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 |
| LOBBY_ACCEPTED_TIMEOUT | Lobby accept timeout in seconds | 21600 (6 hours) |
| LOBBY_NOTIFICATION_TYPE | Lobby notification types | participantWaiting |
| LOBBY_COOKIE_NAME | Lobby cookie name | lobbyParticipantId |
| ROOM_CREATION_CALLBACK_CACHE_TIMEOUT | Room creation callback cache timeout | 600 (10 minutes) |
| ROOM_TELEPHONY_ENABLED | Enable SIP telephony feature | false |
| ROOM_TELEPHONY_PIN_LENGTH | Telephony PIN length | 10 |
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Telephony PIN maximum retries | 5 |
+20 -2
View File
@@ -12,12 +12,20 @@ PYTHONPATH=/app
# Mail
DJANGO_EMAIL_HOST="mailcatcher"
DJANGO_EMAIL_PORT=1025
DJANGO_EMAIL_BRAND_NAME=La Suite Numérique
DJANGO_EMAIL_SUPPORT_EMAIL=test@yopmail.com
DJANGO_EMAIL_LOGO_IMG=http://localhost:3000/assets/logo-suite-numerique.png
DJANGO_EMAIL_DOMAIN=localhost:3000
DJANGO_EMAIL_APP_BASE_URL=http://localhost:3000
# Backend url
MEET_BASE_URL="http://localhost:8072"
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
@@ -34,11 +42,21 @@ LOGIN_REDIRECT_URL=http://localhost:3000
LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000
LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_REDIRECT_ALLOWED_HOSTS=localhost:8083,localhost:3000
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# Livekit Token settings
LIVEKIT_API_SECRET=secret
LIVEKIT_API_KEY=devkey
LIVEKIT_API_URL=http://localhost:7880
LIVEKIT_API_URL=http://127.0.0.1.nip.io:7880
LIVEKIT_VERIFY_SSL=False
ALLOW_UNREGISTERED_ROOMS=False
# Recording
SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
# Telephony
ROOM_TELEPHONY_ENABLED=True
FRONTEND_USE_FRENCH_GOV_FOOTER=False
FRONTEND_USE_PROCONNECT_BUTTON=False
+1 -1
View File
@@ -7,7 +7,7 @@
"enabled": false,
"groupName": "ignored python dependencies",
"matchManagers": ["pep621"],
"matchPackageNames": []
"matchPackageNames": ["redis"]
},
{
"enabled": false,
+30 -1
View File
@@ -99,6 +99,7 @@ class ResourceAccessInline(admin.TabularInline):
model = models.ResourceAccess
extra = 0
autocomplete_fields = ["user"]
@admin.register(models.Room)
@@ -106,6 +107,10 @@ class RoomAdmin(admin.ModelAdmin):
"""Room admin interface declaration."""
inlines = (ResourceAccessInline,)
search_fields = ["name", "slug", "=id"]
list_display = ["name", "slug", "access_level", "created_at"]
list_filter = ["access_level", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
class RecordingAccessInline(admin.TabularInline):
@@ -120,4 +125,28 @@ class RecordingAdmin(admin.ModelAdmin):
"""Recording admin interface declaration."""
inlines = (RecordingAccessInline,)
list_display = ("id", "status", "room", "created_at", "worker_id")
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
list_display = ("id", "status", "room", "get_owner", "created_at", "worker_id")
list_filter = ["status", "room", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
return super().get_queryset(request).prefetch_related("accesses__user")
def get_owner(self, obj):
"""Return the owner of the recording for display in the admin list."""
owners = [
access
for access in obj.accesses.all()
if access.role == models.RoleChoices.OWNER
]
if not owners:
return _("No owner")
if len(owners) > 1:
return _("Multiple owners")
return str(owners[0].user)
+8
View File
@@ -40,6 +40,14 @@ def get_frontend_configuration(request):
"recording": {
"is_enabled": settings.RECORDING_ENABLE,
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
},
"telephony": {
"enabled": settings.ROOM_TELEPHONY_ENABLED,
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
if settings.ROOM_TELEPHONY_ENABLED
else None,
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
},
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
+4 -4
View File
@@ -64,7 +64,7 @@ class RoomPermissions(permissions.BasePermission):
if request.method == "DELETE":
return obj.is_owner(user)
return obj.is_administrator(user)
return obj.is_administrator_or_owner(user)
class ResourceAccessPermission(IsAuthenticated):
@@ -80,7 +80,7 @@ class ResourceAccessPermission(IsAuthenticated):
if request.method == "DELETE" and obj.role == RoleChoices.OWNER:
return obj.user == user
return obj.resource.is_administrator(user)
return obj.resource.is_administrator_or_owner(user)
class HasAbilityPermission(IsAuthenticated):
@@ -94,11 +94,11 @@ class HasAbilityPermission(IsAuthenticated):
class HasPrivilegesOnRoom(IsAuthenticated):
"""Check if user has privileges on a given room."""
message = "You must have privileges to start a recording."
message = "You must have privileges on room to perform this action."
def has_object_permission(self, request, view, obj):
"""Determine if user has privileges on room."""
return obj.is_owner(request.user) or obj.is_administrator(request.user)
return obj.is_administrator_or_owner(request.user)
class IsRecordingEnabled(permissions.BasePermission):
+70 -15
View File
@@ -1,9 +1,12 @@
"""Client serializers for the Meet core app."""
import uuid
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
from core import models, utils
@@ -11,9 +14,11 @@ from core import models, utils
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
timezone = TimeZoneSerializerField()
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name"]
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"]
@@ -34,10 +39,10 @@ class ResourceAccessSerializerMixin:
# Update
self.instance
and (
data["role"] == models.RoleChoices.OWNER
data.get("role") == models.RoleChoices.OWNER
and not self.instance.resource.is_owner(user)
or self.instance.role == models.RoleChoices.OWNER
and not self.instance.user == user
and self.instance.user != user
)
) or (
# Create
@@ -55,7 +60,9 @@ class ResourceAccessSerializerMixin:
request = self.context.get("request", None)
user = getattr(request, "user", None)
if not (user and user.is_authenticated and resource.is_administrator(user)):
if not (
user and user.is_authenticated and resource.is_administrator_or_owner(user)
):
raise PermissionDenied(
_("You must be administrator or owner of a room to add accesses to it.")
)
@@ -100,8 +107,8 @@ class RoomSerializer(serializers.ModelSerializer):
class Meta:
model = models.Room
fields = ["id", "name", "slug", "configuration", "access_level"]
read_only_fields = ["id", "slug"]
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
read_only_fields = ["id", "slug", "pin_code"]
def to_representation(self, instance):
"""
@@ -115,9 +122,11 @@ class RoomSerializer(serializers.ModelSerializer):
return output
role = instance.get_role(request.user)
is_admin = models.RoleChoices.check_administrator_role(role)
is_admin_or_owner = models.RoleChoices.check_administrator_role(
role
) or models.RoleChoices.check_owner_role(role)
if role is not None:
if is_admin_or_owner:
access_serializer = NestedResourceAccessSerializer(
instance.accesses.select_related("resource", "user").all(),
context=self.context,
@@ -125,7 +134,7 @@ class RoomSerializer(serializers.ModelSerializer):
)
output["accesses"] = access_serializer.data
if not is_admin:
if not is_admin_or_owner:
del output["configuration"]
should_access_room = (
@@ -144,7 +153,7 @@ class RoomSerializer(serializers.ModelSerializer):
room_id=room_id, user=request.user, username=username
)
output["is_administrable"] = is_admin
output["is_administrable"] = is_admin_or_owner
return output
@@ -156,7 +165,17 @@ class RecordingSerializer(serializers.ModelSerializer):
class Meta:
model = models.Recording
fields = ["id", "room", "created_at", "updated_at", "status"]
fields = [
"id",
"room",
"created_at",
"updated_at",
"status",
"mode",
"key",
"is_expired",
"expired_at",
]
read_only_fields = fields
@@ -189,11 +208,11 @@ class RequestEntrySerializer(serializers.Serializer):
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")
raise NotImplementedError("RequestEntrySerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")
raise NotImplementedError("RequestEntrySerializer is validation-only")
class ParticipantEntrySerializer(serializers.Serializer):
@@ -202,10 +221,46 @@ class ParticipantEntrySerializer(serializers.Serializer):
participant_id = serializers.CharField(required=True)
allow_entry = serializers.BooleanField(required=True)
def validate_participant_id(self, value):
"""Validate that the participant_id is a valid UUID hex string."""
try:
uuid.UUID(hex=value, version=4)
except (ValueError, TypeError) as e:
raise serializers.ValidationError("Invalid UUID hex format") from e
return value
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")
raise NotImplementedError("ParticipantEntrySerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")
raise NotImplementedError("ParticipantEntrySerializer is validation-only")
class CreationCallbackSerializer(serializers.Serializer):
"""Validate room creation callback data."""
callback_id = serializers.CharField(required=True)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("CreationCallbackSerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("CreationCallbackSerializer is validation-only")
class RoomInviteSerializer(serializers.Serializer):
"""Validate room invite creation data."""
emails = serializers.ListField(child=serializers.EmailField(), allow_empty=False)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RoomInviteSerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RoomInviteSerializer is validation-only")
+171 -27
View File
@@ -2,6 +2,7 @@
import uuid
from logging import getLogger
from urllib.parse import urlparse
from django.conf import settings
from django.db.models import Q
@@ -20,7 +21,8 @@ from rest_framework import (
status as drf_status,
)
from core import models, utils
from core import enums, models, utils
from core.recording.enums import FileExtension
from core.recording.event.authentication import StorageEventAuthentication
from core.recording.event.exceptions import (
InvalidBucketError,
@@ -39,6 +41,7 @@ from core.recording.worker.factories import (
from core.recording.worker.mediator import (
WorkerServiceMediator,
)
from core.services.invitation import InvitationService
from core.services.livekit_events import (
LiveKitEventsService,
LiveKitWebhookError,
@@ -47,6 +50,7 @@ from core.services.lobby import (
LobbyParticipantNotFound,
LobbyService,
)
from core.services.room_creation import RoomCreation
from . import permissions, serializers
@@ -186,6 +190,12 @@ class RequestEntryAnonRateThrottle(throttling.AnonRateThrottle):
scope = "request_entry"
class CreationCallbackAnonRateThrottle(throttling.AnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback"""
scope = "creation_callback"
class RoomViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
@@ -268,6 +278,9 @@ class RoomViewSet(
role=models.RoleChoices.OWNER,
)
if callback_id := self.request.data.get("callback_id"):
RoomCreation().persist_callback_state(callback_id, room)
@decorators.action(
detail=True,
methods=["post"],
@@ -353,7 +366,7 @@ class RoomViewSet(
@decorators.action(
detail=True,
methods=["POST"],
methods=["post"],
url_path="request-entry",
permission_classes=[],
throttle_classes=[RequestEntryAnonRateThrottle],
@@ -436,7 +449,7 @@ class RoomViewSet(
@decorators.action(
detail=False,
methods=["POST"],
methods=["post"],
url_path="webhooks-livekit",
permission_classes=[],
)
@@ -460,41 +473,70 @@ class RoomViewSet(
{"status": "error", "message": str(e)}, status=status_code
)
@decorators.action(
detail=False,
methods=["post"],
url_path="creation-callback",
permission_classes=[],
throttle_classes=[CreationCallbackAnonRateThrottle],
)
def creation_callback(self, request):
"""Retrieve cached room data via an unauthenticated request with a unique ID.
class ResourceAccessListModelMixin:
"""List mixin for resource access API."""
Designed for interoperability across iframes, popups, and other contexts,
even on the same domain, bypassing browser security restrictions on direct communication.
"""
def get_permissions(self):
"""User only needs to be authenticated to list rooms access"""
if self.action == "list":
permission_classes = [permissions.IsAuthenticated]
else:
return super().get_permissions()
serializer = serializers.CreationCallbackSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
return [permission() for permission in permission_classes]
room = RoomCreation().get_callback_state(
callback_id=serializer.validated_data.get("callback_id")
)
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
if self.action == "list":
user = self.request.user
queryset = queryset.filter(
Q(resource__accesses__user=user),
resource__accesses__role__in=[
models.RoleChoices.ADMIN,
models.RoleChoices.OWNER,
],
).distinct()
return queryset
return drf_response.Response(
{"status": "success", "room": room}, status=drf_status.HTTP_200_OK
)
@decorators.action(
detail=True,
methods=["post"],
url_path="invite",
permission_classes=[
permissions.HasPrivilegesOnRoom,
],
)
def invite(self, request, pk=None): # pylint: disable=unused-argument
"""Send email invitations to join a room.
This API endpoint allows a user with appropriate privileges to send email invitations
to one or more recipients, inviting them to join the specified room.
"""
room = self.get_object()
serializer = serializers.RoomInviteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
emails = serializer.validated_data.get("emails")
emails = list(set(emails))
InvitationService().invite_to_room(
room=room, sender=request.user, emails=emails
)
return drf_response.Response(
{"status": "success", "message": "invitations sent"},
status=drf_status.HTTP_200_OK,
)
class ResourceAccessViewSet(
ResourceAccessListModelMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""
@@ -505,10 +547,30 @@ class ResourceAccessViewSet(
queryset = models.ResourceAccess.objects.all()
serializer_class = serializers.ResourceAccessSerializer
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
# Restrict access to resources the user either has explicit
# permissions for or administrative privileges over.
if self.action == "list":
user = self.request.user
queryset = queryset.filter(
Q(resource__accesses__user=user),
resource__accesses__role__in=[
models.RoleChoices.ADMIN,
models.RoleChoices.OWNER,
],
).distinct()
return queryset
class RecordingViewSet(
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
"""
@@ -582,3 +644,85 @@ class RecordingViewSet(
return drf_response.Response(
{"message": "Event processed."},
)
def _auth_get_original_url(self, request):
"""
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
Raises PermissionDenied if the header is missing.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
Based on the original url and the logged-in user, we must decide if we authorize Nginx
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
"""
# Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
if not original_url:
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
raise drf_exceptions.PermissionDenied()
logger.debug("Original url: '%s'", original_url)
return urlparse(original_url)
def _auth_get_url_params(self, pattern, fragment):
"""
Extracts URL parameters from the given fragment using the specified regex pattern.
Raises PermissionDenied if parameters cannot be extracted.
"""
match = pattern.search(fragment)
try:
return match.groupdict()
except (ValueError, AttributeError) as exc:
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
raise drf_exceptions.PermissionDenied() from exc
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
def media_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to a recording's
media file.
When we let the request go through, we compute authorization headers that will be added to
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
annotation. The request will then be proxied to the object storage backend who will
respond with the file after checking the signature included in headers.
"""
parsed_url = self._auth_get_original_url(request)
url_params = self._auth_get_url_params(
enums.RECORDING_STORAGE_URL_PATTERN, parsed_url.path
)
user = request.user
recording_id = url_params["recording_id"]
extension = url_params["extension"]
if extension not in [item.value for item in FileExtension]:
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
try:
recording = models.Recording.objects.get(id=recording_id)
except models.Recording.DoesNotExist as e:
raise drf_exceptions.NotFound("No recording found for this event.") from e
if extension != recording.extension:
raise drf_exceptions.NotFound("No recording found with this extension.")
abilities = recording.get_abilities(user)
if not abilities["retrieve"]:
logger.debug("User '%s' lacks permission for attachment", user.id)
raise drf_exceptions.PermissionDenied()
if not recording.is_saved:
logger.debug("Recording '%s' has not been saved", recording)
raise drf_exceptions.PermissionDenied()
request = utils.generate_s3_authorization_headers(recording.key)
return drf_response.Response("authorized", headers=request.headers, status=200)
-11
View File
@@ -1,11 +0,0 @@
"""Meet Core application"""
# from django.apps import AppConfig
# from django.utils.translation import gettext_lazy as _
# class CoreConfig(AppConfig):
# """Configuration class for the Meet core app."""
# name = "core"
# app_label = "core"
# verbose_name = _("meet core application")
+34 -107
View File
@@ -1,12 +1,13 @@
"""Authentication Backends for the Meet core app."""
import contextlib
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from django.utils.translation import gettext_lazy as _
import requests
from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
)
from core.models import User
@@ -17,93 +18,46 @@ from core.services.marketing import (
)
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
This class overrides the default OIDC Authentication Backend to accommodate differences
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
"""
def get_userinfo(self, access_token, id_token, payload):
"""Return user details dictionary.
def get_extra_claims(self, user_info):
"""
Return extra claims from user_info.
Parameters:
- access_token (str): The access token.
- id_token (str): The id token (unused).
- payload (dict): The token payload (unused).
Note: The id_token and payload parameters are unused in this implementation,
but were kept to preserve base method signature.
Note: It handles signed and/or encrypted UserInfo Response. It is required by
Agent Connect, which follows the OIDC standard. It forces us to override the
base method, which deal with 'application/json' response.
Args:
user_info (dict): The user information dictionary.
Returns:
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
dict: A dictionary of extra claims.
"""
user_response = requests.get(
self.OIDC_OP_USER_ENDPOINT,
headers={"Authorization": f"Bearer {access_token}"},
verify=self.get_settings("OIDC_VERIFY_SSL", True),
timeout=self.get_settings("OIDC_TIMEOUT", None),
proxies=self.get_settings("OIDC_PROXY", None),
)
user_response.raise_for_status()
userinfo = self.verify_token(user_response.text)
return userinfo
def get_or_create_user(self, access_token, id_token, payload):
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
Parameters:
- access_token (str): The access token.
- id_token (str): The ID token.
- payload (dict): The user payload.
Returns:
- User: An existing or newly created User instance.
Raises:
- Exception: Raised when user creation is not allowed and no existing user is found.
"""
user_info = self.get_userinfo(access_token, id_token, payload)
sub = user_info.get("sub")
if not sub:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
email = user_info.get("email")
user = self.get_existing_user(sub, email)
claims = {
"email": email,
return {
# Get user's full name from OIDC fields defined in settings
"full_name": self.compute_full_name(user_info),
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
}
if not user and self.get_settings("OIDC_CREATE_USER", True):
user = User.objects.create(
sub=sub,
password="!", # noqa: S106
**claims,
)
if settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
self.signup_to_marketing_email(email)
def post_get_or_create_user(self, user, claims, is_new_user):
"""
Post-processing after user creation or retrieval.
elif not user:
return None
Args:
user (User): The user instance.
claims (dict): The claims dictionary.
is_new_user (bool): Indicates if the user was newly created.
if not user.is_active:
raise SuspiciousOperation(_("User account is disabled"))
Returns:
- None
self.update_user_if_needed(user, claims)
return user
"""
email = claims["email"]
if is_new_user and email and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
self.signup_to_marketing_email(email)
@staticmethod
def signup_to_marketing_email(email):
@@ -116,14 +70,16 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
"""
try:
with contextlib.suppress(
ContactCreationError, ImproperlyConfigured, ImportError
):
marketing_service = get_marketing_service()
contact_data = ContactData(
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
)
marketing_service.create_contact(contact_data, timeout=1)
except (ContactCreationError, ImproperlyConfigured, ImportError):
pass
marketing_service.create_contact(
contact_data, timeout=settings.BREVO_API_TIMEOUT
)
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
@@ -137,35 +93,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
pass
except User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
_("Multiple user accounts share a common email.")
"Multiple user accounts share a common email."
) from e
return None
@staticmethod
def compute_full_name(user_info):
"""Compute user's full name based on OIDC fields in settings."""
full_name = " ".join(
filter(
None,
(
user_info.get(field)
for field in settings.OIDC_USERINFO_FULLNAME_FIELDS
),
)
)
return full_name or None
@staticmethod
def update_user_if_needed(user, claims):
"""Update user claims if they have changed."""
user_fields = vars(user.__class__) # Get available model fields
updated_claims = {
key: value
for key, value in claims.items()
if value and key in user_fields and value != getattr(user, key)
}
if not updated_claims:
return
User.objects.filter(sub=user.sub).update(**updated_claims)
-18
View File
@@ -1,18 +0,0 @@
"""Authentication URLs for the People core app."""
from django.urls import path
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
from .views import OIDCLogoutCallbackView, OIDCLogoutView
urlpatterns = [
# Override the default 'logout/' path from Mozilla Django OIDC with our custom view.
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
path(
"logout-callback/",
OIDCLogoutCallbackView.as_view(),
name="oidc_logout_callback",
),
*mozzila_oidc_urls,
]
-181
View File
@@ -1,181 +0,0 @@
"""Authentication Views for the People core app."""
import copy
from urllib.parse import urlencode
from django.contrib import auth
from django.core.exceptions import SuspiciousOperation
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils import crypto
from mozilla_django_oidc.utils import (
absolutify,
)
from mozilla_django_oidc.views import (
OIDCAuthenticationCallbackView as MozillaOIDCAuthenticationCallbackView,
)
from mozilla_django_oidc.views import (
OIDCAuthenticationRequestView as MozillaOIDCAuthenticationRequestView,
)
from mozilla_django_oidc.views import (
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
)
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
Adds support for handling logout callbacks from the identity provider (OP)
by initiating the logout flow if the user has an active session.
The Django session is retained during the logout process to persist the 'state' OIDC parameter.
This parameter is crucial for maintaining the integrity of the logout flow between this call
and the subsequent callback.
"""
@staticmethod
def persist_state(request, state):
"""Persist the given 'state' parameter in the session's 'oidc_states' dictionary
This method is used to store the OIDC state parameter in the session, according to the
structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session'
utility function.
"""
if "oidc_states" not in request.session or not isinstance(
request.session["oidc_states"], dict
):
request.session["oidc_states"] = {}
request.session["oidc_states"][state] = {}
request.session.save()
def construct_oidc_logout_url(self, request):
"""Create the redirect URL for interfacing with the OIDC provider.
Retrieves the necessary parameters from the session and constructs the URL
required to initiate logout with the OpenID Connect provider.
If no ID token is found in the session, the logout flow will not be initiated,
and the method will return the default redirect URL.
The 'state' parameter is generated randomly and persisted in the session to ensure
its integrity during the subsequent callback.
"""
oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT")
if not oidc_logout_endpoint:
return self.redirect_url
reverse_url = reverse("oidc_logout_callback")
id_token = request.session.get("oidc_id_token", None)
if not id_token:
return self.redirect_url
query = {
"id_token_hint": id_token,
"state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)),
"post_logout_redirect_uri": absolutify(request, reverse_url),
}
self.persist_state(request, query["state"])
return f"{oidc_logout_endpoint}?{urlencode(query)}"
def post(self, request):
"""Handle user logout.
If the user is not authenticated, redirects to the default logout URL.
Otherwise, constructs the OIDC logout URL and redirects the user to start
the logout process.
If the user is redirected to the default logout URL, ensure her Django session
is terminated.
"""
logout_url = self.redirect_url
if request.user.is_authenticated:
logout_url = self.construct_oidc_logout_url(request)
# If the user is not redirected to the OIDC provider, ensure logout
if logout_url == self.redirect_url:
auth.logout(request)
return HttpResponseRedirect(logout_url)
class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
"""Custom view for handling the logout callback from the OpenID Connect (OIDC) provider.
Handles the callback after logout from the identity provider (OP).
Verifies the state parameter and performs necessary logout actions.
The Django session is maintained during the logout process to ensure the integrity
of the logout flow initiated in the previous step.
"""
http_method_names = ["get"]
def get(self, request):
"""Handle the logout callback.
If the user is not authenticated, redirects to the default logout URL.
Otherwise, verifies the state parameter and performs necessary logout actions.
"""
if not request.user.is_authenticated:
return HttpResponseRedirect(self.redirect_url)
state = request.GET.get("state")
if state not in request.session.get("oidc_states", {}):
msg = "OIDC callback state not found in session `oidc_states`!"
raise SuspiciousOperation(msg)
del request.session["oidc_states"][state]
request.session.save()
auth.logout(request)
return HttpResponseRedirect(self.redirect_url)
class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
"""Custom callback view for handling the silent login flow."""
@property
def failure_url(self):
"""Override the failure URL property to handle silent login flow
A silent login failure (e.g., no active user session) should not be
considered as an authentication failure.
"""
if self.request.session.get("silent", None):
del self.request.session["silent"]
self.request.session.save()
return self.success_url
return super().failure_url
class OIDCAuthenticationRequestView(MozillaOIDCAuthenticationRequestView):
"""Custom authentication view for handling the silent login flow."""
def get_extra_params(self, request):
"""Handle 'prompt' extra parameter for the silent login flow
This extra parameter is necessary to distinguish between a standard
authentication flow and the silent login flow.
"""
extra_params = self.get_settings("OIDC_AUTH_REQUEST_EXTRA_PARAMS", None)
if extra_params is None:
extra_params = {}
if request.GET.get("silent") == "true":
extra_params = copy.deepcopy(extra_params)
extra_params.update({"prompt": "none"})
request.session["silent"] = True
request.session.save()
return extra_params
+12
View File
@@ -2,9 +2,21 @@
Core application enums declaration
"""
import re
from django.conf import global_settings, settings
from django.utils.translation import gettext_lazy as _
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
# pylint: disable=line-too-long
RECORDING_STORAGE_URL_PATTERN = re.compile(
f"/media/{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s}).(?P<extension>{FILE_EXT_REGEX:s})"
)
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
# the choice of languages which should not be limited to the few languages active in
# the app.
-1
View File
@@ -1,4 +1,3 @@
# ruff: noqa: S311
"""
Core application factories
"""
@@ -0,0 +1,18 @@
# Generated by Django 5.1.8 on 2025-04-22 14:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0012_alter_room_access_level'),
]
operations = [
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('nl-nl', 'Dutch'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
@@ -0,0 +1,45 @@
# Generated by Django 5.1.9 on 2025-05-13 08:22
import secrets
from django.db import migrations, models
def generate_pin_for_rooms(apps, schema_editor):
"""Generate unique 10-digit PIN codes for existing rooms.
The PIN code is required for SIP telephony features.
"""
Room = apps.get_model('core', 'Room')
rooms_without_pin_code = Room.objects.filter(pin_code__isnull=True)
existing_pins = set(Room.objects.values_list('pin_code', flat=True))
length = 10
def generate_pin_code():
while True:
pin_code = str(secrets.randbelow(10 ** length)).zfill(length)
if pin_code not in existing_pins:
return pin_code
for room in rooms_without_pin_code:
room.pin_code = generate_pin_code()
room.save()
class Migration(migrations.Migration):
dependencies = [
('core', '0013_alter_user_language'),
]
operations = [
migrations.AddField(
model_name='room',
name='pin_code',
field=models.CharField(blank=True, help_text='Unique n-digit code that identifies this room in telephony mode.', max_length=None, null=True, unique=True, verbose_name='Room PIN code'),
),
migrations.RunPython(
generate_pin_for_rooms,
reverse_code=migrations.RunPython.noop
),
]
+107 -10
View File
@@ -2,9 +2,11 @@
Declare and configure the models for the Meet core application
"""
import secrets
import uuid
from datetime import datetime, timedelta
from logging import getLogger
from typing import List
from typing import List, Optional
from django.conf import settings
from django.contrib.auth import models as auth_models
@@ -12,12 +14,14 @@ from django.contrib.auth.base_user import AbstractBaseUser
from django.core import mail, validators
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import models
from django.utils.functional import lazy
from django.utils import timezone
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
from timezone_field import TimeZoneField
from .recording.enums import FileExtension
logger = getLogger(__name__)
@@ -31,7 +35,7 @@ class RoleChoices(models.TextChoices):
@classmethod
def check_administrator_role(cls, role):
"""Check if a role is administrator."""
return role in [cls.ADMIN, cls.OWNER]
return role == cls.ADMIN
@classmethod
def check_owner_role(cls, role):
@@ -160,7 +164,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
)
language = models.CharField(
max_length=10,
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
choices=settings.LANGUAGES,
default=settings.LANGUAGE_CODE,
verbose_name=_("language"),
help_text=_("The language in which the user wants to see the interface."),
@@ -284,13 +288,13 @@ class Resource(BaseModel):
role = RoleChoices.MEMBER
return role
def is_administrator(self, user):
def is_administrator_or_owner(self, user):
"""
Check if a user is administrator of the resource.
Users carrying the "owner" role are considered as administrators a fortiori.
"""
return RoleChoices.check_administrator_role(self.get_role(user))
Check if a user is administrator or owner of the resource."""
role = self.get_role(user)
return RoleChoices.check_administrator_role(
role
) or RoleChoices.check_owner_role(role)
def is_owner(self, user):
"""Check if a user is owner of the resource."""
@@ -379,6 +383,14 @@ class Room(Resource):
verbose_name=_("Visio room configuration"),
help_text=_("Values for Visio parameters to configure the room."),
)
pin_code = models.CharField(
max_length=None,
unique=True,
blank=True,
null=True,
verbose_name=_("Room PIN code"),
help_text=_("Unique n-digit code that identifies this room in telephony mode."),
)
class Meta:
db_table = "meet_room"
@@ -389,6 +401,14 @@ class Room(Resource):
def __str__(self):
return capfirst(self.name)
def save(self, *args, **kwargs):
"""Generate a unique n-digit pin code for new rooms."""
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
self.pin_code = self.generate_unique_pin_code(
length=settings.ROOM_TELEPHONY_PIN_LENGTH
)
super().save(*args, **kwargs)
def clean_fields(self, exclude=None):
"""
Automatically generate the slug from the name and make sure it does not look like a UUID.
@@ -403,6 +423,7 @@ class Room(Resource):
pass
else:
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
super().clean_fields(exclude=exclude)
@property
@@ -410,6 +431,31 @@ class Room(Resource):
"""Check if a room is public"""
return self.access_level == RoomAccessLevel.PUBLIC
@staticmethod
def generate_unique_pin_code(length):
"""Generate a unique n-digit PIN code"""
if length < 4:
raise ValueError(
"PIN code length must be at least 4 digits for minimal security"
)
max_value = 10**length
for _ in range(settings.ROOM_TELEPHONY_PIN_MAX_RETRIES):
pin_code = str(secrets.randbelow(max_value)).zfill(length)
if not Room.objects.filter(pin_code=pin_code).exists():
return pin_code
# Log a warning as a temporary measure until backend observability is implemented.
logger.warning(
"Failed to generate unique PIN code of length %s after %s attempts",
length,
settings.ROOM_TELEPHONY_PIN_MAX_RETRIES,
)
return None
class BaseAccessManager(models.Manager):
"""Base manager for handling resource access control."""
@@ -576,6 +622,57 @@ class Recording(BaseModel):
RecordingStatusChoices.STOPPED,
}
@property
def is_saved(self) -> bool:
"""Check if the recording is in a saved state."""
return self.status in {
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
RecordingStatusChoices.SAVED,
}
@property
def extension(self):
"""Get recording extension based on its mode."""
extensions = {
RecordingModeChoices.TRANSCRIPT: FileExtension.OGG.value,
RecordingModeChoices.SCREEN_RECORDING: FileExtension.MP4.value,
}
return extensions.get(self.mode, FileExtension.MP4.value)
@property
def key(self):
"""Generate the file key based on recording mode."""
return f"{settings.RECORDING_OUTPUT_FOLDER}/{self.id}.{self.extension}"
@property
def expired_at(self) -> Optional[datetime]:
"""
Calculate the expiration date based on created_at and RECORDING_EXPIRATION_DAYS.
Returns None if no expiration is configured.
Note: This is a naive and imperfect implementation since recordings are actually
saved to the bucket after created_at timestamp is set. The actual expiration
will be determined by the bucket lifecycle policy which operates on the object's
timestamp in the storage system, not this value.
"""
if not settings.RECORDING_EXPIRATION_DAYS:
return None
return self.created_at + timedelta(days=settings.RECORDING_EXPIRATION_DAYS)
@property
def is_expired(self) -> bool:
"""
Determine if the recording has expired by comparing expired_at with current UTC time.
Returns False if no expiration is configured or if expiration date is in the future.
"""
if not self.expired_at:
return False
return self.expired_at < timezone.now()
class RecordingAccess(BaseAccess):
"""Relation model to give access to a recording for a user or a team with a role."""
+10
View File
@@ -0,0 +1,10 @@
"""Enums related to recordings."""
from enum import Enum
class FileExtension(Enum):
"""Enum for file extensions used in recordings."""
OGG = "ogg"
MP4 = "mp4"
@@ -55,7 +55,7 @@ class StorageEventAuthentication(BaseAuthentication):
if not required_token:
if settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
raise AuthenticationFailed(
_("Authentication is enabled but token is not configured.")
"Authentication is enabled but token is not configured."
)
return MachineUser(), None
@@ -67,7 +67,7 @@ class StorageEventAuthentication(BaseAuthentication):
"Authentication failed: Missing Authorization header (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed(_("Authorization header is required"))
raise AuthenticationFailed("Authorization header is required")
auth_parts = auth_header.split(" ")
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
@@ -75,7 +75,7 @@ class StorageEventAuthentication(BaseAuthentication):
"Authentication failed: Invalid authorization header (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed(_("Invalid authorization header."))
raise AuthenticationFailed("Invalid authorization header.")
token = auth_parts[1]
@@ -85,7 +85,7 @@ class StorageEventAuthentication(BaseAuthentication):
"Authentication failed: Invalid token (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed(_("Invalid token"))
raise AuthenticationFailed("Invalid token")
return MachineUser(), token
@@ -1,8 +1,13 @@
"""Service to notify external services when a new recording is ready."""
import logging
import smtplib
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
import requests
@@ -21,10 +26,7 @@ class NotificationService:
return self._notify_summary_service(recording)
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
logger.warning(
"Screen recording mode not implemented for recording %s", recording.id
)
return False
return self._notify_user_by_email(recording)
logger.error(
"Unknown recording mode %s for recording %s",
@@ -33,6 +35,79 @@ class NotificationService:
)
return False
@staticmethod
def _notify_user_by_email(recording) -> bool:
"""
Send an email notification to recording owners when their recording is ready.
The email includes a direct link that redirects owners to a dedicated download
page in the frontend where they can access their specific recording.
"""
owner_accesses = (
models.RecordingAccess.objects.select_related("user")
.filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
)
.order_by("created_at")
)
if not owner_accesses:
logger.error("No owner found for recording %s", recording.id)
return False
context = {
"brandname": settings.EMAIL_BRAND_NAME,
"support_email": settings.EMAIL_SUPPORT_EMAIL,
"logo_img": settings.EMAIL_LOGO_IMG,
"domain": settings.EMAIL_DOMAIN,
"room_name": recording.room.name,
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"link": f"{settings.SCREEN_RECORDING_BASE_URL}/{recording.id}",
}
has_failures = False
# We process emails individually rather than in batch because:
# 1. Each email requires personalization (timezone, language)
# 2. The number of recipients per recording is typically small (not thousands)
for access in owner_accesses:
user = access.user
language = user.language or get_language()
with override(language):
personalized_context = {
"recording_date": recording.created_at.astimezone(
user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
user.timezone
).strftime("%H:%M"),
**context,
}
msg_html = render_to_string(
"mail/html/screen_recording.html", personalized_context
)
msg_plain = render_to_string(
"mail/text/screen_recording.txt", personalized_context
)
subject = str(_("Your recording is ready")) # Force translation
try:
send_mail(
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
[user.email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("notification could not be sent: %s", exception)
has_failures = True
return not has_failures
@staticmethod
def _notify_summary_service(recording):
"""Notify summary service about a new recording."""
@@ -57,10 +132,8 @@ class NotificationService:
logger.error("No owner found for recording %s", recording.id)
return False
key = f"{settings.RECORDING_OUTPUT_FOLDER}/{recording.id}.ogg"
payload = {
"filename": key,
"filename": recording.key,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
}
+2 -2
View File
@@ -86,7 +86,7 @@ class MinioParser:
# pylint: disable=line-too-long
self._filepath_regex = re.compile(
r"(?P<url_encoded_folder_path>(?:[^%]+%2F)*)?(?P<recording_id>[0-9a-fA-F\-]{36})\.(?P<extension>[a-zA-Z0-9]+)"
r"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?(?P<recording_id>[0-9a-fA-F\-]{36})\.(?P<extension>[a-zA-Z0-9]+)"
)
@staticmethod
@@ -123,7 +123,7 @@ class MinioParser:
f"Invalid bucket: expected {self._bucket_name}, got {event_data.bucket_name}"
)
if not event_data.filetype in self._allowed_filetypes:
if event_data.filetype not in self._allowed_filetypes:
raise InvalidFileTypeError(
f"Invalid file type, expected {self._allowed_filetypes},"
f"got '{event_data.filetype}'"
@@ -17,7 +17,6 @@ class WorkerServiceConfig:
output_folder: str
server_configurations: Dict[str, Any]
verify_ssl: Optional[bool]
bucket_args: Optional[dict]
@classmethod
@@ -29,7 +28,6 @@ class WorkerServiceConfig:
return cls(
output_folder=settings.RECORDING_OUTPUT_FOLDER,
server_configurations=settings.LIVEKIT_CONFIGURATION,
verify_ssl=settings.RECORDING_VERIFY_SSL,
bucket_args={
"endpoint": settings.AWS_S3_ENDPOINT_URL,
"access_key": settings.AWS_S3_ACCESS_KEY_ID,
+26 -18
View File
@@ -2,11 +2,11 @@
# pylint: disable=no-member
import aiohttp
from asgiref.sync import async_to_sync
from livekit import api as livekit_api
from livekit.api.egress_service import EgressService
from ... import utils
from ..enums import FileExtension
from .exceptions import WorkerConnectionError, WorkerResponseError
from .factories import WorkerServiceConfig
@@ -28,21 +28,26 @@ class BaseEgressService:
async def _handle_request(self, request, method_name: str):
"""Handle making a request to the LiveKit API and returns the response."""
# Use HTTP connector for local development with Tilt,
# where cluster communications are unsecure
connector = aiohttp.TCPConnector(ssl=self._config.verify_ssl)
lkapi = utils.create_livekit_client(self._config.server_configurations)
async with aiohttp.ClientSession(connector=connector) as session:
client = EgressService(session, **self._config.server_configurations)
method = getattr(client, method_name)
try:
response = await method(request)
except livekit_api.TwirpError as e:
raise WorkerConnectionError(
f"LiveKit client connection error, {e.message}."
) from e
# ruff: noqa: SLF001
# pylint: disable=protected-access
method = getattr(lkapi._egress, method_name)
try:
response = await method(request)
return response
except livekit_api.TwirpError as e:
raise WorkerConnectionError(
f"LiveKit client connection error, {e.message}."
) from e
except Exception as e:
raise WorkerConnectionError(
f"Unexpected error during LiveKit client connection: {str(e)}"
) from e
finally:
await lkapi.aclose()
def stop(self, worker_id: str) -> str:
"""Stop an ongoing egress worker.
@@ -89,7 +94,9 @@ class VideoCompositeEgressService(BaseEgressService):
# Save room's recording as a mp4 video file.
file_type = livekit_api.EncodedFileType.MP4
filepath = self._get_filepath(filename=recording_id, extension="mp4")
filepath = self._get_filepath(
filename=recording_id, extension=FileExtension.MP4.value
)
file_output = livekit_api.EncodedFileOutput(
file_type=file_type,
@@ -98,8 +105,7 @@ class VideoCompositeEgressService(BaseEgressService):
)
request = livekit_api.RoomCompositeEgressRequest(
room_name=room_name,
file_outputs=[file_output],
room_name=room_name, file_outputs=[file_output], layout="speaker-light"
)
response = self._handle_request(request, "start_room_composite_egress")
@@ -120,7 +126,9 @@ class AudioCompositeEgressService(BaseEgressService):
# Save room's recording as an ogg audio file.
file_type = livekit_api.EncodedFileType.OGG
filepath = self._get_filepath(filename=recording_id, extension="ogg")
filepath = self._get_filepath(
filename=recording_id, extension=FileExtension.OGG.value
)
file_output = livekit_api.EncodedFileOutput(
file_type=file_type,
+59
View File
@@ -0,0 +1,59 @@
"""Invitation Service."""
import smtplib
from logging import getLogger
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
logger = getLogger(__name__)
class InvitationError(Exception):
"""Exception raised when invitation emails cannot be sent."""
status_code = 500
class InvitationService:
"""Service for invitations to users."""
@staticmethod
def invite_to_room(room, sender, emails):
"""Send invitation emails to join a room."""
language = get_language()
context = {
"brandname": settings.EMAIL_BRAND_NAME,
"logo_img": settings.EMAIL_LOGO_IMG,
"domain": settings.EMAIL_DOMAIN,
"room_url": f"{settings.EMAIL_APP_BASE_URL}/{room.slug}",
"room_link": f"{settings.EMAIL_DOMAIN}/{room.slug}",
"sender_email": sender.email,
}
with override(language):
msg_html = render_to_string("mail/html/invitation.html", context)
msg_plain = render_to_string("mail/text/invitation.txt", context)
subject = str(
_(
f"Video call in progress: {sender.email} is waiting for you to connect"
)
) # Force translation
try:
send_mail(
subject,
msg_plain,
settings.EMAIL_FROM,
emails,
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as e:
logger.error("invitation to %s was not sent: %s", emails, e)
raise InvitationError("Could not send invitation") from e
+54 -3
View File
@@ -2,12 +2,18 @@
import uuid
from enum import Enum
from logging import getLogger
from django.conf import settings
from livekit import api
from core import models
from .lobby import LobbyService
from .telephony import TelephonyException, TelephonyService
logger = getLogger(__name__)
class LiveKitWebhookError(Exception):
@@ -77,6 +83,7 @@ class LiveKitEventsService:
)
self.webhook_receiver = api.WebhookReceiver(token_verifier)
self.lobby_service = LobbyService()
self.telephony_service = TelephonyService()
def receive(self, request):
"""Process webhook and route to appropriate handler."""
@@ -108,10 +115,54 @@ class LiveKitEventsService:
# pylint: disable=not-callable
handler(data)
def _handle_room_finished(self, data):
"""Handle 'room_finished' event."""
def _handle_room_started(self, data):
"""Handle 'room_started' event."""
try:
room_id = uuid.UUID(data.room.name)
except ValueError as e:
logger.warning(
"Ignoring room event: room name '%s' is not a valid UUID format.",
data.room.name,
)
raise ActionFailedError("Failed to process room started event") from e
try:
room = models.Room.objects.get(id=room_id)
except models.Room.DoesNotExist as err:
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
if settings.ROOM_TELEPHONY_ENABLED:
try:
self.telephony_service.create_dispatch_rule(room)
except TelephonyException as e:
raise ActionFailedError(
f"Failed to create telephony dispatch rule for room {room_id}"
) from e
def _handle_room_finished(self, data):
"""Handle 'room_finished' event."""
try:
room_id = uuid.UUID(data.room.name)
except ValueError as e:
logger.warning(
"Ignoring room event: room name '%s' is not a valid UUID format.",
data.room.name,
)
raise ActionFailedError("Failed to process room finished event") from e
if settings.ROOM_TELEPHONY_ENABLED:
try:
self.telephony_service.delete_dispatch_rule(room_id)
except TelephonyException as e:
raise ActionFailedError(
f"Failed to delete telephony dispatch rule for room {room_id}"
) from e
try:
self.lobby_service.clear_room_cache(room_id)
except Exception as e:
raise ActionFailedError("Failed to process room finished event") from e
raise ActionFailedError(
f"Failed to clear room cache for room {room_id}"
) from e
+17 -2
View File
@@ -12,7 +12,11 @@ from django.conf import settings
from django.core.cache import cache
from asgiref.sync import async_to_sync
from livekit.api import LiveKitAPI, SendDataRequest, TwirpError # pylint: disable=E0611
from livekit.api import ( # pylint: disable=E0611
ListRoomsRequest,
SendDataRequest,
TwirpError,
)
from core import models, utils
@@ -342,8 +346,19 @@ class LobbyService:
"type": settings.LOBBY_NOTIFICATION_TYPE,
}
lkapi = LiveKitAPI(**settings.LIVEKIT_CONFIGURATION)
lkapi = utils.create_livekit_client()
try:
room_response = await lkapi.room.list_rooms(
ListRoomsRequest(
names=[str(room_id)],
)
)
# Check if the room exists
if not room_response.rooms:
return
await lkapi.room.send_data(
SendDataRequest(
room=str(room_id),
+6 -2
View File
@@ -10,6 +10,7 @@ from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
import brevo_python
import urllib3
logger = logging.getLogger(__name__)
@@ -120,8 +121,11 @@ class BrevoMarketingService:
try:
response = contact_api.create_contact(contact, **api_configurations)
except brevo_python.rest.ApiException as err:
logger.exception("Failed to create contact in Brevo")
except (
brevo_python.rest.ApiException,
urllib3.exceptions.ReadTimeoutError,
) as err:
logger.warning("Failed to create contact in Brevo", exc_info=True)
raise ContactCreationError("Failed to create contact in Brevo") from err
return response
@@ -0,0 +1,37 @@
"""Room creation service."""
from django.conf import settings
from django.core.cache import cache
class RoomCreation:
"""Room creation related methods"""
@staticmethod
def _get_cache_key(callback_id):
"""Generate a standardized cache key for room creation callbacks."""
return f"room-creation-callback_{callback_id}"
def persist_callback_state(self, callback_id: str, room) -> None:
"""Store room data in cache using the callback ID as an identifier."""
data = {
"slug": room.slug,
}
cache.set(
self._get_cache_key(callback_id),
data,
timeout=settings.ROOM_CREATION_CALLBACK_CACHE_TIMEOUT,
)
def get_callback_state(self, callback_id: str) -> dict:
"""Retrieve and clear cached room data for the given callback ID."""
cache_key = self._get_cache_key(callback_id)
data = cache.get(cache_key)
if not data:
return {}
cache.delete(cache_key)
return data
+124
View File
@@ -0,0 +1,124 @@
"""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
from livekit.protocol.sip import (
CreateSIPDispatchRuleRequest,
DeleteSIPDispatchRuleRequest,
ListSIPDispatchRuleRequest,
SIPDispatchRule,
SIPDispatchRuleDirect,
)
from core import utils
logger = getLogger(__name__)
class TelephonyException(Exception):
"""Exception raised when telephony operations fail."""
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."""
return f"SIP_{str(room_id)}"
@async_to_sync
async def create_dispatch_rule(self, room):
"""Create a SIP inbound dispatch rule for direct room routing.
Configures telephony to route incoming SIP calls directly to the specified room
using the room's ID and PIN code for authentication.
"""
direct_rule = SIPDispatchRule(
dispatch_rule_direct=SIPDispatchRuleDirect(
room_name=str(room.id), pin=str(room.pin_code)
)
)
request = CreateSIPDispatchRuleRequest(
rule=direct_rule, name=self._rule_name(room.id)
)
lkapi = utils.create_livekit_client()
try:
await lkapi.sip.create_sip_dispatch_rule(create=request)
except TwirpError as e:
logger.exception(
"Unexpected error creating dispatch rule for room %s", room.id
)
raise TelephonyException("Could not create dispatch rule") from e
finally:
await lkapi.aclose()
async def _list_dispatch_rules_ids(self, room_id):
"""List SIP dispatch rule IDs for a specific room.
Fetches all existing SIP dispatch rules and filters them by room name
since LiveKit API doesn't support server-side filtering by 'room_name'.
This approach is acceptable for moderate scale but may need refactoring
for high-volume scenarios.
Note:
Feature request for server-side filtering: livekit/sip#405
"""
lkapi = utils.create_livekit_client()
try:
existing_rules = await lkapi.sip.list_sip_dispatch_rule(
list=ListSIPDispatchRuleRequest()
)
except TwirpError as e:
logger.exception("Failed to list dispatch rules for room %s", room_id)
raise TelephonyException("Could not list dispatch rules") from e
finally:
await lkapi.aclose()
if not existing_rules or not existing_rules.items:
return []
rule_name = self._rule_name(room_id)
return [
existing_rule.sip_dispatch_rule_id
for existing_rule in existing_rules.items
if existing_rule.name == rule_name
]
@async_to_sync
async def delete_dispatch_rule(self, room_id):
"""Delete all SIP inbound dispatch rules associated with a specific room."""
rules_ids = await self._list_dispatch_rules_ids(room_id)
if not rules_ids:
logger.info("No dispatch rules found for room %s", room_id)
return False
if len(rules_ids) > 1:
logger.error("Multiple dispatch rules found for room %s", room_id)
lkapi = utils.create_livekit_client()
try:
for rule_id in rules_ids:
await lkapi.sip.delete_sip_dispatch_rule(
delete=DeleteSIPDispatchRuleRequest(sip_dispatch_rule_id=rule_id)
)
return True
except TwirpError as e:
logger.exception("Failed to delete dispatch rules for room %s", room_id)
raise TelephonyException("Could not delete dispatch rules") from e
finally:
await lkapi.aclose()
@@ -58,7 +58,7 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
assert user.sub == "123"
assert user.email is None
assert user.password == "!"
assert user.has_usable_password() is False
assert models.User.objects.count() == 1
@@ -84,7 +84,7 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert user.email == email
assert user.full_name is None
assert user.short_name is None
assert user.password == "!"
assert user.has_usable_password() is False
assert models.User.objects.count() == 1
@@ -110,7 +110,7 @@ def test_authentication_getter_new_user_with_names(monkeypatch, email):
assert user.email == email
assert user.full_name == "John Doe"
assert user.short_name == "John"
assert user.password == "!"
assert user.has_usable_password() is False
assert models.User.objects.count() == 1
@@ -129,7 +129,7 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
django_assert_num_queries(0),
pytest.raises(
SuspiciousOperation,
match="User info contained no recognizable user identification",
match="Claims verification failed",
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
@@ -287,9 +287,7 @@ def test_finds_user_whitespace_email(django_assert_num_queries, settings):
[
"john.doe@xample.com", # Fullwidth character in domain
"john.doe@еxample.com", # Cyrillic 'е' in domain
"JOHN.DOe@exam𝔭le.com", # Mixed Gothic '𝔭' in domain
"john.doe@exаmple.com", # Cyrillic 'а' (a) in domain
"john.doe@e𝓧𝓪𝓶𝓹𝓵𝓮.com", # Mixed fullwidth and cursive in domain
],
)
def test_authentication_getter_existing_user_email_tricky(email, monkeypatch, settings):
@@ -1,10 +0,0 @@
"""Unit tests for the Authentication URLs."""
from core.authentication.urls import urlpatterns
def test_urls_override_default_mozilla_django_oidc():
"""Custom URL patterns should override default ones from Mozilla Django OIDC."""
url_names = [u.name for u in urlpatterns]
assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout")
@@ -1,359 +0,0 @@
"""Unit tests for the Authentication Views."""
from unittest import mock
from urllib.parse import parse_qs, urlparse
from django.contrib.auth.models import AnonymousUser
from django.contrib.sessions.middleware import SessionMiddleware
from django.core.exceptions import SuspiciousOperation
from django.test import RequestFactory
from django.test.utils import override_settings
from django.urls import reverse
from django.utils import crypto
import pytest
from rest_framework.test import APIClient
from core import factories
from core.authentication.views import (
MozillaOIDCAuthenticationCallbackView,
OIDCAuthenticationCallbackView,
OIDCAuthenticationRequestView,
OIDCLogoutCallbackView,
OIDCLogoutView,
)
pytestmark = pytest.mark.django_db
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_anonymous():
"""Anonymous users calling the logout url,
should be redirected to the specified LOGOUT_REDIRECT_URL."""
url = reverse("oidc_logout_custom")
response = APIClient().get(url)
assert response.status_code == 302
assert response.url == "/example-logout"
@mock.patch.object(
OIDCLogoutView, "construct_oidc_logout_url", return_value="/example-logout"
)
def test_view_logout(mocked_oidc_logout_url):
"""Authenticated users should be redirected to OIDC provider for logout."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
url = reverse("oidc_logout_custom")
response = client.get(url)
mocked_oidc_logout_url.assert_called_once()
assert response.status_code == 302
assert response.url == "/example-logout"
@override_settings(LOGOUT_REDIRECT_URL="/default-redirect-logout")
@mock.patch.object(
OIDCLogoutView, "construct_oidc_logout_url", return_value="/default-redirect-logout"
)
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
"""Authenticated users should be logged out when no OIDC provider is available."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
url = reverse("oidc_logout_custom")
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
response = client.get(url)
mocked_oidc_logout_url.assert_called_once()
mock_logout.assert_called_once()
assert response.status_code == 302
assert response.url == "/default-redirect-logout"
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_callback_anonymous():
"""Anonymous users calling the logout callback url,
should be redirected to the specified LOGOUT_REDIRECT_URL."""
url = reverse("oidc_logout_callback")
response = APIClient().get(url)
assert response.status_code == 302
assert response.url == "/example-logout"
@pytest.mark.parametrize(
"initial_oidc_states",
[{}, {"other_state": "foo"}],
)
def test_view_logout_persist_state(initial_oidc_states):
"""State value should be persisted in session's data."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
if initial_oidc_states:
request.session["oidc_states"] = initial_oidc_states
request.session.save()
mocked_state = "mock_state"
OIDCLogoutView().persist_state(request, mocked_state)
assert "oidc_states" in request.session
assert request.session["oidc_states"] == {
"mock_state": {},
**initial_oidc_states,
}
@override_settings(OIDC_OP_LOGOUT_ENDPOINT="/example-logout")
@mock.patch.object(OIDCLogoutView, "persist_state")
@mock.patch.object(crypto, "get_random_string", return_value="mocked_state")
def test_view_logout_construct_oidc_logout_url(
mocked_get_random_string, mocked_persist_state
):
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
request.session["oidc_id_token"] = "mocked_oidc_id_token"
request.session.save()
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
mocked_persist_state.assert_called_once()
mocked_get_random_string.assert_called_once()
params = parse_qs(urlparse(redirect_url).query)
assert params["id_token_hint"][0] == "mocked_oidc_id_token"
assert params["state"][0] == "mocked_state"
url = reverse("oidc_logout_callback")
assert url in params["post_logout_redirect_uri"][0]
@override_settings(LOGOUT_REDIRECT_URL="/")
def test_view_logout_construct_oidc_logout_url_none_id_token():
"""If no ID token is available in the session,
the user should be redirected to the final URL."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
assert redirect_url == "/"
@pytest.mark.parametrize(
"initial_state",
[None, {"other_state": "foo"}],
)
def test_view_logout_callback_wrong_state(initial_state):
"""Should raise an error if OIDC state doesn't match session data."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
if initial_state:
request.session["oidc_states"] = initial_state
request.session.save()
callback_view = OIDCLogoutCallbackView.as_view()
with pytest.raises(SuspiciousOperation) as excinfo:
callback_view(request)
assert (
str(excinfo.value) == "OIDC callback state not found in session `oidc_states`!"
)
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_callback():
"""If state matches, callback should clear OIDC state and redirects."""
user = factories.UserFactory()
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
mocked_state = "mocked_state"
request.session["oidc_states"] = {mocked_state: {}}
request.session.save()
callback_view = OIDCLogoutCallbackView.as_view()
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
def clear_user(request):
# Assert state is cleared prior to logout
assert request.session["oidc_states"] == {}
request.user = AnonymousUser()
mock_logout.side_effect = clear_user
response = callback_view(request)
mock_logout.assert_called_once()
assert response.status_code == 302
assert response.url == "/example-logout"
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
def test_view_authentication_default(settings, mocked_extra_params_setting):
"""By default, authentication request should not trigger silent login."""
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
request.GET = {}
view = OIDCAuthenticationRequestView()
extra_params = view.get_extra_params(request)
assert extra_params == (mocked_extra_params_setting or {})
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
def test_view_authentication_silent_false(settings, mocked_extra_params_setting):
"""Ensure setting 'silent' parameter to a random value doesn't trigger the silent login flow."""
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
request.GET = {"silent": "foo"}
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
view = OIDCAuthenticationRequestView()
extra_params = view.get_extra_params(request)
assert extra_params == (mocked_extra_params_setting or {})
assert not request.session.get("silent")
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
def test_view_authentication_silent_true(settings, mocked_extra_params_setting):
"""If 'silent' parameter is set to True, the silent login should be triggered."""
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
request.GET = {"silent": "true"}
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
view = OIDCAuthenticationRequestView()
extra_params = view.get_extra_params(request)
expected_params = {"prompt": "none"}
assert (
extra_params == {**mocked_extra_params_setting, **expected_params}
if mocked_extra_params_setting
else expected_params
)
assert request.session.get("silent") is True
@mock.patch.object(
MozillaOIDCAuthenticationCallbackView,
"failure_url",
new_callable=mock.PropertyMock,
return_value="foo",
)
def test_view_callback_failure_url(mocked_failure_url):
"""Test default behavior of the 'failure_url' property"""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
view = OIDCAuthenticationCallbackView()
view.request = request
returned_url = view.failure_url
mocked_failure_url.assert_called_once()
assert returned_url == "foo"
@mock.patch.object(
OIDCAuthenticationCallbackView,
"success_url",
new_callable=mock.PropertyMock,
return_value="foo",
)
def test_view_callback_failure_url_silent_login(mocked_success_url):
"""If a silent login was initiated and failed, it should not be treated as a failure."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
request.session["silent"] = True
request.session.save()
view = OIDCAuthenticationCallbackView()
view.request = request
returned_url = view.failure_url
mocked_success_url.assert_called_once()
assert returned_url == "foo"
assert not request.session.get("silent")
@@ -0,0 +1,224 @@
"""
Test event notification.
"""
# pylint: disable=E1128,W0621,W0613,W0212
import datetime
import smtplib
from unittest import mock
from django.contrib.sites.models import Site
import pytest
from core import factories, models
from core.recording.event.notification import NotificationService, notification_service
pytestmark = pytest.mark.django_db
@pytest.fixture
def mocked_current_site():
"""Mocks the Site.objects.get_current()to return a controlled predefined domain."""
site_mock = mock.Mock()
site_mock.domain = "test-domain.com"
with mock.patch.object(
Site.objects, "get_current", return_value=site_mock
) as patched:
yield patched
@mock.patch.object(NotificationService, "_notify_summary_service", return_value=True)
def test_notify_external_services_transcript_mode(mock_notify_summary):
"""Test notification routing for transcript mode recordings."""
service = NotificationService()
recording = factories.RecordingFactory(mode=models.RecordingModeChoices.TRANSCRIPT)
result = service.notify_external_services(recording)
assert result is True
mock_notify_summary.assert_called_once_with(recording)
@mock.patch.object(NotificationService, "_notify_user_by_email", return_value=True)
def test_notify_external_services_screen_recording_mode(mock_notify_email):
"""Test notification routing for screen recording mode."""
service = NotificationService()
recording = factories.RecordingFactory(
mode=models.RecordingModeChoices.SCREEN_RECORDING
)
result = service.notify_external_services(recording)
assert result is True
mock_notify_email.assert_called_once_with(recording)
def test_notify_external_services_unknown_mode(caplog):
"""Test notification for unknown recording mode."""
recording = factories.RecordingFactory()
# Bypass validation
recording.mode = "unknown"
service = NotificationService()
result = service.notify_external_services(recording)
assert result is False
assert f"Unknown recording mode unknown for recording {recording.id}" in caplog.text
# pylint: disable=too-many-locals
def test_notify_user_by_email_success(mocked_current_site, settings):
"""Test successful email notification to recording owners."""
settings.EMAIL_BRAND_NAME = "ACME"
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.SCREEN_RECORDING_BASE_URL = "https://acme.com/recordings"
settings.EMAIL_FROM = "notifications@acme.com"
recording = factories.RecordingFactory(room__name="Conference Room A")
# Fix recording test to avoid flaky tests
recording.created_at = datetime.datetime(2023, 5, 15, 14, 30, 0)
french_user = factories.UserFactory(
email="franc@test.com", language="fr-fr", timezone="Europe/Paris"
)
dutch_user = factories.UserFactory(
email="berry@test.com", language="nl-nl", timezone="Europe/Amsterdam"
)
english_user = factories.UserFactory(
email="john@test.com", language="en-us", timezone="America/Phoenix"
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=french_user
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=dutch_user
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=english_user
)
# Create non-owner users to verify they don't receive emails
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.MEMBER
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.ADMIN
)
notification_service = NotificationService()
with mock.patch("core.recording.event.notification.send_mail") as mock_send_mail:
result = notification_service._notify_user_by_email(recording)
assert result is True
assert mock_send_mail.call_count == 3
call_args_list = mock_send_mail.call_args_list
base_content = [
"ACME", # Brand name
"support@acme.com", # Support email
"https://acme.com/logo", # Logo URL
f"https://acme.com/recordings/{recording.id}", # Recording link
"Conference Room A", # Room name
]
# First call verification
subject1, body1, sender1, recipients1 = call_args_list[0][0]
assert subject1 == "Votre enregistrement est prêt"
# Verify email contains expected content
personalized_content1 = [
*base_content,
"2023-05-15", # Formatted date
"16:30", # Formatted time
"Votre enregistrement est prêt !", # Intro
]
for content in personalized_content1:
assert content in body1
assert recipients1 == ["franc@test.com"]
assert sender1 == "notifications@acme.com"
# Second call verification
subject2, body2, sender2, recipients2 = call_args_list[1][0]
assert subject2 == "Je opname is klaar"
# Verify second email content (if needed)
personalized_content2 = [
*base_content,
"2023-05-15", # Formatted date
"16:30", # Formatted time
"Je opname is klaar!", # Intro
]
for content in personalized_content2:
assert content in body2
assert recipients2 == ["berry@test.com"]
assert sender2 == "notifications@acme.com"
# Third call verification
subject3, body3, sender3, recipients3 = call_args_list[2][0]
assert subject3 == "Your recording is ready"
# Verify second email content (if needed)
personalized_content3 = [
*base_content,
"Conference Room A", # Room name
"2023-05-15", # Formatted date
"07:30", # Formatted time
"Your recording is ready!", # Intro
]
for content in personalized_content3:
assert content in body3
assert recipients3 == ["john@test.com"]
assert sender3 == "notifications@acme.com"
def test_notify_user_by_email_no_owners(mocked_current_site, caplog):
"""Test email notification when no owners are found."""
# Recording with no access
recording = factories.RecordingFactory()
result = notification_service._notify_user_by_email(recording)
assert result is False
assert f"No owner found for recording {recording.id}" in caplog.text
def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
"""Test email notification when an exception occurs."""
recording = factories.RecordingFactory(room__name="Conference Room A")
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
notification_service = NotificationService()
with mock.patch(
"core.recording.event.notification.send_mail",
side_effect=smtplib.SMTPException("SMTP Error"),
) as mock_send_mail:
result = notification_service._notify_user_by_email(recording)
assert result is False
assert mock_send_mail.call_count == 2
assert "notification could not be sent:" in caplog.text
@@ -129,8 +129,11 @@ def test_validate_invalid_filetype(minio_parser):
@pytest.mark.parametrize(
"invalid_filepath",
[
"invalid_filepath",
"invalid_filepath", # totally invalid string
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
"46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing url_encoded_folder_path and extension
"", # empty string
"recording%2F46d1a1212426484d8fb309b5d886f7a8.ogg",
],
)
@@ -22,15 +22,37 @@ def test_api_recordings_list_anonymous():
assert response.status_code == 401
def test_api_recordings_list_authenticated_no_recording():
"""
Authenticated users listing recordings should only
see recordings to which they have access.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
factories.UserRecordingAccessFactory(user=other_user)
response = client.get(
"/api/v1.0/recordings/",
)
assert response.status_code == 200
results = response.json()["results"]
assert results == []
@pytest.mark.parametrize(
"role",
["administrator", "member", "owner"],
)
def test_api_recordings_list_authenticated_direct(role):
def test_api_recordings_list_authenticated_direct(role, settings):
"""
Authenticated users listing recordings, should only see the recordings
to which they are related.
"""
settings.RECORDING_EXPIRATIONS_DAYS = None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -57,7 +79,9 @@ def test_api_recordings_list_authenticated_direct(role):
assert expected_ids == result_ids
assert results[0] == {
"id": str(recording.id),
"key": recording.key,
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"mode": recording.mode,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
@@ -66,6 +90,8 @@ def test_api_recordings_list_authenticated_direct(role):
},
"status": "initiated",
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"expired_at": None,
"is_expired": False,
}
@@ -0,0 +1,284 @@
"""
Test media-auth authorization API endpoint in docs core app.
"""
from io import BytesIO
from urllib.parse import urlparse
from uuid import uuid4
from django.conf import settings
from django.core.files.storage import default_storage
from django.utils import timezone
import pytest
import requests
from rest_framework.test import APIClient
from core import models
from core.factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
pytestmark = pytest.mark.django_db
def test_api_recordings_media_auth_unauthenticated():
"""
Test that unauthenticated requests to download media are rejected
"""
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
response = APIClient().get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 401
def test_api_recordings_media_auth_wrong_path():
"""
Test that media URLs with incorrect path structures are rejected
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/wrong-path/{uuid4()!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
def test_api_recordings_media_auth_unknown_recording():
"""
Test that requests for non-existent recordings are properly handled
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 404
def test_api_recordings_media_auth_no_abilities():
"""
Test that users without any access permissions cannot download recordings
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
def test_api_recordings_media_auth_wrong_abilities():
"""
Test that users with insufficient role permissions cannot download recordings
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
UserRecordingAccessFactory(user=user, recording=recording, role="member")
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
@pytest.mark.parametrize("wrong_status", ["initiated", "active", "failed_to_stop"])
def test_api_recordings_media_auth_unsaved(wrong_status):
"""
Test that recordings that aren't in 'saved' status cannot be downloaded
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=wrong_status)
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
def test_api_recordings_media_auth_mismatched_extension():
"""
Test that requests with mismatched file extensions are rejected
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(
status=models.RecordingStatusChoices.SAVED,
mode=models.RecordingModeChoices.TRANSCRIPT,
)
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 404
assert response.json() == {"detail": "No recording found with this extension."}
@pytest.mark.parametrize(
"wrong_extension", ["jpg", "txt", "mp3"], ids=["image", "text", "audio"]
)
def test_api_recordings_media_auth_wrong_extension(wrong_extension):
"""
Trying to download a recording with an unsupported extension should return
a validation error (400) with details about allowed extensions.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
original_url = (
f"http://localhost/media/recordings/{recording.id!s}.{wrong_extension}"
)
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 400
assert response.json() == {"detail": "Unsupported extension."}
@pytest.mark.parametrize("mode", ["screen_recording", "transcript"])
def test_api_recordings_media_auth_success_owner(mode):
"""
Test downloading a recording when logged in and authorized.
Verifies S3 authentication headers and successful file retrieval.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED, mode=mode)
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=recording.key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
original_url = f"http://localhost/media/{recording.key:s}"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
@pytest.mark.parametrize("mode", ["screen_recording", "transcript"])
def test_api_recordings_media_auth_success_administrator(mode):
"""
Test downloading a recording when logged in and authorized.
Verifies S3 authentication headers and successful file retrieval.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED, mode=mode)
UserRecordingAccessFactory(user=user, recording=recording, role="administrator")
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=recording.key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
original_url = f"http://localhost/media/{recording.key:s}"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
@@ -0,0 +1,243 @@
"""
Test recordings API endpoints in the Meet core app: retrieve.
"""
import random
import pytest
from freezegun import freeze_time
from rest_framework.test import APIClient
from ...factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
from ...models import RecordingStatusChoices
pytestmark = pytest.mark.django_db
def test_api_recording_retrieve_anonymous():
"""Anonymous users should not be able to retrieve recordings."""
recording = RecordingFactory()
client = APIClient()
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_recording_retrieve_authenticated():
"""Authenticated users without access receive 404 when requesting recordings.
The API returns 404 instead of 403 to avoid revealing the existence of
resources the user doesn't have permission to access.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
other_user = UserFactory()
recording = UserRecordingAccessFactory(user=other_user).recording
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "No Recording matches the given query."}
def test_api_recording_retrieve_members():
"""
A user who is a member of a recording should not be able to retrieve it.
"""
user = UserFactory()
recording = RecordingFactory()
UserRecordingAccessFactory(recording=recording, user=user, role="member")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_recording_retrieve_administrators(settings):
"""A user who is an administrator of a recording should be able to retrieve it."""
settings.RECORDING_EXPIRATION_DAYS = None
user = UserFactory()
recording = RecordingFactory()
UserRecordingAccessFactory(recording=recording, user=user, role="administrator")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
room = recording.room
assert content == {
"id": str(recording.id),
"key": recording.key,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": None,
"is_expired": False,
}
def test_api_recording_retrieve_owners(settings):
"""A user who is an owner of a recording should be able to retrieve it."""
settings.RECORDING_EXPIRATION_DAYS = None
user = UserFactory()
recording = RecordingFactory()
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
room = recording.room
assert content == {
"id": str(recording.id),
"key": recording.key,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": None,
"is_expired": False,
}
@freeze_time("2023-01-15 12:00:00")
def test_api_recording_retrieve_compute_expiration_date_correctly(settings):
"""Test that the API returns the correct expiration date for a non-expired recording."""
settings.RECORDING_EXPIRATION_DAYS = 1
user = UserFactory()
recording = RecordingFactory()
UserRecordingAccessFactory(
recording=recording, user=user, role=random.choice(["administrator", "owner"])
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
room = recording.room
assert content == {
"id": str(recording.id),
"key": recording.key,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"created_at": "2023-01-15T12:00:00Z",
"updated_at": "2023-01-15T12:00:00Z",
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": "2023-01-16T12:00:00Z",
"is_expired": False, # Ensure the recording is still valid and hasn't expired
}
def test_api_recording_retrieve_expired(settings):
"""Test that the API returns the correct expiration date and flag for an expired recording."""
settings.RECORDING_EXPIRATION_DAYS = 2
user = UserFactory()
with freeze_time("2023-01-15 12:00:00"):
recording = RecordingFactory()
UserRecordingAccessFactory(
recording=recording, user=user, role=random.choice(["administrator", "owner"])
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
room = recording.room
assert content == {
"id": str(recording.id),
"key": recording.key,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"created_at": "2023-01-15T12:00:00Z",
"updated_at": "2023-01-15T12:00:00Z",
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": "2023-01-17T12:00:00Z",
"is_expired": True, # Ensure the recording has expired
}
@pytest.mark.parametrize(
"status",
[
RecordingStatusChoices.INITIATED,
RecordingStatusChoices.ACTIVE,
RecordingStatusChoices.SAVED,
RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.ABORTED,
],
)
def test_api_recording_retrieve_any_status(status):
"""Test that recordings with any status can be retrieved."""
user = UserFactory()
recording = RecordingFactory(status=status)
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
assert content["id"] == str(recording.id)
assert content["status"] == status
@@ -32,7 +32,6 @@ def test_settings():
mocked_settings = {
"RECORDING_OUTPUT_FOLDER": "/test/output",
"LIVEKIT_CONFIGURATION": {"server": "test.example.com"},
"RECORDING_VERIFY_SSL": True,
"AWS_S3_ENDPOINT_URL": "https://s3.test.com",
"AWS_S3_ACCESS_KEY_ID": "test_key",
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
@@ -56,7 +55,6 @@ def test_config_initialization(default_config):
"""Test that WorkerServiceConfig is properly initialized from settings"""
assert default_config.output_folder == "/test/output"
assert default_config.server_configurations == {"server": "test.example.com"}
assert default_config.verify_ssl is True
assert default_config.bucket_args == {
"endpoint": "https://s3.test.com",
"access_key": "test_key",
@@ -76,7 +74,6 @@ def test_config_immutability(default_config):
@override_settings(
RECORDING_OUTPUT_FOLDER="/test/output",
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
RECORDING_VERIFY_SSL=True,
AWS_S3_ENDPOINT_URL="https://s3.test.com",
AWS_S3_ACCESS_KEY_ID="test_key",
AWS_S3_SECRET_ACCESS_KEY="test_secret",
@@ -6,10 +6,9 @@ Test worker service classes.
from unittest.mock import AsyncMock, Mock, patch
import aiohttp
import pytest
from core.recording.worker.exceptions import WorkerConnectionError, WorkerResponseError
from core.recording.worker.exceptions import WorkerResponseError
from core.recording.worker.factories import WorkerServiceConfig
from core.recording.worker.services import (
AudioCompositeEgressService,
@@ -25,11 +24,10 @@ def config():
return WorkerServiceConfig(
output_folder="/test/output",
server_configurations={
"host": "test.livekit.io",
"url": "test.livekit.io",
"api_key": "test_key",
"api_secret": "test_secret",
},
verify_ssl=True,
bucket_args={
"endpoint": "https://s3.test.com",
"access_key": "test_key",
@@ -127,58 +125,6 @@ def test_base_egress_filepath_construction(service, filename, extension, expecte
assert result.endswith(f"{filename}.{extension}")
def test_base_egress_handle_request_success(
config, service, mock_client_session, mock_egress_service, mock_tcp_connector
):
"""Test successful request handling"""
# Setup mock response
mock_response = Mock()
mock_method = AsyncMock(return_value=mock_response)
mock_egress_instance = Mock()
mock_egress_instance.test_method = mock_method
mock_egress_service.return_value = mock_egress_instance
# Create test request
test_request = Mock()
response = service._handle_request(test_request, "test_method")
mock_client_session.assert_called_once_with(
connector=mock_tcp_connector.return_value
)
# Verify EgressService initialization
mock_egress_service.assert_called_once_with(
mock_client_session.return_value.__aenter__.return_value,
**service._config.server_configurations,
)
# Verify method call and response
mock_method.assert_called_once_with(test_request)
assert response == mock_response
def test_base_egress_handle_request_connection_error(service, mock_egress_service):
"""Test handling of connection errors"""
# Setup mock error
mock_method = AsyncMock(
side_effect=livekit_api.TwirpError(msg="Connection failed", code=500)
)
mock_egress_instance = Mock()
mock_egress_instance.test_method = mock_method
mock_egress_service.return_value = mock_egress_instance
# Create test request
test_request = Mock()
# Verify error handling
with pytest.raises(WorkerConnectionError) as exc:
service._handle_request(test_request, "test_method")
assert "LiveKit client connection error" in str(exc.value)
assert "Connection failed" in str(exc.value)
@pytest.mark.parametrize(
"response_status,expected_result",
[
@@ -224,43 +170,6 @@ def test_base_egress_start_not_implemented(service):
assert "Subclass must implement this method" in str(exc.value)
@pytest.mark.parametrize("verify_ssl", [True, False])
def test_base_egress_ssl_verification_config(verify_ssl):
"""Test SSL verification configuration"""
config = WorkerServiceConfig(
output_folder="/test/output",
server_configurations={
"host": "test.livekit.io",
"api_key": "test_key",
"api_secret": "test_secret",
},
verify_ssl=verify_ssl,
bucket_args={
"endpoint": "https://s3.test.com",
"access_key": "test_key",
"secret": "test_secret",
"region": "test-region",
"bucket": "test-bucket",
"force_path_style": True,
},
)
service = BaseEgressService(config)
# Mock ClientSession to capture connector configuration
with patch("aiohttp.ClientSession") as mock_session:
mock_session.return_value.__aenter__ = AsyncMock()
mock_session.return_value.__aexit__ = AsyncMock()
# Trigger request to verify connector configuration
service._handle_request(Mock(), "test_method")
# Verify SSL configuration
connector = mock_session.call_args[1]["connector"]
assert isinstance(connector, aiohttp.TCPConnector)
assert connector._ssl == verify_ssl
def test_video_composite_egress_hrid(video_service):
"""Test HRID is correct"""
assert video_service.hrid == "video-recording-composite-livekit-egress"
@@ -2,6 +2,9 @@
Test rooms API endpoints in the Meet core app: create.
"""
# pylint: disable=W0621,W0613
from django.core.cache import cache
import pytest
from rest_framework.test import APIClient
@@ -11,6 +14,15 @@ from ...models import Room
pytestmark = pytest.mark.django_db
@pytest.fixture
def reset_cache():
"""Provide cache cleanup after each test to maintain test isolation."""
yield
keys = cache.keys("room-creation-callback_*")
if keys:
cache.delete(*keys)
def test_api_rooms_create_anonymous():
"""Anonymous users should not be allowed to create rooms."""
client = APIClient()
@@ -26,7 +38,7 @@ def test_api_rooms_create_anonymous():
assert Room.objects.exists() is False
def test_api_rooms_create_authenticated():
def test_api_rooms_create_authenticated(reset_cache):
"""
Authenticated users should be able to create rooms and should automatically be declared
as owner of the newly created room.
@@ -49,6 +61,33 @@ def test_api_rooms_create_authenticated():
assert room.slug == "my-room"
assert room.accesses.filter(role="owner", user=user).exists() is True
rooms_data = cache.keys("room-creation-callback_*")
assert not rooms_data
def test_api_rooms_create_generation_cache(reset_cache):
"""
Authenticated users creating a room with a callback ID should have room data stored in cache.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{"name": "my room", "callback_id": "1234"},
)
assert response.status_code == 201
room = Room.objects.get()
assert room.name == "my room"
assert room.slug == "my-room"
assert room.accesses.filter(role="owner", user=user).exists() is True
room_data = cache.get("room-creation-callback_1234")
assert room_data.get("slug") == "my-room"
def test_api_rooms_create_authenticated_existing_slug():
"""
@@ -0,0 +1,98 @@
"""
Test rooms API endpoints in the Meet core app: creation callback functionality.
"""
# pylint: disable=W0621,W0613
from django.core.cache import cache
import pytest
from rest_framework.test import APIClient
from ...factories import UserFactory
pytestmark = pytest.mark.django_db
# Tests for creation_callback endpoint
@pytest.fixture
def reset_cache():
"""Provide cache cleanup after each test to maintain test isolation."""
yield
keys = cache.keys("room-creation-callback_*")
if keys:
cache.delete(*keys)
def test_api_rooms_create_anonymous(reset_cache):
"""Anonymous user can retrieve room data once using a valid callback ID."""
client = APIClient()
cache.set("room-creation-callback_123", {"slug": "my room"})
response = client.post(
"/api/v1.0/rooms/creation-callback/",
{
"callback_id": "123",
},
)
assert response.status_code == 200
assert response.json() == {"status": "success", "room": {"slug": "my room"}}
# Data should be cleared after retrieval
response = client.post(
"/api/v1.0/rooms/creation-callback/",
{
"callback_id": "123",
},
)
assert response.status_code == 200
assert response.json() == {"status": "success", "room": {}}
def test_api_rooms_create_empty_cache():
"""Valid callback ID return empty room data when nothing is stored in the cache."""
client = APIClient()
response = client.post(
"/api/v1.0/rooms/creation-callback/",
{
"callback_id": "123",
},
)
assert response.status_code == 200
assert response.json() == {"status": "success", "room": {}}
def test_api_rooms_create_missing_callback_id():
"""Requests without a callback ID properly fail with a 400 status code."""
client = APIClient()
response = client.post(
"/api/v1.0/rooms/creation-callback/",
{},
)
assert response.status_code == 400
def test_api_rooms_create_authenticated(reset_cache):
"""Authenticated users can also successfully retrieve room data using a valid callback ID"""
user = UserFactory()
client = APIClient()
client.force_login(user)
cache.set("room-creation-callback_123", {"slug": "my room"})
response = client.post(
"/api/v1.0/rooms/creation-callback/",
{
"callback_id": "123",
},
)
assert response.status_code == 200
assert response.json() == {"status": "success", "room": {"slug": "my room"}}
@@ -0,0 +1,294 @@
"""
Test rooms API endpoints in the Meet core app: invite.
"""
# pylint: disable=W0621,W0613
import json
import random
from unittest import mock
import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...services.invitation import InvitationError, InvitationService
pytestmark = pytest.mark.django_db
def test_api_rooms_invite_anonymous():
"""Test anonymous users should not be allowed to invite people to rooms."""
client = APIClient()
room = RoomFactory()
data = {"emails": ["toto@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 401
def test_api_rooms_invite_no_access():
"""Test non-privileged users should not be allowed to invite people to rooms."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
client.force_login(user)
data = {"emails": ["toto@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You must have privileges on room to perform this action.",
}
def test_api_rooms_invite_member():
"""Test member users should not be allowed to invite people to rooms."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
client.force_login(user)
room.accesses.create(user=user, role="member")
data = {"emails": ["toto@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You must have privileges on room to perform this action.",
}
def test_api_rooms_invite_missing_emails():
"""Test missing email list should return validation error."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"foo": []}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 400
assert response.json() == {
"emails": [
"This field is required.",
]
}
def test_api_rooms_invite_empty_emails():
"""Test empty email list should return validation error."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": []}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 400
assert response.json() == {
"emails": [
"This list may not be empty.",
]
}
def test_api_rooms_invite_invalid_emails():
"""Test invalid email addresses should return validation errors."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["abdc", "efg"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 400
assert response.json() == {
"emails": {
"0": ["Enter a valid email address."],
"1": ["Enter a valid email address."],
}
}
def test_api_rooms_invite_partially_invalid_emails():
"""Test partially invalid email addresses should return validation errors."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["fabrice@yopmail.com", "efg"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 400
assert response.json() == {
"emails": {
"1": ["Enter a valid email address."],
}
}
@mock.patch.object(InvitationService, "invite_to_room")
def test_api_rooms_invite_duplicates(mock_invite_to_room):
"""Test duplicate emails should be deduplicated before processing."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["toto@yopmail.com", "toto@yopmail.com", "Toto@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 200
mock_invite_to_room.assert_called_once()
_, kwargs = mock_invite_to_room.call_args
assert kwargs["room"] == room
assert kwargs["sender"] == user
assert sorted(kwargs["emails"]) == sorted(["Toto@yopmail.com", "toto@yopmail.com"])
@mock.patch.object(InvitationService, "invite_to_room", side_effect=InvitationError())
def test_api_rooms_invite_error(mock_invite_to_room):
"""Test invitation service error should return appropriate error response."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["toto@yopmail.com", "toto@yopmail.com"]}
with pytest.raises(InvitationError):
client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
mock_invite_to_room.assert_called_once()
@mock.patch("core.services.invitation.send_mail")
def test_api_rooms_invite_success(mock_send_mail, settings):
"""Test privileged users should successfully send invitation emails."""
settings.EMAIL_BRAND_NAME = "ACME"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.EMAIL_APP_BASE_URL = "https://acme.com"
settings.EMAIL_FROM = "notifications@acme.com"
settings.EMAIL_DOMAIN = "acme.com"
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["fabien@yopmail.com", "gerald@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 200
assert response.json() == {"status": "success", "message": "invitations sent"}
mock_send_mail.assert_called_once()
subject, body, sender, recipients = mock_send_mail.call_args[0]
assert (
subject == f"Video call in progress: {user.email} is waiting for you to connect"
)
# Verify email contains expected content
required_content = [
"ACME", # Brand name
"https://acme.com/logo", # Logo URL
f"https://acme.com/{room.slug}", # Room url
f"acme.com/{room.slug}", # Room link
]
for content in required_content:
assert content in body
assert sender == "notifications@acme.com"
# Verify all owners received the email (order-independent comparison)
assert sorted(recipients) == sorted(["fabien@yopmail.com", "gerald@yopmail.com"])
@@ -132,18 +132,18 @@ def test_request_entry_with_existing_participants(settings):
# Add two participants already waiting in the lobby
cache.set(
f"mocked-cache-prefix_{room.id}_participant1",
f"mocked-cache-prefix_{room.id}_2f7f162fe7d1421b90e702bfbfbf8def",
{
"id": "participant1",
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
},
)
cache.set(
f"mocked-cache-prefix_{room.id}_participant2",
f"mocked-cache-prefix_{room.id}_f4ca3ab8a6c04ad88097b8da33f60f10",
{
"id": "participant2",
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
"username": "user2",
"status": "accepted",
"color": "#654321",
@@ -257,7 +257,9 @@ def test_request_entry_authenticated_user_public_room(settings):
with (
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(
LobbyService, "_get_or_create_participant_id", return_value="123"
LobbyService,
"_get_or_create_participant_id",
return_value="2f7f162fe7d1421b90e702bfbfbf8def",
),
mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
@@ -274,11 +276,11 @@ def test_request_entry_authenticated_user_public_room(settings):
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "123"
assert cookie.value == "2f7f162fe7d1421b90e702bfbfbf8def"
# Verify response content matches expected structure and values
assert response.json() == {
"id": "123",
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"username": "test_user",
"status": "accepted",
"color": "mocked-color",
@@ -300,9 +302,9 @@ def test_request_entry_waiting_participant_public_room(settings):
# Add a waiting participant to the room's lobby cache
cache.set(
f"mocked-cache-prefix_{room.id}_participant1",
f"mocked-cache-prefix_{room.id}_2f7f162fe7d1421b90e702bfbfbf8def",
{
"id": "participant1",
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
@@ -310,7 +312,7 @@ def test_request_entry_waiting_participant_public_room(settings):
)
# Simulate a browser with existing participant cookie
client.cookies.load({"mocked-cookie": "participant1"})
client.cookies.load({"mocked-cookie": "2f7f162fe7d1421b90e702bfbfbf8def"})
with (
mock.patch.object(LobbyService, "notify_participants", return_value=None),
@@ -328,11 +330,11 @@ def test_request_entry_waiting_participant_public_room(settings):
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "participant1"
assert cookie.value == "2f7f162fe7d1421b90e702bfbfbf8def"
# Verify response content matches expected structure and values
assert response.json() == {
"id": "participant1",
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"username": "user1",
"status": "accepted",
"color": "#123456",
@@ -379,7 +381,7 @@ def test_allow_participant_to_enter_anonymous():
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "test-id", "allow_entry": True},
{"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def", "allow_entry": True},
)
assert response.status_code == 401
@@ -394,7 +396,7 @@ def test_allow_participant_to_enter_non_owner():
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "test-id", "allow_entry": True},
{"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def", "allow_entry": True},
)
assert response.status_code == 403
@@ -412,7 +414,7 @@ def test_allow_participant_to_enter_public_room():
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "test-id", "allow_entry": True},
{"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def", "allow_entry": True},
)
assert response.status_code == 404
@@ -435,9 +437,9 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
cache.set(
f"mocked-cache-prefix_{room.id!s}_participant1",
f"mocked-cache-prefix_{room.id!s}_2f7f162fe7d1421b90e702bfbfbf8def",
{
"id": "test-id",
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"status": "waiting",
"username": "foo",
"color": "123",
@@ -446,13 +448,18 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "participant1", "allow_entry": allow_entry},
{
"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def",
"allow_entry": allow_entry,
},
)
assert response.status_code == 200
assert response.json() == {"message": "Participant was updated."}
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_participant1")
participant_data = cache.get(
f"mocked-cache-prefix_{room.id!s}_2f7f162fe7d1421b90e702bfbfbf8def"
)
assert participant_data.get("status") == updated_status
@@ -468,12 +475,14 @@ def test_allow_participant_to_enter_participant_not_found(settings):
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_test-id")
participant_data = cache.get(
f"mocked-cache-prefix_{room.id!s}_2f7f162fe7d1421b90e702bfbfbf8def"
)
assert participant_data is None
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "test-id", "allow_entry": True},
{"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def", "allow_entry": True},
)
assert response.status_code == 404
@@ -563,18 +572,18 @@ def test_list_waiting_participants_success(settings):
# Add participants in the lobby
cache.set(
f"mocked-cache-prefix_{room.id}_participant1",
f"mocked-cache-prefix_{room.id}_2f7f162fe7d1421b90e702bfbfbf8def",
{
"id": "participant1",
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
},
)
cache.set(
f"mocked-cache-prefix_{room.id}_participant2",
f"mocked-cache-prefix_{room.id}_f4ca3ab8a6c04ad88097b8da33f60f10",
{
"id": "participant2",
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
"username": "user2",
"status": "waiting",
"color": "#654321",
@@ -588,13 +597,13 @@ def test_list_waiting_participants_success(settings):
participants = response.json().get("participants")
assert sorted(participants, key=lambda p: p["id"]) == [
{
"id": "participant1",
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
},
{
"id": "participant2",
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
"username": "user2",
"status": "waiting",
"color": "#654321",
@@ -32,6 +32,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -51,6 +52,7 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -69,6 +71,7 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -85,6 +88,7 @@ def test_api_rooms_retrieve_anonymous_private_slug():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -101,6 +105,7 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -209,6 +214,7 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -251,6 +257,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -295,6 +302,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -324,6 +332,7 @@ def test_api_rooms_retrieve_authenticated():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -336,23 +345,22 @@ def test_api_rooms_retrieve_authenticated():
"url": "test_url_value",
}
)
def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, settings):
"""
Users who are members of a room should be allowed to see related users.
Users who are members of a room should not be allowed to see related users.
"""
settings.TIME_ZONE = "UTC"
user = UserFactory()
other_user = UserFactory()
room = RoomFactory()
user_access = UserResourceAccessFactory(resource=room, user=user, role="member")
other_user_access = UserResourceAccessFactory(
resource=room, user=other_user, role="member"
)
UserResourceAccessFactory(resource=room, user=user, role="member")
UserResourceAccessFactory(resource=room, user=other_user, role="member")
client = APIClient()
client.force_login(user)
with django_assert_num_queries(4):
with django_assert_num_queries(3):
response = client.get(
f"/api/v1.0/rooms/{room.id!s}/",
)
@@ -360,33 +368,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
assert response.status_code == 200
content_dict = response.json()
assert sorted(content_dict.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
"user": {
"id": str(user_access.user.id),
"email": user_access.user.email,
"full_name": user_access.user.full_name,
"short_name": user_access.user.short_name,
},
"resource": str(room.id),
"role": user_access.role,
},
{
"id": str(other_user_access.id),
"user": {
"id": str(other_user_access.user.id),
"email": other_user_access.user.email,
"full_name": other_user_access.user.full_name,
"short_name": other_user_access.user.short_name,
},
"resource": str(room.id),
"role": other_user_access.role,
},
],
key=lambda x: x["id"],
)
assert "accesses" not in content_dict
expected_name = str(room.id)
assert content_dict == {
@@ -399,6 +381,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -415,11 +398,14 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
"url": "test_url_value",
}
)
def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries):
def test_api_rooms_retrieve_administrators(
mock_token, django_assert_num_queries, settings
):
"""
A user who is an administrator or owner of a room should be allowed
to see related users.
"""
settings.TIME_ZONE = "UTC"
user = UserFactory()
other_user = UserFactory()
room = RoomFactory()
@@ -448,6 +434,8 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
"email": other_user_access.user.email,
"full_name": other_user_access.user.full_name,
"short_name": other_user_access.user.short_name,
"timezone": "UTC",
"language": other_user_access.user.language,
},
"resource": str(room.id),
"role": other_user_access.role,
@@ -459,6 +447,8 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
"email": user_access.user.email,
"full_name": user_access.user.full_name,
"short_name": user_access.user.short_name,
"timezone": "UTC",
"language": user_access.user.language,
},
"resource": str(room.id),
"role": user_access.role,
@@ -478,6 +468,7 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -2,7 +2,6 @@
Test LiveKit webhook endpoint on the rooms API.
"""
# ruff: noqa: PLR0913
# pylint: disable=R0913,W0621,R0917,W0613
import base64
import hashlib
@@ -25,7 +24,7 @@ def webhook_event_data():
"name": "00000000-0000-0000-0000-000000000000",
"emptyTimeout": 300,
"creationTime": "1692627281",
"turnPassword": "2Pvdj+/WV1xV4EkB8klJ9xkXDWY=",
"turnPassword": "fake-turn-password",
"enabledCodecs": [
{"mime": "audio/opus"},
{"mime": "video/H264"},
@@ -144,7 +143,7 @@ def test_handled_event_type(
def test_unhandled_event_type(client, mock_livekit_config):
"""Should return 200 for event types that have no handler."""
event_data = json.dumps({"event": "room_started"})
event_data = json.dumps({"event": "participant_joined"})
hash64 = base64.b64encode(hashlib.sha256(event_data.encode()).digest()).decode()
token = api.AccessToken(
@@ -8,6 +8,7 @@ from unittest import mock
import pytest
from core.factories import RoomFactory
from core.services.livekit_events import (
ActionFailedError,
AuthenticationError,
@@ -17,6 +18,7 @@ from core.services.livekit_events import (
api,
)
from core.services.lobby import LobbyService
from core.services.telephony import TelephonyException, TelephonyService
pytestmark = pytest.mark.django_db
@@ -56,42 +58,149 @@ def test_initialization(
@mock.patch.object(LobbyService, "clear_room_cache")
def test_handle_room_finished(mock_clear_cache, service):
"""Should clear lobby cache when room is finished."""
@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 telephony dispatch rule when room finishes."""
settings.ROOM_TELEPHONY_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(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
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_not_called()
mock_clear_cache.assert_called_once_with(mock_room_name)
@mock.patch.object(
LobbyService, "clear_room_cache", side_effect=Exception("Test error")
)
def test_handle_room_finished_error(mock_clear_cache, service):
"""Should raise ActionFailedError when processing fails."""
@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
):
"""Should raise ActionFailedError when lobby cache clearing fails when room finishes."""
settings.ROOM_TELEPHONY_ENABLED = True
mock_data = mock.MagicMock()
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
with pytest.raises(
ActionFailedError, match="Failed to process room finished event"
):
expected_error = (
"Failed to clear room cache for room 00000000-0000-0000-0000-000000000000"
)
with pytest.raises(ActionFailedError, match=expected_error):
service._handle_room_finished(mock_data)
mock_delete_dispatch_rule.assert_called_once_with(
uuid.UUID("00000000-0000-0000-0000-000000000000")
)
def test_handle_room_finished_invalid_room_name(service):
"""Should raise ActionFailedError when processing fails."""
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(
TelephonyService,
"delete_dispatch_rule",
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
):
"""Should raise ActionFailedError when dispatch rule deletion fails when room finishes."""
settings.ROOM_TELEPHONY_ENABLED = True
mock_data = mock.MagicMock()
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
expected_error = (
"Failed to delete telephony dispatch rule for room "
"00000000-0000-0000-0000-000000000000"
)
with pytest.raises(ActionFailedError, match=expected_error):
service._handle_room_finished(mock_data)
mock_clear_cache.assert_not_called()
def test_handle_room_finished_raises_error_for_invalid_room_name(service):
"""Should raise ActionFailedError when room name format is invalid when room finishes."""
mock_data = mock.MagicMock()
mock_data.room.name = "invalid"
with pytest.raises(
ActionFailedError, match="Failed to process room finished event"
):
service._handle_room_finished(mock_data)
@mock.patch.object(TelephonyService, "create_dispatch_rule")
def test_handle_room_started_creates_dispatch_rule_successfully(
mock_create_dispatch_rule, service, settings
):
"""Should create telephony dispatch rule when room starts successfully."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
mock_create_dispatch_rule.assert_called_once_with(room)
@mock.patch.object(TelephonyService, "create_dispatch_rule")
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
mock_create_dispatch_rule, service, settings
):
"""Should skip creating telephony dispatch rule when telephony is disabled during room start."""
settings.ROOM_TELEPHONY_ENABLED = False
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
mock_create_dispatch_rule.assert_not_called()
def test_handle_room_started_raises_error_for_invalid_room_name(service):
"""Should raise ActionFailedError when room name format is invalid when room starts."""
mock_data = mock.MagicMock()
mock_data.room.name = "invalid"
with pytest.raises(ActionFailedError, match="Failed to process room started event"):
service._handle_room_started(mock_data)
def test_handle_room_started_raises_error_for_nonexistent_room(service):
"""Should raise ActionFailedError when a room starts that doesn't exist in the database."""
mock_data = mock.MagicMock()
mock_data.room.name = str(uuid.uuid4())
expected_error = f"Room with ID {mock_data.room.name} does not exist"
with pytest.raises(ActionFailedError, match=expected_error):
service._handle_room_started(mock_data)
@mock.patch.object(
api.WebhookReceiver, "receive", side_effect=Exception("Invalid payload")
)
+61 -11
View File
@@ -776,22 +776,64 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.LiveKitAPI")
def test_notify_participants_success(mock_livekit_api, lobby_service):
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success_no_room(mock_create_livekit_client, lobby_service):
"""Test the notify_participants method when the LiveKit room doesn't exist yet."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock()
# Create a proper response object with an empty rooms list
class MockResponse:
"""LiveKit API response mock with empty rooms list."""
rooms = []
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Act
lobby_service.notify_participants(room.id)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was not called since no room exists
mock_api_instance.room.send_data.assert_not_called()
# Verify the connection was properly closed
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success(mock_create_livekit_client, lobby_service):
"""Test successful participant notification."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock()
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_livekit_api.return_value = mock_api_instance
mock_create_livekit_client.return_value = mock_api_instance
# Call the function
lobby_service.notify_participants(room.id)
# Verify the API was called correctly
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was called
mock_api_instance.room.send_data.assert_called_once()
@@ -807,18 +849,26 @@ def test_notify_participants_success(mock_livekit_api, lobby_service):
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.services.lobby.LiveKitAPI")
def test_notify_participants_error(mock_livekit_api, lobby_service):
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_error(mock_create_livekit_client, lobby_service):
"""Test participant notification with API error."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock(
side_effect=TwirpError(msg="test error", code=123)
side_effect=TwirpError(msg="test error", code=123, status=123)
)
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_livekit_api.return_value = mock_api_instance
mock_create_livekit_client.return_value = mock_api_instance
# Call the function and expect an exception
with pytest.raises(
@@ -826,8 +876,8 @@ def test_notify_participants_error(mock_livekit_api, lobby_service):
):
lobby_service.notify_participants(room.id)
# Verify the API was called correctly
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify send_data was called
mock_api_instance.room.send_data.assert_called_once()
@@ -11,6 +11,7 @@ from django.core.exceptions import ImproperlyConfigured
import brevo_python
import pytest
import urllib3
from core.services.marketing import (
BrevoMarketingService,
@@ -133,6 +134,34 @@ def test_create_contact_api_error(mock_contact_api):
brevo_service.create_contact(valid_contact_data)
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_timeout_error(mock_contact_api):
"""Test contact creation timeout error handling."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
mock_api.create_contact.side_effect = urllib3.exceptions.ReadTimeoutError(
pool=mock.Mock(),
url="https://api.brevo.com/v3/endpoint",
message="HTTPSConnectionPool(host='api.brevo.com', port=443): Read timed out.",
)
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
brevo_service.create_contact(valid_contact_data)
@pytest.fixture
def clear_marketing_cache():
"""Clear marketing service cache between tests."""
@@ -0,0 +1,305 @@
"""
Test telephony service.
"""
# pylint: disable=W0212
from unittest import mock
import pytest
from asgiref.sync import async_to_sync
from livekit.api import TwirpError
from livekit.protocol.sip import (
CreateSIPDispatchRuleRequest,
DeleteSIPDispatchRuleRequest,
ListSIPDispatchRuleRequest,
ListSIPDispatchRuleResponse,
SIPDispatchRule,
SIPDispatchRuleInfo,
)
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.services.telephony import TelephonyException, TelephonyService
pytestmark = pytest.mark.django_db
def create_mock_livekit_client():
"""Factory for creating LiveKit client mock."""
mock_api = mock.Mock()
mock_api.sip = mock.Mock()
mock_api.aclose = mock.AsyncMock()
return mock_api
def test_rule_name():
"""Test rule name generation."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
rule_name = telephony_service._rule_name(room.id)
assert rule_name == f"SIP_{str(room.id)}"
@mock.patch("core.utils.create_livekit_client")
def test_create_dispatch_rule_success(mock_client_factory):
"""Test successful dispatch rule creation."""
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
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"]
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_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_create_dispatch_rule_api_failure(mock_client_factory):
"""Test dispatch rule creation when API fails."""
telephony_service = TelephonyService()
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="Internal server error", code=500, status=500)
)
mock_client_factory.return_value = mock_api
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()
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_success(mock_client_factory):
"""Test successful listing of dispatch rule IDs."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_rules = [
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-1",
name=f"SIP_{str(room.id)}",
rule=SIPDispatchRule(),
),
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-2", name="OTHER_RULE", rule=SIPDispatchRule()
),
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-3",
name=f"SIP_{str(room.id)}",
rule=SIPDispatchRule(),
),
]
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
return_value=ListSIPDispatchRuleResponse(items=mock_rules)
)
mock_client_factory.return_value = mock_api
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert len(result) == 2
assert "rule-1" in result
assert "rule-3" in result
assert "rule-2" not in result
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
list_request = mock_api.sip.list_sip_dispatch_rule.call_args[1]["list"]
assert isinstance(list_request, ListSIPDispatchRuleRequest)
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
"""Test listing dispatch rule IDs when no rules exist."""
telephony_service = TelephonyService()
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_client_factory.return_value = mock_api
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert result == []
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
"""Test listing dispatch rule IDs when no rules match the room."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_rules = [
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-1", name="OTHER_RULE_1", rule=SIPDispatchRule()
),
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-2", name="OTHER_RULE_2", rule=SIPDispatchRule()
),
]
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
return_value=ListSIPDispatchRuleResponse(items=mock_rules)
)
mock_client_factory.return_value = mock_api
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert result == []
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
"""Test listing dispatch rule IDs when API fails."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
)
mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not list dispatch rules"):
async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules):
"""Test deleting dispatch rules when no rules exist."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = []
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.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."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1"]
mock_api = create_mock_livekit_client()
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
result = telephony_service.delete_dispatch_rule(room.id)
assert result is True
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
delete_request = mock_api.sip.delete_sip_dispatch_rule.call_args[1]["delete"]
assert isinstance(delete_request, DeleteSIPDispatchRuleRequest)
assert delete_request.sip_dispatch_rule_id == "rule-1"
mock_api.aclose.assert_called_once()
@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."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
mock_api = create_mock_livekit_client()
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
result = telephony_service.delete_dispatch_rule(room.id)
assert result is True
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3
deleted_rule_ids = [
call_args[1]["delete"].sip_dispatch_rule_id
for call_args in mock_api.sip.delete_sip_dispatch_rule.call_args_list
]
assert all(
rule_id in deleted_rule_ids for rule_id in ["rule-1", "rule-2", "rule-3"]
)
mock_api.aclose.assert_called_once()
@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."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
mock_api = create_mock_livekit_client()
call_count = 0
def delete_side_effect(*args, **kwargs):
nonlocal call_count
if call_count == 0:
call_count += 1
return None
raise TwirpError(msg="Deletion failed", code=500, status=500)
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
side_effect=delete_side_effect
)
mock_client_factory.return_value = mock_api
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.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."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1"]
mock_api = create_mock_livekit_client()
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
)
mock_client_factory.return_value = mock_api
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()
+5 -1
View File
@@ -103,8 +103,10 @@ def test_api_users_retrieve_me_anonymous():
}
def test_api_users_retrieve_me_authenticated():
def test_api_users_retrieve_me_authenticated(settings):
"""Authenticated users should be able to retrieve their own user via the "/users/me" path."""
settings.TIME_ZONE = "UTC"
user = factories.UserFactory()
client = APIClient()
@@ -121,6 +123,8 @@ def test_api_users_retrieve_me_authenticated():
"email": user.email,
"full_name": user.full_name,
"short_name": user.short_name,
"language": user.language,
"timezone": "UTC",
}
@@ -12,7 +12,8 @@ from core.factories import (
UserFactory,
UserRecordingAccessFactory,
)
from core.models import Recording, RecordingStatusChoices
from core.models import Recording, RecordingModeChoices, RecordingStatusChoices
from core.recording.enums import FileExtension
pytestmark = pytest.mark.django_db
@@ -216,3 +217,74 @@ def test_models_recording_worker_id_very_long():
too_long_id = "w" * 256
with pytest.raises(ValidationError):
RecordingFactory(worker_id=too_long_id)
# Test key property method
def test_models_recording_key_for_transcript(settings):
"""Test key property returns correct path for transcript mode."""
settings.RECORDING_OUTPUT_FOLDER = "/custom/path"
recording = RecordingFactory(mode=RecordingModeChoices.TRANSCRIPT)
expected_path = f"/custom/path/{recording.id}.{FileExtension.OGG.value}"
assert recording.key == expected_path
def test_models_recording_key_for_screen_recording(settings):
"""Test key property returns correct path for screen recording mode."""
settings.RECORDING_OUTPUT_FOLDER = "/custom/path"
recording = RecordingFactory(mode=RecordingModeChoices.SCREEN_RECORDING)
expected_path = f"/custom/path/{recording.id}.{FileExtension.MP4.value}"
assert recording.key == expected_path
def test_models_recording_key_for_unknown_mode(settings):
"""Test key property uses default extension for unknown mode."""
settings.RECORDING_OUTPUT_FOLDER = "/custom/path"
recording = RecordingFactory()
# Directly set an invalid mode (bypassing validation)
recording.mode = "unknown_mode"
expected_path = f"/custom/path/{recording.id}.{FileExtension.MP4.value}"
assert recording.key == expected_path
# Test is_saved method
def test_models_recording_is_saved_true():
"""Test is_saved property returns True for SAVED and NOTIFICATION_SUCCEEDED status."""
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
assert recording.is_saved is True
recording = RecordingFactory(status=RecordingStatusChoices.NOTIFICATION_SUCCEEDED)
assert recording.is_saved is True
def test_models_recording_is_saved_false_active():
"""Test is_saved property returns False for ACTIVE status."""
recording = RecordingFactory(status=RecordingStatusChoices.ACTIVE)
assert recording.is_saved is False
def test_models_recording_is_saved_false_initiated():
"""Test is_saved property returns False for INITIATED status."""
recording = RecordingFactory(status=RecordingStatusChoices.INITIATED)
assert recording.is_saved is False
@pytest.mark.parametrize(
"status",
[
RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.ABORTED,
],
)
def test_models_recording_is_saved_false_error_states(status):
"""Test is_saved property returns False for error statuses."""
recording = RecordingFactory(status=status)
assert recording.is_saved is False
+178 -6
View File
@@ -2,6 +2,12 @@
Unit tests for the Room model
"""
# pylint: disable=W0613
import secrets
from logging import Logger
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
@@ -96,7 +102,7 @@ def test_models_rooms_access_rights_none(django_assert_num_queries):
with django_assert_num_queries(0):
assert room.get_role(None) is None
with django_assert_num_queries(0):
assert room.is_administrator(None) is False
assert room.is_administrator_or_owner(None) is False
with django_assert_num_queries(0):
assert room.is_owner(None) is False
@@ -109,7 +115,7 @@ def test_models_rooms_access_rights_anonymous(django_assert_num_queries):
with django_assert_num_queries(0):
assert room.get_role(user) is None
with django_assert_num_queries(0):
assert room.is_administrator(user) is False
assert room.is_administrator_or_owner(user) is False
with django_assert_num_queries(0):
assert room.is_owner(user) is False
@@ -122,7 +128,7 @@ def test_models_rooms_access_rights_authenticated(django_assert_num_queries):
with django_assert_num_queries(1):
assert room.get_role(user) is None
with django_assert_num_queries(1):
assert room.is_administrator(user) is False
assert room.is_administrator_or_owner(user) is False
with django_assert_num_queries(1):
assert room.is_owner(user) is False
@@ -135,7 +141,7 @@ def test_models_rooms_access_rights_member_direct(django_assert_num_queries):
with django_assert_num_queries(1):
assert room.get_role(user) == "member"
with django_assert_num_queries(1):
assert room.is_administrator(user) is False
assert room.is_administrator_or_owner(user) is False
with django_assert_num_queries(1):
assert room.is_owner(user) is False
@@ -148,7 +154,7 @@ def test_models_rooms_access_rights_administrator_direct(django_assert_num_queri
with django_assert_num_queries(1):
assert room.get_role(user) == "administrator"
with django_assert_num_queries(1):
assert room.is_administrator(user) is True
assert room.is_administrator_or_owner(user) is True
with django_assert_num_queries(1):
assert room.is_owner(user) is False
@@ -161,7 +167,7 @@ def test_models_rooms_access_rights_owner_direct(django_assert_num_queries):
with django_assert_num_queries(1):
assert room.get_role(user) == "owner"
with django_assert_num_queries(1):
assert room.is_administrator(user) is True
assert room.is_administrator_or_owner(user) is True
with django_assert_num_queries(1):
assert room.is_owner(user) is True
@@ -175,3 +181,169 @@ def test_models_rooms_is_public_property():
# Test non-public room
private_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
assert private_room.is_public is False
@mock.patch.object(Room, "generate_unique_pin_code")
def test_telephony_disabled_skips_pin_generation(
mock_generate_unique_pin_code, settings
):
"""Telephony disabled should not generate pin codes."""
settings.ROOM_TELEPHONY_ENABLED = False
room = RoomFactory()
mock_generate_unique_pin_code.assert_not_called()
assert room.pin_code is None
def test_default_and_custom_pin_length(settings):
"""Pin codes should be created with correct configured length."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
# Assert default value is 10 for collision reasons
assert len(room.pin_code) == 10
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
room = RoomFactory()
# Assert custom size
assert len(room.pin_code) == 5
def test_room_updates_preserve_pin_code(settings):
"""Room updates should preserve existing pin code."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
# Store the original pin code to compare after updates
previous_pin_code = room.pin_code
# If this method is called, it would indicate the pin is being regenerated unnecessarily
with mock.patch.object(
Room, "generate_unique_pin_code"
) as mock_generate_unique_pin_code:
# Explicitly call save to persist the changes to the room
room.slug = "aaa-aaaa-aaa"
room.save()
assert room.pin_code == previous_pin_code
mock_generate_unique_pin_code.assert_not_called()
@pytest.mark.parametrize("is_telephony_enabled", [True, False])
def test_manual_pin_code_updates(is_telephony_enabled, settings):
"""Manual pin code changes should persist regardless of telephony setting."""
settings.ROOM_TELEPHONY_ENABLED = is_telephony_enabled
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
room = RoomFactory()
assert room.pin_code != "12345"
room.pin_code = "12345"
room.save()
assert room.pin_code == "12345"
# No data validation when manual updates are made
room.pin_code = "123"
room.save()
assert room.pin_code == "123"
def test_pin_code_uniqueness(settings):
"""Duplicate pin codes should raise validation error."""
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
RoomFactory(pin_code="12345")
with pytest.raises(ValidationError) as excinfo:
RoomFactory(pin_code="12345")
assert "Room with this Room PIN code already exists." in str(excinfo.value)
@pytest.mark.parametrize("invalid_length", [0, 1, 2, 3])
def test_pin_code_minimum_length(invalid_length, settings):
"""Pin codes should enforce minimum length for security."""
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PIN_LENGTH = 4
# Assert no exception is raised with a valid length
RoomFactory()
settings.ROOM_TELEPHONY_PIN_LENGTH = invalid_length
with pytest.raises(ValueError) as excinfo:
RoomFactory()
assert "PIN code length must be at least 4 digits for minimal security" in str(
excinfo.value
)
@mock.patch.object(Logger, "warning")
@mock.patch.object(secrets, "randbelow", return_value=12345)
def test_pin_generation_max_retries(mock_randbelow, mock_logger, settings):
"""Pin generation should give up after max retries."""
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
RoomFactory(pin_code="12345")
# Assert default max retries is low, 5
room1 = RoomFactory()
assert mock_randbelow.call_count == 5
assert room1.pin_code is None
mock_logger.assert_called_once_with(
"Failed to generate unique PIN code of length %s after %s attempts", 5, 5
)
mock_logger.reset_mock()
mock_randbelow.reset_mock()
settings.ROOM_TELEPHONY_PIN_MAX_RETRIES = 3
room2 = RoomFactory()
assert mock_randbelow.call_count == 3
assert room2.pin_code is None
mock_logger.assert_called_once_with(
"Failed to generate unique PIN code of length %s after %s attempts", 5, 3
)
@mock.patch.object(secrets, "randbelow", return_value=12345)
def test_pin_code_zero_padding(mock_randbelow, settings):
"""Pin codes should be zero-padded to meet required length."""
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PIN_LENGTH = 10
room = RoomFactory()
assert room.pin_code == "0000012345"
@mock.patch.object(secrets, "randbelow", return_value=12345)
def test_pin_generation_upper_bound(mock_randbelow, settings):
"""Random number generator should use correct upper bound based on pin length."""
settings.ROOM_TELEPHONY_ENABLED = False
room = RoomFactory()
room.generate_unique_pin_code(length=5)
# Assert called with the right exclusive upper bound, 10^5
mock_randbelow.assert_called_with(100000)
+62
View File
@@ -0,0 +1,62 @@
"""
Test utils functions
"""
from unittest import mock
from core.utils import create_livekit_client
@mock.patch("asyncio.get_running_loop")
@mock.patch("core.utils.LiveKitAPI")
def test_create_livekit_client_ssl_enabled(
mock_livekit_api, mock_get_running_loop, settings
):
"""Test LiveKitAPI client creation with SSL verification enabled."""
mock_get_running_loop.return_value = mock.MagicMock()
settings.LIVEKIT_VERIFY_SSL = True
create_livekit_client()
mock_livekit_api.assert_called_once_with(
**settings.LIVEKIT_CONFIGURATION, session=None
)
@mock.patch("core.utils.aiohttp.ClientSession")
@mock.patch("asyncio.get_running_loop")
@mock.patch("core.utils.LiveKitAPI")
def test_create_livekit_client_ssl_disabled(
mock_livekit_api, mock_get_running_loop, mock_client_session, settings
):
"""Test LiveKitAPI client creation with SSL verification disabled."""
mock_get_running_loop.return_value = mock.MagicMock()
mock_session_instance = mock.MagicMock()
mock_client_session.return_value = mock_session_instance
settings.LIVEKIT_VERIFY_SSL = False
create_livekit_client()
mock_livekit_api.assert_called_once_with(
**settings.LIVEKIT_CONFIGURATION, session=mock_session_instance
)
@mock.patch("asyncio.get_running_loop")
@mock.patch("core.utils.LiveKitAPI")
def test_create_livekit_client_custom_configuration(
mock_livekit_api, mock_get_running_loop, settings
):
"""Test LiveKitAPI client creation with custom configuration."""
settings.LIVEKIT_VERIFY_SSL = True
mock_get_running_loop.return_value = mock.MagicMock()
custom_configuration = {
"api_key": "mock_key",
"api_secret": "mock_secret",
"url": "http://mock-url.com",
}
create_livekit_client(custom_configuration)
mock_livekit_api.assert_called_once_with(**custom_configuration, session=None)
+1 -1
View File
@@ -3,10 +3,10 @@
from django.conf import settings
from django.urls import include, path
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from rest_framework.routers import DefaultRouter
from core.api import get_frontend_configuration, viewsets
from core.authentication.urls import urlpatterns as oidc_urls
# - Main endpoints
router = DefaultRouter()
+49 -1
View File
@@ -11,8 +11,11 @@ from typing import Optional
from uuid import uuid4
from django.conf import settings
from django.core.files.storage import default_storage
from livekit.api import AccessToken, VideoGrants
import aiohttp
import botocore
from livekit.api import AccessToken, LiveKitAPI, VideoGrants
def generate_color(identity: str) -> str:
@@ -110,3 +113,48 @@ def generate_livekit_config(
room=room_id, user=user, username=username, color=color
),
}
def generate_s3_authorization_headers(key):
"""
Generate authorization headers for an s3 object.
These headers can be used as an alternative to signed urls with many benefits:
- the urls of our files never expire and can be stored in our recording' metadata
- we don't leak authorized urls that could be shared (file access can only be done
with cookies)
- access control is truly realtime
- the object storage service does not need to be exposed on internet
"""
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
"get_object",
ExpiresIn=0,
Params={"Bucket": default_storage.bucket_name, "Key": key},
)
request = botocore.awsrequest.AWSRequest(method="get", url=url)
s3_client = default_storage.connection.meta.client
# pylint: disable=protected-access
credentials = s3_client._request_signer._credentials # noqa: SLF001
frozen_credentials = credentials.get_frozen_credentials()
region = s3_client.meta.region_name
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
auth.add_auth(request)
return request
def create_livekit_client(custom_configuration=None):
"""Create and return a configured LiveKit API client."""
custom_session = None
if not settings.LIVEKIT_VERIFY_SSL:
connector = aiohttp.TCPConnector(ssl=False)
custom_session = aiohttp.ClientSession(connector=connector)
# Use default configuration if none provided
configuration = custom_configuration or settings.LIVEKIT_CONFIGURATION
return LiveKitAPI(session=custom_session, **configuration)
Binary file not shown.
@@ -0,0 +1,446 @@
# 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: 2025-05-26 10:50+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:26
msgid "Personal info"
msgstr "Persönliche Informationen"
#: core/admin.py:39
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:51
msgid "Important dates"
msgstr "Wichtige Daten"
#: core/admin.py:143
msgid "No owner"
msgstr "Kein Eigentümer"
#: core/admin.py:146
msgid "Multiple owners"
msgstr "Mehrere Eigentümer"
#: core/api/serializers.py:63
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
"hinzuzufügen."
#: core/models.py:31
msgid "Member"
msgstr "Mitglied"
#: core/models.py:32
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:33
msgid "Owner"
msgstr "Eigentümer"
#: core/models.py:49
msgid "Initiated"
msgstr "Gestartet"
#: core/models.py:50
msgid "Active"
msgstr "Aktiv"
#: core/models.py:51
msgid "Stopped"
msgstr "Beendet"
#: core/models.py:52
msgid "Saved"
msgstr "Gespeichert"
#: core/models.py:53
msgid "Aborted"
msgstr "Abgebrochen"
#: core/models.py:54
msgid "Failed to Start"
msgstr "Start fehlgeschlagen"
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Stopp fehlgeschlagen"
#: core/models.py:56
msgid "Notification succeeded"
msgstr "Benachrichtigung erfolgreich"
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr "BILDSCHIRMAUFZEICHNUNG"
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr "TRANSKRIPT"
#: core/models.py:90
msgid "Public Access"
msgstr "Öffentlicher Zugriff"
#: core/models.py:91
msgid "Trusted Access"
msgstr "Vertrauenswürdiger Zugriff"
#: core/models.py:92
msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff"
#: core/models.py:104
msgid "id"
msgstr "ID"
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr "Primärschlüssel des Eintrags als UUID"
#: core/models.py:111
msgid "created on"
msgstr "erstellt am"
#: core/models.py:112
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
#: core/models.py:117
msgid "updated on"
msgstr "aktualisiert am"
#: core/models.py:118
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
"und die Zeichen @/./+/-/_ enthalten."
#: core/models.py:144
msgid "sub"
msgstr "Sub"
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Erforderlich. Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ sind "
"erlaubt."
#: core/models.py:154
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:159
msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:161
msgid "full name"
msgstr "Vollständiger Name"
#: core/models.py:163
msgid "short name"
msgstr "Kurzname"
#: core/models.py:169
msgid "language"
msgstr "Sprache"
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:179
msgid "device"
msgstr "Gerät"
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:184
msgid "staff status"
msgstr "Mitarbeiterstatus"
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:189
msgid "active"
msgstr "aktiv"
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos."
#: core/models.py:205
msgid "user"
msgstr "Benutzer"
#: core/models.py:206
msgid "users"
msgstr "Benutzer"
#: core/models.py:265
msgid "Resource"
msgstr "Ressource"
#: core/models.py:266
msgid "Resources"
msgstr "Ressourcen"
#: core/models.py:320
msgid "Resource access"
msgstr "Ressourcenzugriff"
#: core/models.py:321
msgid "Resource accesses"
msgstr "Ressourcenzugriffe"
#: core/models.py:327
msgid "Resource access with this User and Resource already exists."
msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits."
#: core/models.py:383
msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:384
msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:391
msgid "Room PIN code"
msgstr "PIN-Code für den Raum"
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:398 core/models.py:552
msgid "Room"
msgstr "Raum"
#: core/models.py:399
msgid "Rooms"
msgstr "Räume"
#: core/models.py:563
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:565
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr ""
"Geben Sie eine ID für die Aufzeichnung des Workers ein. Diese ID bleibt "
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht."
#: core/models.py:573
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:574
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:580
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:581
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:689
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:690
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:696
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:702
msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:708
msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit"
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
#: 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 "Logo-E-Mail"
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call"
msgstr "lädt Sie zu einem laufenden Videoanruf ein"
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "AM ANRUF TEILNEHMEN"
#: 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 ""
"Wenn Sie den Button nicht anklicken können, kopieren Sie die URL und fügen "
"Sie sie in Ihren Browser ein, um am Anruf teilzunehmen."
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:"
msgstr "Tipps für ein besseres Erlebnis:"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality"
msgstr "Verwenden Sie Chrome oder Firefox für eine bessere Anrufqualität"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining"
msgstr "Testen Sie Ihr Mikrofon und Ihre Kamera vor dem Beitritt"
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Stellen Sie sicher, dass Sie eine stabile Internetverbindung haben"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Vielen Dank für die Nutzung von %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr "Ihre Aufzeichnung ist fertig!"
#: 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 ""
" Ihre Aufzeichnung von \"%(room_name)s\" am %(recording_date)s um "
"%(recording_time)s steht nun zum Herunterladen bereit. "
#: 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 " Die Aufzeichnung wird in %(days)s Tagen ablaufen. "
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \"Open\" button below "
msgstr "Klicken Sie auf den Button „Öffnen“ unten "
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \"Download\" button in the interface "
msgstr "Verwenden Sie den Button „Herunterladen“ in der Oberfläche "
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
msgid "Save the file to your preferred location"
msgstr "Speichern Sie die Datei an einem gewünschten Ort"
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr "Öffnen"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
msgstr ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. "
#: meet/settings.py:162
msgid "English"
msgstr "Englisch"
#: meet/settings.py:163
msgid "French"
msgstr "Französisch"
#: meet/settings.py:164
msgid "Dutch"
msgstr "Niederländisch"
#: meet/settings.py:165
msgid "German"
msgstr "Deutsch"
Binary file not shown.
+343 -110
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
"POT-Creation-Date: 2025-05-26 10:50+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,192 +17,425 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:31
#: core/admin.py:26
msgid "Personal info"
msgstr ""
#: core/admin.py:33
#: core/admin.py:39
msgid "Permissions"
msgstr ""
msgstr "Permissions"
#: core/admin.py:45
#: core/admin.py:51
msgid "Important dates"
msgstr ""
msgstr "Important dates"
#: core/api/serializers.py:128
msgid "Markdown Body"
msgstr ""
#: core/admin.py:143
msgid "No owner"
msgstr "No owner"
#: core/authentication.py:71
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/admin.py:146
msgid "Multiple owners"
msgstr "Multiple owners"
#: core/authentication.py:91
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/api/serializers.py:63
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "You must be administrator or owner of a room to add accesses to it."
#: core/models.py:27
#: core/models.py:31
msgid "Member"
msgstr ""
msgstr "Member"
#: core/models.py:28
#: core/models.py:32
msgid "Administrator"
msgstr ""
msgstr "Administrator"
#: core/models.py:29
#: core/models.py:33
msgid "Owner"
msgstr ""
#: core/models.py:41
msgid "id"
msgstr ""
#: core/models.py:42
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:48
msgid "created on"
msgstr ""
msgstr "Owner"
#: core/models.py:49
msgid "date and time at which a record was created"
msgstr ""
msgid "Initiated"
msgstr "Initiated"
#: core/models.py:50
msgid "Active"
msgstr "Active"
#: core/models.py:51
msgid "Stopped"
msgstr "Stopped"
#: core/models.py:52
msgid "Saved"
msgstr "Saved"
#: core/models.py:53
msgid "Aborted"
msgstr "Aborted"
#: core/models.py:54
msgid "updated on"
msgstr ""
msgid "Failed to Start"
msgstr "Failed to Start"
#: core/models.py:55
msgid "date and time at which a record was last updated"
msgstr ""
msgid "Failed to Stop"
msgstr "Failed to Stop"
#: core/models.py:75
#: core/models.py:56
msgid "Notification succeeded"
msgstr "Notification succeeded"
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING"
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:90
msgid "Public Access"
msgstr "Public Access"
#: core/models.py:91
msgid "Trusted Access"
msgstr "Trusted Access"
#: core/models.py:92
msgid "Restricted Access"
msgstr "Restricted Access"
#: core/models.py:104
msgid "id"
msgstr "id"
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr "primary key for the record as UUID"
#: core/models.py:111
msgid "created on"
msgstr "created on"
#: core/models.py:112
msgid "date and time at which a record was created"
msgstr "date and time at which a record was created"
#: core/models.py:117
msgid "updated on"
msgstr "updated on"
#: core/models.py:118
msgid "date and time at which a record was last updated"
msgstr "date and time at which a record was last updated"
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: core/models.py:81
#: core/models.py:144
msgid "sub"
msgstr ""
msgstr "sub"
#: core/models.py:83
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:91
#: core/models.py:154
msgid "identity email address"
msgstr ""
msgstr "identity email address"
#: core/models.py:96
#: core/models.py:159
msgid "admin email address"
msgstr ""
msgstr "admin email address"
#: core/models.py:103
#: core/models.py:161
msgid "full name"
msgstr "full name"
#: core/models.py:163
msgid "short name"
msgstr "short name"
#: core/models.py:169
msgid "language"
msgstr ""
msgstr "language"
#: core/models.py:104
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr ""
msgstr "The language in which the user wants to see the interface."
#: core/models.py:110
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr ""
msgstr "The timezone in which the user wants to see times."
#: core/models.py:113
#: core/models.py:179
msgid "device"
msgstr ""
msgstr "device"
#: core/models.py:115
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr ""
msgstr "Whether the user is a device or a real user."
#: core/models.py:118
#: core/models.py:184
msgid "staff status"
msgstr ""
msgstr "staff status"
#: core/models.py:120
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr ""
msgstr "Whether the user can log into this admin site."
#: core/models.py:123
#: core/models.py:189
msgid "active"
msgstr ""
msgstr "active"
#: core/models.py:126
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:138
#: core/models.py:205
msgid "user"
msgstr ""
msgstr "user"
#: core/models.py:139
#: core/models.py:206
msgid "users"
msgstr ""
msgstr "users"
#: core/models.py:161
msgid "title"
msgstr ""
#: core/models.py:265
msgid "Resource"
msgstr "Resource"
#: core/models.py:162
msgid "description"
msgstr ""
#: core/models.py:266
msgid "Resources"
msgstr "Resources"
#: core/models.py:163
msgid "code"
msgstr ""
#: core/models.py:320
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:164
msgid "css"
msgstr ""
#: core/models.py:321
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:166
msgid "public"
msgstr ""
#: core/models.py:327
msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:168
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:383
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:174
msgid "Template"
msgstr ""
#: core/models.py:384
msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:175
msgid "Templates"
msgstr ""
#: core/models.py:391
msgid "Room PIN code"
msgstr "Room PIN code"
#: core/models.py:256
msgid "Template/user relation"
msgstr ""
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unique n-digit code that identifies this room in telephony mode."
#: core/models.py:257
msgid "Template/user relations"
msgstr ""
#: core/models.py:398 core/models.py:552
msgid "Room"
msgstr "Room"
#: core/models.py:263
msgid "This user is already in this template."
msgstr ""
#: core/models.py:399
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:269
msgid "This team is already in this template."
msgstr ""
#: core/models.py:563
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:275
#: core/models.py:565
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:573
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:574
msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called."
#: core/models.py:580
msgid "Recording"
msgstr "Recording"
#: core/models.py:581
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:689
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:690
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:696
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:702
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:708
msgid "Either user or team must be set, not both."
msgstr ""
msgstr "Either user or team must be set, not both."
#: meet/settings.py:134
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Your recording is ready"
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video call in progress: {sender.email} is waiting for you to connect"
#: 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 "Logo email"
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call"
msgstr "invites you to join an ongoing video call"
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "JOIN THE CALL"
#: 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 ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:"
msgstr "Tips for a better experience:"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality"
msgstr "Use Chrome or Firefox for better call quality"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining"
msgstr "Test your microphone and camera before joining"
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Make sure you have a stable internet connection"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Thank you for using %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr "Your recording is ready!"
#: 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 ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
#: 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 " The recording will expire in %(days)s days. "
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr "To keep this recording permanently:"
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \"Open\" button below "
msgstr "Click the \"Open\" button below "
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \"Download\" button in the interface "
msgstr "Use the \"Download\" button in the interface "
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
msgid "Save the file to your preferred location"
msgstr "Save the file to your preferred location"
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr "Open"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
msgstr ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
#: meet/settings.py:162
msgid "English"
msgstr ""
msgstr "English"
#: meet/settings.py:135
#: meet/settings.py:163
msgid "French"
msgstr ""
msgstr "French"
#: meet/settings.py:164
msgid "Dutch"
msgstr "Dutch"
#: meet/settings.py:165
msgid "German"
msgstr "German"
Binary file not shown.
+346 -107
View File
@@ -8,201 +8,440 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
"POT-Creation-Date: 2025-05-26 10:50+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\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:31
#: core/admin.py:26
msgid "Personal info"
msgstr ""
msgstr "Informations personnelles"
#: core/admin.py:33
#: core/admin.py:39
msgid "Permissions"
msgstr ""
msgstr "Permissions"
#: core/admin.py:45
#: core/admin.py:51
msgid "Important dates"
msgstr ""
msgstr "Dates importantes"
#: core/api/serializers.py:128
msgid "Markdown Body"
msgstr ""
#: core/admin.py:143
msgid "No owner"
msgstr "Pas de propriétaire"
#: core/authentication.py:71
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/admin.py:146
msgid "Multiple owners"
msgstr "Plusieurs propriétaires"
#: core/authentication.py:91
msgid "Claims contained no recognizable user identification"
#: core/api/serializers.py:63
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
"des accès."
#: core/models.py:27
#: core/models.py:31
msgid "Member"
msgstr ""
msgstr "Membre"
#: core/models.py:28
#: core/models.py:32
msgid "Administrator"
msgstr ""
msgstr "Administrateur"
#: core/models.py:29
#: core/models.py:33
msgid "Owner"
msgstr ""
#: core/models.py:41
msgid "id"
msgstr ""
#: core/models.py:42
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:48
msgid "created on"
msgstr ""
msgstr "Propriétaire"
#: core/models.py:49
msgid "date and time at which a record was created"
msgstr ""
msgid "Initiated"
msgstr "Initié"
#: core/models.py:50
msgid "Active"
msgstr "Actif"
#: core/models.py:51
msgid "Stopped"
msgstr "Arrêté"
#: core/models.py:52
msgid "Saved"
msgstr "Enregistré"
#: core/models.py:53
msgid "Aborted"
msgstr "Abandonné"
#: core/models.py:54
msgid "updated on"
msgstr ""
msgid "Failed to Start"
msgstr "Échec au démarrage"
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Échec à l'arrêt"
#: core/models.py:56
msgid "Notification succeeded"
msgstr "Notification réussie"
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION"
#: core/models.py:90
msgid "Public Access"
msgstr "Accès public"
#: core/models.py:91
msgid "Trusted Access"
msgstr "Accès de confiance"
#: core/models.py:92
msgid "Restricted Access"
msgstr "Accès restreint"
#: core/models.py:104
msgid "id"
msgstr "id"
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:111
msgid "created on"
msgstr "créé le"
#: core/models.py:112
msgid "date and time at which a record was created"
msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:117
msgid "updated on"
msgstr "mis à jour le"
#: core/models.py:118
msgid "date and time at which a record was last updated"
msgstr ""
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
"fois"
#: core/models.py:75
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, "
"des chiffres et les caractères @/./+/-/_."
#: core/models.py:81
#: core/models.py:144
msgid "sub"
msgstr ""
msgstr "sub"
#: core/models.py:83
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./"
"+/-/_ uniquement."
#: core/models.py:91
#: core/models.py:154
msgid "identity email address"
msgstr ""
msgstr "adresse e-mail d'identité"
#: core/models.py:96
#: core/models.py:159
msgid "admin email address"
msgstr ""
msgstr "adresse e-mail d'administrateur"
#: core/models.py:103
#: core/models.py:161
msgid "full name"
msgstr "nom complet"
#: core/models.py:163
msgid "short name"
msgstr "nom court"
#: core/models.py:169
msgid "language"
msgstr ""
msgstr "langue"
#: core/models.py:104
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr ""
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:110
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr ""
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:113
#: core/models.py:179
msgid "device"
msgstr ""
msgstr "appareil"
#: core/models.py:115
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr ""
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:118
#: core/models.py:184
msgid "staff status"
msgstr ""
msgstr "statut du personnel"
#: core/models.py:120
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr ""
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:123
#: core/models.py:189
msgid "active"
msgstr ""
msgstr "actif"
#: core/models.py:126
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:138
#: core/models.py:205
msgid "user"
msgstr ""
msgstr "utilisateur"
#: core/models.py:139
#: core/models.py:206
msgid "users"
msgstr ""
msgstr "utilisateurs"
#: core/models.py:161
msgid "title"
msgstr ""
#: core/models.py:265
msgid "Resource"
msgstr "Ressource"
#: core/models.py:162
msgid "description"
msgstr ""
#: core/models.py:266
msgid "Resources"
msgstr "Ressources"
#: core/models.py:163
msgid "code"
msgstr ""
#: core/models.py:320
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:164
msgid "css"
msgstr ""
#: core/models.py:321
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:166
msgid "public"
#: core/models.py:327
msgid "Resource access with this User and Resource already exists."
msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:168
msgid "Whether this template is public for anyone to use."
#: core/models.py:383
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:384
msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:391
msgid "Room PIN code"
msgstr "Code PIN de la salle"
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:398 core/models.py:552
msgid "Room"
msgstr "Salle"
#: core/models.py:399
msgid "Rooms"
msgstr "Salles"
#: core/models.py:563
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:565
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant "
"est conservé même lorsque le Worker s'arrête, permettant un suivi "
"facile."
#: core/models.py:174
msgid "Template"
msgstr ""
#: core/models.py:573
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:175
msgid "Templates"
msgstr ""
#: core/models.py:574
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:256
msgid "Template/user relation"
msgstr ""
#: core/models.py:580
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:257
msgid "Template/user relations"
msgstr ""
#: core/models.py:581
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:263
msgid "This user is already in this template."
msgstr ""
#: core/models.py:689
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:269
msgid "This team is already in this template."
msgstr ""
#: core/models.py:690
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:275
#: core/models.py:696
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:702
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:708
msgid "Either user or team must be set, not both."
msgstr ""
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: meet/settings.py:134
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez"
#: 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 "Logo email"
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call"
msgstr "vous invite à rejoindre un appel vidéo en cours"
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "REJOINDRE L'APPEL"
#: 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 vous ne pouvez pas cliquer sur le bouton, copiez et collez l'URL dans "
"votre navigateur pour rejoindre l'appel."
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:"
msgstr "Conseils pour une meilleure expérience :"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality"
msgstr "Utilisez Chrome ou Firefox pour une meilleure qualité d'appel"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining"
msgstr "Testez votre microphone et votre caméra avant de rejoindre"
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Assurez-vous d'avoir une connexion Internet stable"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Merci d'utiliser %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr "Votre enregistrement est prêt !"
#: 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 ""
" Votre enregistrement de \"%(room_name)s\" du %(recording_date)s à "
"%(recording_time)s est maintenant prêt à être téléchargé. "
#: 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 " L'enregistrement expirera dans %(days)s jours. "
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr "Pour conserver cet enregistrement de façon permanente :"
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \"Open\" button below "
msgstr "Cliquez sur le bouton \"Ouvrir\" ci-dessous "
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \"Download\" button in the interface "
msgstr "Utilisez le bouton \"Télécharger\" dans l'interface "
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
msgid "Save the file to your preferred location"
msgstr "Enregistrez le fichier à l'emplacement de votre choix"
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. "
#: meet/settings.py:162
msgid "English"
msgstr ""
msgstr "Anglais"
#: meet/settings.py:135
#: meet/settings.py:163
msgid "French"
msgstr ""
msgstr "Français"
#: meet/settings.py:164
msgid "Dutch"
msgstr "Néerlandais"
#: meet/settings.py:165
msgid "German"
msgstr "Allemand"
Binary file not shown.
@@ -0,0 +1,441 @@
# 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: 2025-05-26 10:50+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:26
msgid "Personal info"
msgstr "Persoonlijke informatie"
#: core/admin.py:39
msgid "Permissions"
msgstr "Rechten"
#: core/admin.py:51
msgid "Important dates"
msgstr "Belangrijke datums"
#: core/admin.py:143
msgid "No owner"
msgstr "Geen eigenaar"
#: core/admin.py:146
msgid "Multiple owners"
msgstr "Meerdere eigenaren"
#: core/api/serializers.py:63
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
#: core/models.py:31
msgid "Member"
msgstr "Lid"
#: core/models.py:32
msgid "Administrator"
msgstr "Beheerder"
#: core/models.py:33
msgid "Owner"
msgstr "Eigenaar"
#: core/models.py:49
msgid "Initiated"
msgstr "Gestart"
#: core/models.py:50
msgid "Active"
msgstr "Actief"
#: core/models.py:51
msgid "Stopped"
msgstr "Gestopt"
#: core/models.py:52
msgid "Saved"
msgstr "Opgeslagen"
#: core/models.py:53
msgid "Aborted"
msgstr "Afgebroken"
#: core/models.py:54
msgid "Failed to Start"
msgstr "Starten mislukt"
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Stoppen mislukt"
#: core/models.py:56
msgid "Notification succeeded"
msgstr "Notificatie geslaagd"
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME"
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:90
msgid "Public Access"
msgstr "Openbare toegang"
#: core/models.py:91
msgid "Trusted Access"
msgstr "Vertrouwde toegang"
#: core/models.py:92
msgid "Restricted Access"
msgstr "Beperkte toegang"
#: core/models.py:104
msgid "id"
msgstr "id"
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:111
msgid "created on"
msgstr "aangemaakt op"
#: core/models.py:112
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record werd aangemaakt"
#: core/models.py:117
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:118
msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten."
#: core/models.py:144
msgid "sub"
msgstr "sub"
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Vereist. 255 tekens of minder. Alleen letters, cijfers en @/./+/-/_ tekens."
#: core/models.py:154
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:159
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:161
msgid "full name"
msgstr "volledige naam"
#: core/models.py:163
msgid "short name"
msgstr "korte naam"
#: core/models.py:169
msgid "language"
msgstr "taal"
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:179
msgid "device"
msgstr "apparaat"
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:184
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:189
msgid "active"
msgstr "actief"
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:205
msgid "user"
msgstr "gebruiker"
#: core/models.py:206
msgid "users"
msgstr "gebruikers"
#: core/models.py:265
msgid "Resource"
msgstr "Bron"
#: core/models.py:266
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:320
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:321
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:327
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:383
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:384
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:391
msgid "Room PIN code"
msgstr "Pincode van de kamer"
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:398 core/models.py:552
msgid "Room"
msgstr "Ruimte"
#: core/models.py:399
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:563
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:565
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:573
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:574
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:580
msgid "Recording"
msgstr "Opname"
#: core/models.py:581
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:689
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:690
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:696
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:702
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:708
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Je opname is klaar"
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video-oproep bezig: {sender.email} wacht op je verbinding"
#: 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 "Logo e-mail"
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call"
msgstr "nodigt je uit om deel te nemen aan een lopende video-oproep"
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "NEEM DEEL AAN DE OPROEP"
#: 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 ""
"Als je niet op de knop kunt klikken, kopieer en plak dan de URL in je "
"browser om deel te nemen aan de oproep."
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:"
msgstr "Tips voor een betere ervaring:"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality"
msgstr "Gebruik Chrome of Firefox voor betere gesprekskwaliteit"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining"
msgstr "Test je microfoon en camera voordat je deelneemt"
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Zorg ervoor dat je een stabiele internetverbinding hebt"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Bedankt voor het gebruik van %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr "Je opname is klaar!"
#: 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 ""
" Je opname van \"%(room_name)s\" op %(recording_date)s om %(recording_time)s "
"is nu klaar om te downloaden. "
#: 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 " De opname verloopt over %(days)s dagen. "
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr "Om deze opname permanent te bewaren:"
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \"Open\" button below "
msgstr "Klik op de \"Openen\"-knop hieronder "
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \"Download\" button in the interface "
msgstr "Gebruik de \"Download\"-knop in de interface "
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
msgid "Save the file to your preferred location"
msgstr "Sla het bestand op naar je gewenste locatie"
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr "Openen"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. "
#: meet/settings.py:162
msgid "English"
msgstr "Engels"
#: meet/settings.py:163
msgid "French"
msgstr "Frans"
#: meet/settings.py:164
msgid "Dutch"
msgstr "Nederlands"
#: meet/settings.py:165
msgid "German"
msgstr "Duits"
+3 -3
View File
@@ -3,12 +3,12 @@
meet's sandbox management script.
"""
import os
import sys
from os import environ
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "meet.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
environ.setdefault("DJANGO_SETTINGS_MODULE", "meet.settings")
environ.setdefault("DJANGO_CONFIGURATION", "Development")
from configurations.management import execute_from_command_line
+3 -3
View File
@@ -1,13 +1,13 @@
"""Meet celery configuration file."""
import os
from os import environ
from celery import Celery
from configurations.importer import install
# Set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "meet.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
environ.setdefault("DJANGO_SETTINGS_MODULE", "meet.settings")
environ.setdefault("DJANGO_CONFIGURATION", "Development")
install(check_options=True)
+130 -30
View File
@@ -11,19 +11,19 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
"""
import json
import os
from os import path
from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
import sentry_sdk
from configurations import Configuration, values
from lasuite.configuration.values import SecretFileValue
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.logging import ignore_logger
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.join("/", "data")
BASE_DIR = path.dirname(path.dirname(path.abspath(__file__)))
def get_release():
@@ -39,7 +39,7 @@ def get_release():
# Try to get the current release from the version.json file generated by the
# CI during the Docker image build
try:
with open(os.path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
with open(path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
return json.load(version)["version"]
except FileNotFoundError:
return "NA" # Default: not available
@@ -70,9 +70,11 @@ class Base(Configuration):
API_VERSION = "v1.0"
DATA_DIR = values.Value(path.join("/", "data"), environ_name="DATA_DIR")
# Security
ALLOWED_HOSTS = values.ListValue([])
SECRET_KEY = values.Value(None)
SECRET_KEY = SecretFileValue(None)
SILENCED_SYSTEM_CHECKS = values.ListValue([])
ALLOW_UNSECURE_USER_LISTING = values.BooleanValue(
False, environ_name="ALLOW_UNSECURE_USER_LISTING", environ_prefix=None
@@ -92,7 +94,7 @@ class Base(Configuration):
),
"NAME": values.Value("meet", environ_name="DB_NAME", environ_prefix=None),
"USER": values.Value("dinum", environ_name="DB_USER", environ_prefix=None),
"PASSWORD": values.Value(
"PASSWORD": SecretFileValue(
"pass", environ_name="DB_PASSWORD", environ_prefix=None
),
"HOST": values.Value(
@@ -105,9 +107,9 @@ class Base(Configuration):
# Static files (CSS, JavaScript, Images)
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(DATA_DIR, "static")
STATIC_ROOT = path.join(DATA_DIR, "static")
MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(DATA_DIR, "media")
MEDIA_ROOT = path.join(DATA_DIR, "media")
SITE_ID = 1
@@ -127,10 +129,10 @@ class Base(Configuration):
AWS_S3_ENDPOINT_URL = values.Value(
environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None
)
AWS_S3_ACCESS_KEY_ID = values.Value(
AWS_S3_ACCESS_KEY_ID = SecretFileValue(
environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None
)
AWS_S3_SECRET_ACCESS_KEY = values.Value(
AWS_S3_SECRET_ACCESS_KEY = SecretFileValue(
environ_name="AWS_S3_SECRET_ACCESS_KEY", environ_prefix=None
)
AWS_S3_REGION_NAME = values.Value(
@@ -160,10 +162,12 @@ class Base(Configuration):
(
("en-us", _("English")),
("fr-fr", _("French")),
("nl-nl", _("Dutch")),
("de-de", _("German")),
)
)
LOCALE_PATHS = (os.path.join(BASE_DIR, "locale"),)
LOCALE_PATHS = (path.join(BASE_DIR, "locale"),)
TIME_ZONE = "UTC"
USE_I18N = True
@@ -173,7 +177,7 @@ class Base(Configuration):
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "templates")],
"DIRS": [path.join(BASE_DIR, "templates")],
"OPTIONS": {
"context_processors": [
"django.contrib.auth.context_processors.auth",
@@ -261,6 +265,9 @@ class Base(Configuration):
"rest_framework.parsers.JSONParser",
"nested_multipart_parser.drf.DrfNestedParser",
],
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
],
"EXCEPTION_HANDLER": "core.api.exception_handler",
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
@@ -272,6 +279,11 @@ class Base(Configuration):
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
environ_prefix=None,
),
"creation_callback": values.Value(
default="600/minute",
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
environ_prefix=None,
),
},
}
@@ -293,6 +305,11 @@ class Base(Configuration):
# Frontend
FRONTEND_CONFIGURATION = {
# If set, a <link> tag with this URL as href is added to the <head> of the frontend app.
# This is useful if you want to change CSS variables to customize the look of the app.
"custom_css_url": values.Value(
None, environ_name="FRONTEND_CUSTOM_CSS_URL", environ_prefix=None
),
"analytics": values.DictValue(
{}, environ_name="FRONTEND_ANALYTICS", environ_prefix=None
),
@@ -302,23 +319,46 @@ class Base(Configuration):
"silence_livekit_debug_logs": values.BooleanValue(
False, environ_name="FRONTEND_SILENCE_LIVEKIT_DEBUG", environ_prefix=None
),
"is_silent_login_enabled": values.BooleanValue(
True, environ_name="FRONTEND_IS_SILENT_LOGIN_ENABLED", environ_prefix=None
),
"feedback": values.DictValue(
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
),
"use_french_gov_footer": values.BooleanValue(
False, environ_name="FRONTEND_USE_FRENCH_GOV_FOOTER", environ_prefix=None
),
"use_proconnect_button": values.BooleanValue(
False, environ_name="FRONTEND_USE_PROCONNECT_BUTTON", environ_prefix=None
),
"transcript": values.DictValue(
{}, environ_name="FRONTEND_TRANSCRIPT", environ_prefix=None
),
"manifest_link": values.Value(
None, environ_name="FRONTEND_MANIFEST_LINK", environ_prefix=None
),
}
# Mail
EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend")
EMAIL_HOST = values.Value(None)
EMAIL_HOST_USER = values.Value(None)
EMAIL_HOST_PASSWORD = values.Value(None)
EMAIL_HOST_PASSWORD = SecretFileValue(None)
EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
EMAIL_FROM = values.Value("from@example.com")
EMAIL_BRAND_NAME = values.Value(None)
EMAIL_SUPPORT_EMAIL = values.Value(None)
EMAIL_LOGO_IMG = values.Value(None)
EMAIL_DOMAIN = values.Value(None)
EMAIL_APP_BASE_URL = values.Value(None)
AUTH_USER_MODEL = "core.User"
# CORS
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(True)
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(False)
CORS_ALLOWED_ORIGINS = values.ListValue([])
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
@@ -338,11 +378,13 @@ class Base(Configuration):
# Session
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
SESSION_COOKIE_AGE = 60 * 60 * 12
SESSION_COOKIE_AGE = values.PositiveIntegerValue(
default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None
)
# OIDC - Authorization Code Flow
OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView"
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
OIDC_AUTHENTICATE_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationRequestView"
OIDC_CALLBACK_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationCallbackView"
OIDC_CREATE_USER = values.BooleanValue(
default=True, environ_name="OIDC_CREATE_USER", environ_prefix=None
)
@@ -359,7 +401,7 @@ class Base(Configuration):
OIDC_RP_CLIENT_ID = values.Value(
"meet", environ_name="OIDC_RP_CLIENT_ID", environ_prefix=None
)
OIDC_RP_CLIENT_SECRET = values.Value(
OIDC_RP_CLIENT_SECRET = SecretFileValue(
None,
environ_name="OIDC_RP_CLIENT_SECRET",
environ_prefix=None,
@@ -376,6 +418,9 @@ class Base(Configuration):
OIDC_OP_USER_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None
)
OIDC_OP_USER_ENDPOINT_FORMAT = values.Value(
"AUTO", environ_name="OIDC_OP_USER_ENDPOINT_FORMAT", environ_prefix=None
)
OIDC_OP_LOGOUT_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None
)
@@ -422,15 +467,23 @@ class Base(Configuration):
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
environ_prefix=None,
)
OIDC_USERINFO_ESSENTIAL_CLAIMS = values.ListValue(
default=[],
environ_name="OIDC_USERINFO_ESSENTIAL_CLAIMS",
environ_prefix=None,
)
# Video conference configuration
LIVEKIT_CONFIGURATION = {
"api_key": values.Value(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
"api_secret": values.Value(
"api_key": SecretFileValue(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
"api_secret": SecretFileValue(
environ_name="LIVEKIT_API_SECRET", environ_prefix=None
),
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
}
LIVEKIT_VERIFY_SSL = values.BooleanValue(
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
)
RESOURCE_DEFAULT_ACCESS_LEVEL = values.Value(
"public", environ_name="RESOURCE_DEFAULT_ACCESS_LEVEL", environ_prefix=None
)
@@ -445,9 +498,6 @@ class Base(Configuration):
RECORDING_OUTPUT_FOLDER = values.Value(
"recordings", environ_name="RECORDING_OUTPUT_FOLDER", environ_prefix=None
)
RECORDING_VERIFY_SSL = values.BooleanValue(
True, environ_name="RECORDING_VERIFY_SSL", environ_prefix=None
)
RECORDING_WORKER_CLASSES = values.DictValue(
{
"screen_recording": "core.recording.worker.services.VideoCompositeEgressService",
@@ -467,15 +517,23 @@ class Base(Configuration):
RECORDING_STORAGE_EVENT_ENABLE = values.BooleanValue(
False, environ_name="RECORDING_STORAGE_EVENT_ENABLE", environ_prefix=None
)
RECORDING_STORAGE_EVENT_TOKEN = values.Value(
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(
None, environ_name="RECORDING_EXPIRATION_DAYS", environ_prefix=None
)
SUMMARY_SERVICE_ENDPOINT = values.Value(
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
)
SUMMARY_SERVICE_API_TOKEN = values.Value(
SUMMARY_SERVICE_API_TOKEN = SecretFileValue(
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
)
SCREEN_RECORDING_BASE_URL = values.Value(
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
)
# Marketing and communication settings
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
@@ -488,7 +546,7 @@ class Base(Configuration):
environ_name="MARKETING_SERVICE_CLASS",
environ_prefix=None,
)
BREVO_API_KEY = values.Value(
BREVO_API_KEY = SecretFileValue(
None, environ_name="BREVO_API_KEY", environ_prefix=None
)
BREVO_API_CONTACT_LIST_IDS = values.ListValue(
@@ -498,6 +556,9 @@ class Base(Configuration):
converter=lambda x: int(x), # pylint: disable=unnecessary-lambda
)
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
BREVO_API_TIMEOUT = values.PositiveIntegerValue(
1, environ_name="BREVO_API_TIMEOUT", environ_prefix=None
)
# Lobby configurations
LOBBY_KEY_PREFIX = values.Value(
@@ -525,6 +586,40 @@ class Base(Configuration):
environ_prefix=None,
)
# Calendar integrations
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
600, # 10 minutes
environ_name="ROOM_CREATION_CALLBACK_CACHE_TIMEOUT",
environ_prefix=None,
)
# SIP Telephony
ROOM_TELEPHONY_ENABLED = values.BooleanValue(
False,
environ_name="ROOM_TELEPHONY_ENABLED",
environ_prefix=None,
)
ROOM_TELEPHONY_PIN_LENGTH = values.PositiveIntegerValue(
10,
environ_name="ROOM_TELEPHONY_PIN_LENGTH",
environ_prefix=None,
)
ROOM_TELEPHONY_PIN_MAX_RETRIES = values.PositiveIntegerValue(
5,
environ_name="ROOM_TELEPHONY_PIN_MAX_RETRIES",
environ_prefix=None,
)
ROOM_TELEPHONY_PHONE_NUMBER = values.Value(
None,
environ_name="ROOM_TELEPHONY_PHONE_NUMBER",
environ_prefix=None,
)
ROOM_TELEPHONY_DEFAULT_COUNTRY = values.Value(
"US",
environ_name="ROOM_TELEPHONY_DEFAULT_COUNTRY",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -571,10 +666,7 @@ class Base(Configuration):
release=get_release(),
integrations=[DjangoIntegration()],
)
# Add the application name to the Sentry scope
scope = sentry_sdk.get_global_scope()
scope.set_tag("application", "backend")
sentry_sdk.set_tag("application", "backend")
# Ignore the logs added by the DockerflowMiddleware
ignore_logger("request.summary")
@@ -738,6 +830,14 @@ class Staging(Production):
"""
class Sandbox(Production):
"""
Sandbox environment settings
nota bene: it should inherit from the Production environment.
"""
class PreProduction(Production):
"""
Pre-production environment settings
+3 -3
View File
@@ -7,11 +7,11 @@ For more information on this file, see
https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/
"""
import os
from os import environ
from configurations.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "meet.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
environ.setdefault("DJANGO_SETTINGS_MODULE", "meet.settings")
environ.setdefault("DJANGO_CONFIGURATION", "Development")
application = get_wsgi_application()
+27 -28
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.15"
version = "0.1.28"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,39 +25,38 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.37.18",
"boto3==1.38.42",
"Brotli==1.1.0",
"brevo-python==1.1.2",
"celery[redis]==5.4.0",
"celery[redis]==5.5.3",
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
"django-lasuite[all]==0.0.10",
"django-parler==2.3",
"redis==5.2.1",
"django-redis==5.4.0",
"django-storages[s3]==1.14.5",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.1.7",
"djangorestframework==3.15.2",
"django==5.2.3",
"djangorestframework==3.16.0",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"june-analytics-python==2.3.0",
"markdown==3.7",
"jsonschema==4.24.0",
"markdown==3.8.2",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.6",
"psycopg[binary]==3.2.9",
"PyJWT==2.10.1",
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.24.1",
"url-normalize==1.4.3",
"requests==2.32.4",
"sentry-sdk==2.30.0",
"whitenoise==6.9.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==0.8.2",
"aiohttp==3.11.14",
"livekit-api==1.0.3",
"aiohttp==3.12.13",
]
[project.urls]
@@ -68,22 +67,22 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2025.3.1",
"freezegun==1.5.1",
"django-extensions==4.1",
"drf-spectacular-sidecar==2025.6.1",
"freezegun==1.5.2",
"ipdb==0.13.13",
"ipython==9.0.2",
"pyfakefs==5.8.0",
"ipython==9.3.0",
"pyfakefs==5.9.1",
"pylint-django==2.6.1",
"pylint==3.3.6",
"pytest-cov==6.0.0",
"pytest-django==4.10.0",
"pytest==8.3.5",
"pylint==3.3.7",
"pytest-cov==6.2.1",
"pytest-django==4.11.1",
"pytest==8.4.1",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"pytest-xdist==3.7.0",
"responses==0.25.7",
"ruff==0.11.2",
"types-requests==2.32.0.20250306",
"ruff==0.12.0",
"types-requests==2.32.4.20250611",
]
[tool.setuptools]

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