Compare commits

...

265 Commits

Author SHA1 Message Date
lebaudantoine 80fdf3faf2 🚨(frontend) remove redundant undefined type or optional specifier
Fix TypeScript warning by removing either 'undefined' type annotation
or '?' optional specifier where both were redundantly specified.
2025-08-11 18:48:27 +02:00
lebaudantoine 8d2df4001a 🚨(frontend) fix unnecessary destructuring rename in devices assignment
Remove unnecessary renaming in destructuring assignment where variable
was renamed to the same name, violating TypeScript rule S6650.
2025-08-11 18:48:27 +02:00
lebaudantoine d610c0dff3 💄(frontend) add symmetric shadows for white button contrast enhancement
Add symmetric shadows at top and bottom of white circle buttons to
ensure sufficient visual contrast against varying background colors.

Improves button visibility and accessibility by providing consistent
visual definition regardless of background context.
2025-08-11 18:48:27 +02:00
lebaudantoine bad054fa8c ♻️(frontend) refactor device select for controlled behavior
Major refactor of device select component with several key improvements:

* Set permission=true for Firefox compatibility - without this flag,
  device list returns empty on Firefox
* Implement controlled component pattern for active device selection,
  ensuring sync with preview track state
* Remove default device handling as controlled behavior eliminates need
* Render selectors only after permissions granted to prevent double
  permission prompts (separate for mic/camera)

Ensures usePreviewTrack handles initial permission request, then
selectors allow specific device choice once access is granted.
2025-08-11 18:48:27 +02:00
lebaudantoine 3b398ca3fd 🐛(frontend) fix default device ID mismatch with actual preview track
Update default device IDs when preview track starts to match the
actual device being used. LiveKit returns 'default' string which may
not exist in device list, causing ID mismatch.

Prevents misleading situation where default device ID doesn't
correspond to the device actually used by the preview track. Now
synchronizes IDs once preview starts for accurate device tracking.
2025-08-11 18:48:27 +02:00
lebaudantoine 3c2c1a04db 🐛(frontend) reset video ref on track stop for state transitions
Reset video element reference when track stops to ensure "camera
starting" to "camera started" message transitions work correctly on
repeated camera toggles.

Previously only worked on initial video element load. Now properly
handles state transitions for multiple camera enable/disable cycles.
2025-08-11 18:44:58 +02:00
lebaudantoine cb30048b27 ♻️(frontend) refactor select to show arrow up when menu opens upward
Update select toggle device component used in conference to display
upward arrow when dropdown menu opens above the select component.

Improves visual consistency by matching arrow direction with actual
menu opening direction, providing clearer user interface feedback.
2025-08-11 18:44:58 +02:00
lebaudantoine 945279d504 🩹(frontend) refactor track muting for proper audio/video control
Fix useTrackToggle hook that wasn't properly muting/unmuting tracks
outside room context. Previously only toggled boolean state via
setUpManualToggle without actual track control.

This caused misleading visual feedback where prejoin showed "camera
disabled" while hardware remained active. Users could see camera/mic
LEDs still on despite UI indicating otherwise.

Refactor provides genuine track muting for accurate user feedback.
2025-08-11 18:44:58 +02:00
lebaudantoine 13c9436dc3 ♻️(frontend) refactor prejoin screen for room context flexibility
Decouple prejoin components from conference context to enable different
behaviors when inside vs outside room environments. Components can now
evolve independently with lighter coupling.

Refactor layout structure to prepare for upcoming speaker selector
introduction. This decoupling allows for more flexible component
evolution and cleaner architecture.
2025-08-11 18:39:38 +02:00
lebaudantoine bd139a1ef9 🎨(frontend) personalize permission modal for Safari UX differences
Provides Safari-specific UI guidance that matches the browser's unique
permission flow, ensuring users receive appropriate instructions for
their specific browser environment.
2025-08-10 18:42:42 +02:00
lebaudantoine f682f8feb3 🐛(frontend) fix React aria warnings with label/wrapper investigation
Attempt to resolve React aria warnings by adding aria-label to form
components. Visual label should be reusable by screen readers, but
warning persists with only form wrapper as apparent difference.

Uncertain if warning is harmful. Added aria-label as potential fix but
removed after feedback from Sophie and Manu. Warning remains annoying
during development.
2025-08-10 18:42:42 +02:00
lebaudantoine 5f1d59c753 🐛(frontend) fix Safari permission change detection with polling
Add polling mechanism to detect permission changes on Safari where
permission change events are not reliably fired when users interact
with system prompts.

Implements 500ms polling when permissions are in 'prompt' state to
catch grant/deny actions that Safari's event system misses. Polling
stops when permissions resolve to prevent performance impact.

Fixes UI inconsistency where Safari users' permission changes weren't
detected, leaving outdated status displays.
2025-08-10 18:42:42 +02:00
lebaudantoine c45b91dc58 🩹(frontend) fix missing aria text for camera preview status
Add missing accessibility text to video container that explains to
screen reader users whether camera preview is currently enabled or
disabled.
2025-08-10 18:42:42 +02:00
lebaudantoine 0e72f61650 🚸(frontend) add permission hints and modal button to join screen
Add explicit messaging on join screen explaining why users should
allow camera/microphone access, with quick button to open permission
modal dialog.

Targets first-time users who need guidance on permission requirements.
Message persists until permissions are granted to ensure proper user
onboarding and reduce support issues.
2025-08-10 18:42:42 +02:00
lebaudantoine 4fae3c6c47 (frontend) add visual permission indicator to device toggle button
Introduce accessible visual indicator on device toggle buttons to hint
when users have permission issues that require action.

Provides clear visual warning to help users understand they need to
resolve permissions before using camera/microphone features. Follows
accessibility guidelines for proper user guidance.
2025-08-10 18:42:42 +02:00
lebaudantoine 120bcdc720 🚸(frontend) add permissions dialog to guide users through setup
Introduce guided permissions dialog to help users understand and
resolve camera/microphone access issues step-by-step.

Addresses common user support requests where users cannot enable their
hardware and don't understand the permission requirements. Provides
clear instructions to reduce confusion and support burden.

Image was quickly prototyped. It will be updated later on.
2025-08-10 18:42:42 +02:00
lebaudantoine f1b20d7981 (frontend) add permissions watcher to sync valtio store with browser
Introduce permissions watcher that continuously monitors browser
permission changes and keeps the valtio global store synchronized
with actual browser permission state.
2025-08-10 18:42:42 +02:00
lebaudantoine 95190ec690 (frontend) add global store for browser permissions monitoring
Introduce new global state management to watch and expose browser
permissions status across the application.

Sets foundation for upcoming changes that will prevent the app from
offering hardware features (camera/microphone) when permissions are
not granted, improving user experience and reducing confusion.
2025-08-10 18:42:42 +02:00
lebaudantoine adb99cc5d9 ♻️(frontend) refactor prejoin layout for better extensibility
Major refactoring of prejoin interface to improve user onboarding and
camera/microphone permission handling:

* Create extensible hint message system for easier addition of
  permission-related guidance.
* Design flexible layout structure to accommodate future camera/mic
  options and component selection features.
* Establish foundation for comprehensive prejoin redesign

In upcoming commits, UX will be enhanced to a smoother user
experience when joining calls and requesting media permissions.
2025-08-08 13:10:08 +02:00
lebaudantoine 201069aa4c ♻️(frontend) refactor clipboard logic into dedicated reusable hook
Extract clipboard content logic from UI components into a separate
custom hook to decouple interface elements from clipboard functionality.

Creates a reusable hook that can better adapt to future UX changes
without requiring modifications to individual UI components.
2025-08-07 12:51:03 +02:00
lebaudantoine b6a5b1a805 🎨(frontend) remove space between PIN digits and # symbol
Update PIN formatting to remove space before the # symbol to clearly
indicate that # is part of the complete PIN code.

Improves user understanding that the hash symbol is an integral part
of the PIN entry sequence, not a separate element.
2025-08-07 12:51:03 +02:00
lebaudantoine f3af637fd6 (frontend) add telephony info to encourage phone participation
Add telephony information to the share dialog when available to help
users take advantage of the newly introduced phone join feature.

Promotes phone participation as an alternative connection method when
users need it, improving meeting accessibility and user adoption of
telephony capabilities.
2025-08-07 12:51:03 +02:00
lebaudantoine de3a5aa404 💄(frontend) update secondaryText button variant to medium font weight
Change secondaryText button style from default to medium font weight
for improved visual comfort and better readability.

I haven't tested this change with Marianne.
2025-08-07 12:51:03 +02:00
lebaudantoine 5e9d20e685 🐛(frontend) fix public room warning showing for all rooms
Remove incorrect public room warning that was always displayed
regardless of room privacy settings.

Warning should only appear for genuinely public rooms since the lobby
system introduction changed room privacy behavior.

Prevents user confusion about room privacy settings.
2025-08-07 12:51:03 +02:00
lebaudantoine b54445739a (frontend) add telephony info to meeting dialog with layout stability
Add telephony information display to the later meeting dialog while
preserving existing layout when telephony is disabled.

Prevent layout shift on modal close by collapsing all modal content
immediately when room becomes undefined.

Critical enhancement for users creating meeting links to have complete
connection information available.
2025-08-07 12:51:03 +02:00
lebaudantoine 7c67bacd94 🚸(frontend) fix locale issues and improve meeting dialog copywriting
Remove ProConnect mentions from frontend locale and enhance the meeting
dialog description text to clearly explain that meeting links are
permanent and persistent.

Improves user understanding of meeting link permanence and cleans up
branding references.
2025-08-07 12:51:03 +02:00
lebaudantoine 1fd1b184ee 💬(frontend) update wording from "room address" to "room information"
Change UI text to use "room information" instead of "room address"
to better reflect the functionality whether copying just the meeting
link or complete meeting details.
2025-08-07 12:51:03 +02:00
lebaudantoine adb517a336 🎨(frontend) harmonize room info sidepanel display with meeting dialog
Remove protocol prefix from room URLs in the information sidepanel to
match the syntax used in the meeting dialog.

This creates consistent URL display formatting across both UI components
for better user experience.
2025-08-07 12:51:03 +02:00
lebaudantoine eec9ff9f26 🚸(frontend) fix copy to include all meeting info from side panel
Rework clipboard functionality to copy complete meeting information when
users click the copy button in the information side panel.

Previously only partial data was copied, causing user confusion. Now
includes all relevant meeting details as expected.

Improves user experience by meeting user expectations for copy behavior.
2025-08-07 12:51:03 +02:00
lebaudantoine e0258a1765 🔧(tilt) configure telephony in tilt stack for development
Add default telephony configuration to the tilt stack to enable
development workflow around authentication features.

Note: This is a fake/mock configuration and is not functional for
production use. It's intended solely for development purposes.
2025-08-07 12:51:03 +02:00
lebaudantoine 872ce1ecc6 (frontend) add icon support to select primitive component
Enhance select component by adding icon display capability for better
visual representation of options.
2025-08-01 17:45:48 +02:00
lebaudantoine e2c3b745ca 🩹(frontend) avoid video glitch on the prejoin while init a processor
We've introduced simplifications that improve performance, enhance ux,
and contribute to the overall perception of experience quality.

Previously, our processor handling was overly complex. LiveKit allows us to set
a processor before starting the local video preview track, which eliminates
the black blink glitch that appeared when loading the join component
with a default processor.

This change prevents the unnecessary stopping and restarting
of the local video track.

I'm glad this issue is now resolved.

We also simplified component communication by avoiding props drilling.
Now, we use a single flag to indicate when the user is ready to enter the room.
This significantly reduces the complexity of props passed through components.
2025-08-01 17:40:11 +02:00
lebaudantoine 965d823d08 (frontend) display position of a raised hand in the queue
Users requested an enhanced visual indicator
for raised hands on the participant tile.

Most major video conferencing platforms display the position of a raised hand
in the queue. This helps hosts quickly see who is requesting to speak,
and in what order, without needing to open the full participant list.

While a minor feature, this improvement is especially valuable for power user
2025-08-01 16:54:24 +02:00
lebaudantoine 1db189ace2 🚸(frontend) sort raised hand by order of arrival
Previously, the participant list was sorted alphabetically by name.
This unintentionally affected the raised hands list,
which inherited the same sorting behavior.

Users requested that raised hands be sorted by order of arrival.
This simple change improves the UX by ensuring that raised hands
are displayed in the correct order.
2025-08-01 16:54:24 +02:00
lebaudantoine 199e0908e9 ♻️(frontend) refactor raised hand to rely on participant's attributes
Previously managed participant hand raised using raw metadata. LiveKit
introduced attributes concept, saves as metadata under hood but makes
update/handling easier.

Still pain that attributes must be strings, cannot pass boolean…

Refactor whole app to use attributes instead of metadata for the raised
hand feature. This commit might introduce regressions. I've double
checked participant list side pannel and the notification features.

Previously I persisted a boolean, I now persist the timestamp at which
the hand was raised. This will be useful in upcoming commits, especially
for sorting raised hands by order of arrival.
2025-08-01 16:54:24 +02:00
Jacques ROUSSEL 8518f83211 (helm) add the ability to configure tls secretName
Yesterday during a deployment, we were unable to configure the tls
secretName for ingress.
2025-08-01 16:53:36 +02:00
lebaudantoine 0240d85837 📝(backend) add Firefox proxy workaround parameter to installation guide
Document LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND setting that enables
connection warmup by pre-calling WebSocket endpoint to resolve proxy
connectivity issues.
2025-08-01 16:23:22 +02:00
lebaudantoine 162896c93c 🩹(backend) allow enforcing WSS protocol to resolve browser compatibility
The LiveKit API URL is necessary to interact with the API. It uses https
protocol.

Eplicit wss protocol is necessary in Websocket constructor for some
older browsers.

This resolves critical compatibility issues with legacy browsers
(notably Firefox <124, Chrome <125, Edge <125) that lack support
for HTTPS URLs in the WebSocket() constructor. Without explicit WSS
URLs, WebSocket signaling connections may fail, crash, or be blocked
entirely in these environments.

The setting is optional and defaults to the current behavior when
not specified, ensuring zero breaking changes for existing deployments.
2025-08-01 16:23:22 +02:00
Jacques ROUSSEL 483a219ac4 ♻️(documentation) remove unused environment variables
Yesterday during a deployment, we discovered that these variables are
unused:
POSTGRES_DB
POSTGRES_USER
POSTGRES_PASSWORD
2025-08-01 16:14:55 +02:00
lebaudantoine 1b26dea178 🐛(frontend) use feature detection for adaptiveStream and dynacast
Replace hardcoded true values with supportsAdaptiveStream() and
supportsDynacast() checks. LiveKit SDK supports broad browser range but
requires specific APIs - modern features need explicit compatibility checks.

Prevents enabling unsupported WebRTC features on incompatible browsers,
which could led to a poor user experience.

One alternative solution could be to install polyfills.
2025-08-01 15:46:17 +02:00
lebaudantoine bdaf4245da 🔖(minor) bump release to 0.1.33
Warmup with WebSocket pre-authentication on FF
2025-07-25 08:50:33 +02:00
lebaudantoine be63993ba2 🩹(frontend) fix connection warmup with WebSocket pre-authentication
Connection warmup wasn't working properly - only works when trying to
establish WebSocket first, then workaround kicks in. Call WebSocket
endpoint without auth info expecting 401 error, but enough to initiate
cache for subsequent WebSocket functionality.

Scope this **dirty** trick to Firefox users only. Haven't figured out
how to detect proxy from JS code simply.

Tested in staging and works on our constrained WiFi.
2025-07-25 08:50:33 +02:00
lebaudantoine 3d245c3bd4 🔖(minor) bump release to 0.1.32
warmup livekit connection.
2025-07-24 14:57:48 +02:00
lebaudantoine 66a36eff73 ♻️(frontend) refactor connection warmup to use LiveKit methods
Replace custom connection warmup implementation with LiveKit's built-in
methods for better reliability and consistency.
2025-07-24 14:49:49 +02:00
lebaudantoine 387bc2e1f4 🐛(frontend) add LiveKit connection warmup for Firefox+proxy fixes
Implement HTTPS prefetch before joining rooms to resolve WebSocket
handshake failures where Firefox+proxy returns HTTP 200 instead of 101.

Reproduced locally with Squid container. No proxy configuration fixes
found - HTTPS warmup is only working workaround. Issue doesn't occur
when signaling server shares webapp domain, making warmup unnecessary.

Use HEAD request to minimize bandwidth.
2025-07-24 14:32:51 +02:00
lebaudantoine d44b45b6aa 🐛(frontend) prevent shortcut handling when key is undefined
Add null check to avoid processing keyboard shortcuts with undefined
key values that could cause errors.

Exception caught in PostHog
2025-07-23 12:06:39 +02:00
lebaudantoine 224b98fd9a 🧪(frontend) capture LiveKit exceptions in PostHog for debugging
Test tracking signaling failures to determine root causes when connection
fails. Will remove if it spams analytics - goal is understanding failure
patterns and reasons.
2025-07-23 12:06:39 +02:00
lebaudantoine 031852d0b1 🔖(minor) bump release to 0.1.31
Fix noise reduction feature flag
2025-07-21 12:00:06 +02:00
lebaudantoine 24915b0485 🐛(frontend) fix wrong feature flag for noise reduction
Replace incorrect faceLandmarks flag with proper noiseReduction flag
to ensure correct feature availability checking.
2025-07-21 10:29:36 +02:00
ericboucher 0862203d5d 🚸(frontend) add Safari warning for unavailable speaker settings
Display notice explaining that missing browser settings are due to Safari
limitations, not app bugs, to prevent user confusion.
2025-07-20 18:35:32 +02:00
ericboucher ee604abe00 🐛(frontend) replace useSize with useMediaQuery in account settings
Fix buggy layout transitions on Safari mobile by using media queries
instead of size detection for smoother settings panel responsive behavior.
2025-07-20 18:35:32 +02:00
lebaudantoine 26d668b478 🐛(frontend) prevent Crisp crash when superuser has no email
Fix crash when switching from admin session to app with superuser account
that lacks email field. Add null check to prevent Crisp initialization
errors.
2025-07-20 18:00:08 +02:00
lebaudantoine 04081f04fc 🌐(frontend) internationalize missing error message
Add translation support for previously untranslated error message to
complete localization coverage.
2025-07-20 18:00:08 +02:00
ericboucher f7268c507b 🚸(frontend) display email with username to clarify logged-in account
Show email alongside full name when available to prevent user confusion
about which account is currently logged in. Enhances general app UX.
2025-07-20 18:00:08 +02:00
lebaudantoine cadb20793a 🔖(minor) bump release to 0.1.30
various fixes
2025-07-18 11:48:02 +02:00
lebaudantoine 8a417806e4 🐛(backend) fix lobby notification type error breaking participant alerts
Correct data type issue that was preventing lobby notifications from
being sent to other participants in the room.
2025-07-18 11:42:43 +02:00
lebaudantoine 223c744e3f 🚨(summary) lint celery_config module to pass ruff checks
Apply code formatting and style fixes to celery configuration
module to meet linting standards.
2025-07-18 11:29:13 +02:00
lebaudantoine f67335f306 🐛(summary) fix Celery task execution by number of required args
Forgot to update the args verification while enriching the task
with more recording metadata.
2025-07-18 11:29:13 +02:00
lebaudantoine 4eb7f29f8e 🔊(summary) add Celery exporter configuration for monitoring
Enable Celery task lifecycle events and broker dispatch events per
@rouja's exporter requirements. Basic configuration following
documentation without parameterization.
2025-07-17 23:53:09 +02:00
lebaudantoine 912bac8756 🔖(minor) bump release to 0.1.29
What:

- fix minor issue on summary microservice
- PKCE (oidc) support
- add limitation on recording if supported

and more.
2025-07-17 20:41:29 +02:00
lebaudantoine 3c97418a70 🐛(summary) fix Celery task execution by switching to apply_async()
Replace delay() with apply_async() to resolve invalid arguments error.
2025-07-17 20:41:29 +02:00
lebaudantoine 26a90456f7 🚸(frontend) add alert for media devices already in use
Show explicit warning when microphone/camera are occupied by other
applications. Helps users understand permission failures and reminds
them to close other video conferencing apps.

Spotted issue through Crisp support - users often forget to quit other
webconfs that don't auto-disconnect when alone.
2025-07-17 20:41:29 +02:00
lebaudantoine d068558c8f 🚸(frontend) explain duplicate identity disconnect on feedback page
Add context to feedback page when user is disconnected for joining with
same identity, which is forbidden by LiveKit. Improves user understanding
of disconnection reasons.
2025-07-17 20:41:29 +02:00
lebaudantoine dac6bfe142 ♻️(frontend) refactor feedback redirect to global onDisconnect hook
Centralize disconnect handling to ensure all client-initiated disconnects
trigger feedback page navigation. More extensible for future disconnect
event routing needs, especially when errors happen.
2025-07-17 17:41:04 +02:00
lebaudantoine 6b3e5d747a 📝(backend) add OIDC PKCE parameters to installation documentation
Document PKCE-related configuration options for OpenID Connect setup
in installation guide for proper authentication flow.
2025-07-16 15:17:39 +02:00
K900 3066e3a83c 🔒️(backend) add environment variables for PKCE settings
Defaulting to off for now to keep compatibility.
2025-07-16 14:52:44 +02:00
lebaudantoine 5e05b6b2a5 ⬆️(frontend) bump libphonenumber-js dependency
Update phone number formatting library to latest version for bug fixes
and improved international number handling.
2025-07-16 14:47:24 +02:00
lebaudantoine d075d60d19 🚸(frontend) notify all participants when recording/transcription stops
Broadcast limit-reached notifications to all room participants, not just
owner, to ensure everyone knows recording has stopped due to duration
limits.
2025-07-16 14:47:24 +02:00
lebaudantoine 7c631bb76f 🚸(frontend) alert room owner when recording exceeds max duration
Display notification to prevent silent recording failures. Shows
configured max duration in proper locale if backend provides the limit.
Prevents users from missing recording termination.
2025-07-16 14:47:24 +02:00
lebaudantoine 59cd1f766a (backend) add egress limit notification handler to LiveKit service
Implement method to process egress limit reached events from LiveKit
webhooks for better recording duration management.

Livekit by default is not notifying the participant of a room when
an egress reached its limit. I needed to proxy it through the back.
2025-07-16 14:47:24 +02:00
lebaudantoine f0a17b1ce1 (backend) add dedicated service for LiveKit recording webhook events
Create new service to handle recording-related webhooks, starting with
limit reached events. Will expand to enhance UX by notifying backend
of other LiveKit events.

Doesn't fit cleanly with existing recording package - may need broader
redesign. Chose dedicated service over mixing responsibilities.
2025-07-16 14:47:24 +02:00
lebaudantoine 17c486f7bf ♻️(backend) extract notify_participant to util function
Move from lobby service to utils for reuse across services. Method is
generic enough for utility status. Future: create dedicated LiveKit
service to encapsulate all LiveKit-related utilities.
2025-07-16 14:47:24 +02:00
lebaudantoine 85bde9633f 🔧(frontend) pass recording max duration to frontend for user alerts
Send backend recording duration limit to frontend to display warning
messages when recordings approach or reach maximum allowed length.

This configuration needs to be synced with the egres. I chose to keep
this duration in ms to be consistent with other settings.
2025-07-16 14:47:24 +02:00
Charles Hall 0eb715b0cd 📝(doc) document the oidc redirect uri 2025-07-14 16:58:50 +02:00
Charles Hall b0617dcfed 📝(doc) update project name in installation prose
References relating to k8s and things remain unchanged, because that's
probably going to be more involved to make sure such changes are
accurate.
2025-07-14 16:58:50 +02:00
lebaudantoine 6c4c44e933 (summary) enhance transcription document naming with room context
Add optional room name, recording time and date to generate better
document names based on user feedback. Template is customizable for
internationalization support.
2025-07-11 15:40:12 +02:00
lebaudantoine 16dde229cc 🚨(summary) lint analytics sources
Fix formatting issues.
2025-07-11 15:40:12 +02:00
lebaudantoine d01d6dd9d1 🚸(backend) clarify link sharing limitations in recording email
Add notice to email notifications that recording link sharing is not
supported in beta to prevent user confusion.
2025-07-11 14:14:02 +02:00
lebaudantoine 2a39978245 🚸(frontend) add beta warning about recording link sharing limitations
Display notice on recording page that link sharing is not available in
beta to prevent user confusion and set proper expectations.
2025-07-11 14:14:02 +02:00
lebaudantoine d85ed031a9 🚸(frontend) improve error messaging for recording access failures
Clarify that recording links are not shareable when users cannot load
recordings. Previous error messages were unclear about access restrictions.
2025-07-11 14:14:02 +02:00
lebaudantoine 5c8c81f97b 🐛(frontend) fix infinite loading for unauthenticated users on downloads
Reorganize exception handling in recording download screen to prevent
unauthenticated users from getting stuck in loading state. Caught by
production users - my bad;
2025-07-11 14:14:02 +02:00
lebaudantoine 3368a9b6af 🔧(summary) extract hardcoded title to configurable setting
Replace hardcoded title with configurable option to enable custom
branding for different deployments of the microservice.
2025-07-11 11:38:28 +02:00
lebaudantoine e87d0519ff 🔧(summary) make audio duration limit optional for deployment safety
Allow deploying without breaking changes while frontend implements proper
egress limit handling. Duration restriction can be enabled when ready.
2025-07-11 11:38:28 +02:00
lebaudantoine 731f0471aa ♻️(summary) rename TaskTracker to MetadataManager for clarity
Update class name to better reflect its responsibility for handling task
metadata rather than tracking tasks themselves.
2025-07-11 11:38:28 +02:00
lebaudantoine e458272745 🐛(summary) fix data type serialization issues with Redis and PostHog
Resolve float/int to string conversion problems when deserializing Redis
data for PostHog. Added type conversion fix - not bulletproof but works
for most cases. Avoid using for critical operations.
2025-07-11 11:38:28 +02:00
lebaudantoine d71e417d58 📝(summary) replace "wip" placeholder with proper docstring
Add proper function documentation to replace temporary placeholder
committed in previous hasty commit.
2025-07-11 11:38:28 +02:00
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
302 changed files with 11643 additions and 7294 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"
+37 -2
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,7 +147,8 @@ 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
+2 -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
@@ -185,7 +185,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]
@@ -249,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
+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
+4 -3
View File
@@ -16,7 +16,7 @@
</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">
@@ -33,9 +33,9 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
- 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
@@ -111,6 +111,7 @@ Come help us make La Suite Meet even better. We're growing fast and [would love
## 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
+3
View File
@@ -72,6 +72,8 @@ services:
- nginx
- livekit
- createbuckets
extra_hosts:
- "127.0.0.1.nip.io:host-gateway"
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -136,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

-3
View File
@@ -35,9 +35,6 @@ backend:
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: meet
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
LIVEKIT_API_SECRET: secret
+144 -104
View File
@@ -1,8 +1,8 @@
# Installation on a k8s cluster
This document is a step-by-step guide that describes how to install Visio on a k8s cluster without AI features.
This document is a step-by-step guide that describes how to install LaSuite Meet 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)
@@ -12,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
@@ -99,11 +110,11 @@ When your k8s cluster is ready, you can start the deployment. This cluster is sp
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.
## Preparation
## 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).
LaSuite Meet 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 LaSuite Meet) 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).
If you haven't run the script **bin/start-kind.sh**, you'll need to manually create the namespace by running the following command:
@@ -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
@@ -123,6 +134,8 @@ keycloak-0 1/1 Running 0 6m48s
keycloak-postgresql-0 1/1 Running 0 6m48s
```
In your OIDC provider, set LaSuite Meet's redirect URI to `https://.../api/v1.0/callback/` where `...` should be replaced with the domain name LaSuite Meet is hosted on.
From here the important information you will need are :
```
@@ -141,7 +154,7 @@ You can find these values in **examples/keycloak.values.yaml**
### Find livekit server connexion values
Visio use livekit for streaming part so if you have a livekit provider, obtain the necessary information to use it. If you do not have a provider, you can install a livekit testing environment as follows:
LaSuite Meet use livekit for streaming part so if you have a livekit provider, obtain the necessary information to use it. If you do not have a provider, you can install a livekit testing environment as follows:
Livekit need a redis (and meet too) so we will start by deploying a redis :
@@ -183,7 +196,7 @@ CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
### Find postgresql connexion values
Visio uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
LaSuite Meet uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
```
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
@@ -204,14 +217,11 @@ DB_NAME: meet
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: meet
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
```
## Deployment
Now you are ready to deploy Visio without AI. AI required more dependencies (Openai-compliant API, LiveKit Egress, Cold storage and a docs deployment to push resumes). To deploy meet you need to provide all previous information to the helm chart.
Now you are ready to deploy LaSuite Meet without AI. AI required more dependencies (Openai-compliant API, LiveKit Egress, Cold storage and a docs deployment to push resumes). To deploy meet you need to provide all previous information to the helm chart.
```
$ helm repo add meet https://suitenumerique.github.io/meet/
@@ -232,99 +242,129 @@ 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 LaSuite Meet 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 |
| ----------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| DJANGO_ALLOWED_HOSTS | Host that are allowed | [] |
| DJANGO_SECRET_KEY | Secret key used | |
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence system checks | [] |
| DJANGO_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 | |
| DJANGO_LANGUAGE_CODE | Default language | en-us |
| REDIS_URL | redis endpoint | redis://redis:6379/1 |
| REQUEST_ENTRY_THROTTLE_RATES | throttle rates | 150/minute |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | deploy check | False |
| FRONTEND_ANALYTICS | analytics information | {} |
| FRONTEND_SUPPORT | crisp frontend support | {} |
| FRONTEND_SILENCE_LIVEKIT_DEBUG | silence livekit debug | 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 tto the email server | |
| DJANGO_EMAIL_PORT | por tot connect to the email server | |
| DJANGO_EMAIL_USE_TLS | enable tls on email connection | false |
| DJANGO_EMAIL_USE_SSL | enable tls on email connection | false |
| DJANGO_EMAIL_FROM | email from account | from@example.com |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all cors origins | true |
| DJANGO_CORS_ALLOWED_ORIGINS | origins to allow in string | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | origins to allow in regex | [] |
| SENTRY_DSN | sentry server | |
| 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 | false |
| 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 algoritm used by oidc | RS256 |
| OIDC_RP_CLIENT_ID | oidc client | 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_LOGOUT_ENDPOINT | oidc endpoint for logout | |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | extra parameters for oidc request | |
| OIDC_RP_SCOPES | oidc scopes | openid email |
| LOGIN_REDIRECT_URL | login redirect url | |
| LOGIN_REDIRECT_URL_FAILURE | login redurect url for failure | |
| LOGOUT_REDIRECT_URL | url to redirect to on logout | |
| 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 |
| ALLOW_LOGOUT_GET_METHOD | allow logout through get method | true |
| OIDC_REDIRECT_FIELD_NAME | direct field for oidc | returnTo |
| OIDC_USERINFO_FULLNAME_FIELDS | full name claim from OIDC token | ["given_name", "usual_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | shortname claim from OIDC token | given_name |
| LIVEKIT_API_KEY | livekit api key | |
| LIVEKIT_API_SECRET | livekit api secret | |
| LIVEKIT_API_URL | livekit api url | |
| 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_VERIFY_SSL | verify ssl for recording storage | true |
| 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 | |
| SUMMARY_SERVICE_ENDPOINT | summary service endpoint | |
| SUMMARY_SERVICE_API_TOKEN | api token voor summary service | |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | signup users to marketing emails | false |
| MARKETING_SERVICE_CLASS | markering class | core.services.marketing.BrevoMarketingService |
| BREVO_API_KEY | breva 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 | 1 |
| LOBBY_KEY_PREFIX | lobby prefix | room_lobby |
| LOBBY_WAITING_TIMEOUT | lobby waiting tumeout | 3 |
| LOBBY_DENIED_TIMEOUT | lobby deny timeout | 5 |
| LOBBY_ACCEPTED_TIMEOUT | lobby accept timeout | 21600 |
| LOBBY_NOTIFICATION_TYPE | lobby notification types | participantWaiting |
| LOBBY_COOKIE_NAME | lobby cookie name | lobbyParticipantId |
| 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 | [] |
| OIDC_USE_PKCE | Enable the use of PKCE (Proof Key for Code Exchange) during the OAuth 2.0 authorization code flow. Recommended for enhanced security. | False |
| OIDC_PKCE_CODE_CHALLENGE_METHOD | Method used to generate the PKCE code challenge. Common values include S256 and plain. Refer to the mozilla-django-oidc documentation for supported options. | S256 |
| OIDC_PKCE_CODE_VERIFIER_SIZE | Length of the random string used as the PKCE code verifier. Must be an integer between 43 and 128, inclusive. | 64 |
| 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 |
| LIVEKIT_FORCE_WSS_PROTOCOL | Enables WSS protocol conversion for legacy browser compatibility (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs fail in WebSocket() constructor. | false |
| LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND | Firefox-only connection warmup: pre-calls WebSocket endpoint (expecting 401) to initialize cache, resolving proxy/network connectivity issues. | false |
| 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 | |
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
| 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 |
+9 -2
View File
@@ -42,14 +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)
+13
View File
@@ -41,6 +41,19 @@ def get_frontend_configuration(request):
"is_enabled": settings.RECORDING_ENABLE,
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"max_duration": settings.RECORDING_MAX_DURATION,
},
"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,
},
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
},
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
+3 -3
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):
@@ -98,7 +98,7 @@ class HasPrivilegesOnRoom(IsAuthenticated):
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):
+23 -9
View File
@@ -1,5 +1,7 @@
"""Client serializers for the Meet core app."""
import uuid
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
@@ -37,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
@@ -58,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.")
)
@@ -103,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):
"""
@@ -118,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,
@@ -128,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 = (
@@ -147,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
@@ -215,6 +221,14 @@ 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("ParticipantEntrySerializer is validation-only")
+20 -29
View File
@@ -531,40 +531,12 @@ class RoomViewSet(
)
class ResourceAccessListModelMixin:
"""List mixin for resource access API."""
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()
return [permission() for permission in permission_classes]
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
class ResourceAccessViewSet(
ResourceAccessListModelMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""
@@ -575,6 +547,25 @@ 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,
-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")
+31 -106
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,7 +70,9 @@ 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"]}
@@ -124,8 +80,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
marketing_service.create_contact(
contact_data, timeout=settings.BREVO_API_TIMEOUT
)
except (ContactCreationError, ImproperlyConfigured, ImportError):
pass
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
@@ -139,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
-1
View File
@@ -1,4 +1,3 @@
# ruff: noqa: S311
"""
Core application factories
"""
@@ -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
),
]
+51 -9
View File
@@ -2,6 +2,7 @@
Declare and configure the models for the Meet core application
"""
import secrets
import uuid
from datetime import datetime, timedelta
from logging import getLogger
@@ -14,7 +15,6 @@ from django.core import mail, validators
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import models
from django.utils import timezone
from django.utils.functional import lazy
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
@@ -35,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):
@@ -164,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."),
@@ -288,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."""
@@ -383,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"
@@ -393,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.
@@ -407,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
@@ -414,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."""
@@ -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
@@ -136,6 +136,13 @@ class NotificationService:
"filename": recording.key,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
"room": recording.room.name,
"recording_date": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%H:%M"),
}
headers = {
+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}'"
@@ -0,0 +1,49 @@
"""Recording-related LiveKit Events Service"""
from logging import getLogger
from core import models, utils
logger = getLogger(__name__)
class RecordingEventsError(Exception):
"""Recording event handling fails."""
class RecordingEventsService:
"""Handles recording-related Livekit webhook events."""
@staticmethod
def handle_limit_reached(recording):
"""Stop recording and notify participants when limit is reached."""
recording.status = models.RecordingStatusChoices.STOPPED
recording.save()
notification_mapping = {
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingLimitReached",
models.RecordingModeChoices.TRANSCRIPT: "transcriptionLimitReached",
}
notification_type = notification_mapping.get(recording.mode)
if not notification_type:
return
try:
utils.notify_participants(
room_name=str(recording.room.id),
notification_data={"type": notification_type},
)
except utils.NotificationError as e:
logger.exception(
"Failed to notify participants about recording limit reached: "
"room=%s, recording_id=%s, mode=%s",
recording.room.id,
recording.id,
recording.mode,
)
raise RecordingEventsError(
f"Failed to notify participants in room '{recording.room.id}' about "
f"recording limit reached (recording_id={recording.id})"
) from e
@@ -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,
+18 -14
View File
@@ -2,11 +2,10 @@
# 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
@@ -29,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.
+84 -3
View File
@@ -1,13 +1,25 @@
"""LiveKit Events Service"""
# pylint: disable=E1101
import uuid
from enum import Enum
from logging import getLogger
from django.conf import settings
from livekit import api
from core import models
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
)
from .lobby import LobbyService
from .telephony import TelephonyException, TelephonyService
logger = getLogger(__name__)
class LiveKitWebhookError(Exception):
@@ -77,6 +89,8 @@ class LiveKitEventsService:
)
self.webhook_receiver = api.WebhookReceiver(token_verifier)
self.lobby_service = LobbyService()
self.telephony_service = TelephonyService()
self.recording_events = RecordingEventsService()
def receive(self, request):
"""Process webhook and route to appropriate handler."""
@@ -108,10 +122,77 @@ class LiveKitEventsService:
# pylint: disable=not-callable
handler(data)
def _handle_room_finished(self, data):
"""Handle 'room_finished' event."""
def _handle_egress_ended(self, data):
"""Handle 'egress_ended' event."""
try:
recording = models.Recording.objects.get(
worker_id=data.egress_info.egress_id
)
except models.Recording.DoesNotExist as err:
raise ActionFailedError(
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
) from err
if (
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
and recording.status == models.RecordingStatusChoices.ACTIVE
):
try:
self.recording_events.handle_limit_reached(recording)
except RecordingEventsError as e:
raise ActionFailedError(
f"Failed to process limit reached event for recording {recording}"
) from e
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
+8 -57
View File
@@ -1,6 +1,5 @@
"""Lobby Service"""
import json
import logging
import uuid
from dataclasses import dataclass
@@ -11,14 +10,6 @@ from uuid import UUID
from django.conf import settings
from django.core.cache import cache
from asgiref.sync import async_to_sync
from livekit.api import ( # pylint: disable=E0611
ListRoomsRequest,
LiveKitAPI,
SendDataRequest,
TwirpError,
)
from core import models, utils
logger = logging.getLogger(__name__)
@@ -47,10 +38,6 @@ class LobbyParticipantNotFound(LobbyError):
"""Raised when participant is not found."""
class LobbyNotificationError(LobbyError):
"""Raised when LiveKit notification fails."""
@dataclass
class LobbyParticipant:
"""Participant in a lobby system."""
@@ -212,9 +199,6 @@ class LobbyService:
Create a new participant entry in waiting status and notify room
participants of the new entry request.
Raises:
LobbyNotificationError: If room notification fails
"""
color = utils.generate_color(participant_id)
@@ -227,10 +211,15 @@ class LobbyService:
)
try:
self.notify_participants(room_id=room_id)
except LobbyNotificationError:
utils.notify_participants(
room_name=str(room_id),
notification_data={
"type": settings.LOBBY_NOTIFICATION_TYPE,
},
)
except utils.NotificationError:
# If room not created yet, there is no participants to notify
pass
logger.exception("Failed to notify room participants")
cache_key = self._get_cache_key(room_id, participant_id)
cache.set(
@@ -335,44 +324,6 @@ class LobbyService:
participant.status = status
cache.set(cache_key, participant.to_dict(), timeout=timeout)
@async_to_sync
async def notify_participants(self, room_id: UUID):
"""Notify room participants about a new waiting participant using LiveKit.
Raises:
LobbyNotificationError: If notification fails to send
"""
notification_data = {
"type": settings.LOBBY_NOTIFICATION_TYPE,
}
lkapi = LiveKitAPI(**settings.LIVEKIT_CONFIGURATION)
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),
data=json.dumps(notification_data).encode("utf-8"),
kind="RELIABLE",
)
)
except TwirpError as e:
logger.exception("Failed to notify room participants")
raise LobbyNotificationError("Failed to notify room participants") from e
finally:
await lkapi.aclose()
def clear_room_cache(self, room_id: UUID) -> None:
"""Clear all participant entries from the cache for a specific room."""
+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
+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")
@@ -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",
],
)
@@ -0,0 +1,72 @@
"""
Test RecordingEventsService service.
"""
# pylint: disable=W0621
from unittest import mock
import pytest
from core.factories import RecordingFactory
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
)
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@pytest.fixture
def service():
"""Initialize RecordingEventsService."""
return RecordingEventsService()
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingLimitReached"),
("transcript", "transcriptionLimitReached"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_limit_reached_success(mock_notify, mode, notification_type, service):
"""Test handle_limit_reached stops recording and notifies participants."""
recording = RecordingFactory(status="active", mode=mode)
service.handle_limit_reached(recording)
assert recording.status == "stopped"
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingLimitReached"),
("transcript", "transcriptionLimitReached"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_limit_reached_error(mock_notify, mode, notification_type, service):
"""Test handle_limit_reached raises RecordingEventsError when notification fails."""
mock_notify.side_effect = NotificationError("Error notifying")
recording = RecordingFactory(status="active", mode=mode)
with pytest.raises(
RecordingEventsError,
match=r"Failed to notify participants in room '.+' "
r"about recording limit reached \(recording_id=.+\)",
):
service.handle_limit_reached(recording)
assert recording.status == "stopped"
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
@@ -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"
@@ -230,15 +230,14 @@ def test_api_rooms_invite_error(mock_invite_to_room):
data = {"emails": ["toto@yopmail.com", "toto@yopmail.com"]}
with pytest.raises(InvitationError) as excinfo:
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()
assert "Could not send invitation" in str(excinfo.value)
mock_invite_to_room.assert_called_once()
@mock.patch("core.services.invitation.send_mail")
@@ -37,7 +37,7 @@ def test_request_entry_anonymous(settings):
assert not lobby_keys
with (
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
@@ -86,7 +86,7 @@ def test_request_entry_authenticated_user(settings):
assert not lobby_keys
with (
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
@@ -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",
@@ -156,7 +156,7 @@ def test_request_entry_with_existing_participants(settings):
# Mock external service calls to isolate the test
with (
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
# Make request as a new anonymous user
@@ -205,7 +205,7 @@ def test_request_entry_public_room(settings):
assert not lobby_keys
with (
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(
LobbyService, "_get_or_create_participant_id", return_value="123"
),
@@ -255,9 +255,11 @@ def test_request_entry_authenticated_user_public_room(settings):
assert not lobby_keys
with (
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(utils, "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,10 +312,10 @@ 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),
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
),
@@ -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,
}
@@ -338,22 +347,20 @@ def test_api_rooms_retrieve_authenticated():
)
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}/",
)
@@ -361,37 +368,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
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,
"timezone": "UTC",
"language": user_access.user.language,
},
"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,
"timezone": "UTC",
"language": other_user_access.user.language,
},
"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 == {
@@ -404,6 +381,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -490,6 +468,7 @@ def test_api_rooms_retrieve_administrators(
"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(
@@ -1,13 +1,16 @@
"""
Test LiveKitEvents service.
"""
# pylint: disable=W0621,W0613, W0212
# pylint: disable=W0621,W0613, W0212, E0611
import uuid
from unittest import mock
import pytest
from livekit.api import EgressStatus
from core.factories import RecordingFactory, RoomFactory
from core.recording.services.recording_events import RecordingEventsService
from core.services.livekit_events import (
ActionFailedError,
AuthenticationError,
@@ -17,6 +20,8 @@ from core.services.livekit_events import (
api,
)
from core.services.lobby import LobbyService
from core.services.telephony import TelephonyException, TelephonyService
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@@ -53,45 +58,253 @@ def test_initialization(
mock_token_verifier.assert_called_once_with(api_key, api_secret)
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
assert isinstance(service.lobby_service, LobbyService)
assert isinstance(service.telephony_service, TelephonyService)
assert isinstance(service.recording_events, RecordingEventsService)
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingLimitReached"),
("transcript", "transcriptionLimitReached"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_success(mock_notify, mode, notification_type, service):
"""Should successfully stop recording and notifies all participant."""
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
service._handle_egress_ended(mock_data)
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
recording.refresh_from_db()
assert recording.status == "stopped"
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_notification_fails(mock_notify, service):
"""Should raise ActionFailedError when notification fails but still stop recording."""
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
mock_notify.side_effect = NotificationError("Error notifying")
with pytest.raises(
ActionFailedError,
match=r"Failed to process limit reached event for recording .+",
):
service._handle_egress_ended(mock_data)
recording.refresh_from_db()
assert recording.status == "stopped"
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_recording_not_found(mock_notify, service):
"""Should raise ActionFailedError when recording doesn't exist."""
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = "worker-2"
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
with pytest.raises(
ActionFailedError, match=r"Recording with worker ID .+ does not exist"
):
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
recording.refresh_from_db()
assert recording.status == "active"
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_recording_not_active(mock_notify, service):
"""Should ignore non-active recordings."""
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = "worker-1"
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
recording.refresh_from_db()
assert recording.status == "failed_to_stop"
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_recording_not_limit_reached(mock_notify, service):
"""Should ignore egress non-limit-reached statuses."""
recording = RecordingFactory(worker_id="worker-1", status="stopped")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = "worker-1"
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
assert recording.status == "stopped"
@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")
)
+7 -126
View File
@@ -5,7 +5,6 @@ Test lobby service.
# pylint: disable=W0621,W0613, W0212, R0913
# ruff: noqa: PLR0913
import json
import uuid
from unittest import mock
@@ -14,18 +13,17 @@ from django.core.cache import cache
from django.http import HttpResponse
import pytest
from livekit.api import TwirpError
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.services.lobby import (
LobbyNotificationError,
LobbyParticipant,
LobbyParticipantNotFound,
LobbyParticipantParsingError,
LobbyParticipantStatus,
LobbyService,
)
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@@ -414,7 +412,7 @@ def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color")
@mock.patch("core.services.lobby.LobbyService.notify_participants")
@mock.patch("core.utils.notify_participants")
def test_enter_success(
mock_notify,
mock_generate_color,
@@ -443,13 +441,15 @@ def test_enter_success(
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
mock_notify.assert_called_once_with(room_id=room.id)
mock_notify.assert_called_once_with(
room_name=str(room.id), notification_data={"type": "participantWaiting"}
)
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color")
@mock.patch("core.services.lobby.LobbyService.notify_participants")
@mock.patch("core.utils.notify_participants")
def test_enter_with_notification_error(
mock_notify,
mock_generate_color,
@@ -460,7 +460,7 @@ def test_enter_with_notification_error(
):
"""Test participant entry with notification error."""
mock_generate_color.return_value = "#123456"
mock_notify.side_effect = LobbyNotificationError("Error notifying")
mock_notify.side_effect = NotificationError("Error notifying")
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -776,125 +776,6 @@ 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_no_room(mock_livekit_api, 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_livekit_api.return_value = mock_api_instance
# Act
lobby_service.notify_participants(room.id)
# Assert
# Verify the API was initialized with correct configuration
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 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.services.lobby.LiveKitAPI")
def test_notify_participants_success(mock_livekit_api, 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
# 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()
send_data_request = mock_api_instance.room.send_data.call_args[0][0]
assert send_data_request.room == str(room.id)
assert (
json.loads(send_data_request.data.decode("utf-8"))["type"]
== settings.LOBBY_NOTIFICATION_TYPE
)
assert send_data_request.kind == 0 # RELIABLE mode in Livekit protocol
# Verify aclose was called
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.services.lobby.LiveKitAPI")
def test_notify_participants_error(mock_livekit_api, 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)
)
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
# Call the function and expect an exception
with pytest.raises(
LobbyNotificationError, match="Failed to notify room participants"
):
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()
# Verify aclose was still called after the exception
mock_api_instance.aclose.assert_called_once()
def test_clear_room_cache(settings, lobby_service):
"""Test clearing room cache actually removes entries from cache."""
@@ -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()
+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)
+168
View File
@@ -0,0 +1,168 @@
"""
Test utils functions
"""
import json
from unittest import mock
import pytest
from livekit.api import TwirpError
from core.utils import NotificationError, create_livekit_client, notify_participants
@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)
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_error(mock_create_livekit_client):
"""Test participant notification with API error."""
# 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, 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_create_livekit_client.return_value = mock_api_instance
# Call the function and expect an exception
with pytest.raises(NotificationError, match="Failed to notify room participants"):
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
# 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()
# Verify aclose was still called after the exception
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success_no_room(mock_create_livekit_client):
"""Test the notify_participants function when the LiveKit room doesn't exist."""
# 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
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
# 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):
"""Test successful participant notification."""
# 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_create_livekit_client.return_value = mock_api_instance
# Call the function
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
# 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()
send_data_request = mock_api_instance.room.send_data.call_args[0][0]
assert send_data_request.room == "room-number-1"
assert json.loads(send_data_request.data.decode("utf-8")) == {"foo": "foo"}
assert send_data_request.kind == 0 # RELIABLE mode in Livekit protocol
# Verify aclose was called
mock_api_instance.aclose.assert_called_once()
+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()
+59 -1
View File
@@ -13,8 +13,17 @@ from uuid import uuid4
from django.conf import settings
from django.core.files.storage import default_storage
import aiohttp
import botocore
from livekit.api import AccessToken, VideoGrants
from asgiref.sync import async_to_sync
from livekit.api import ( # pylint: disable=E0611
AccessToken,
ListRoomsRequest,
LiveKitAPI,
SendDataRequest,
TwirpError,
VideoGrants,
)
def generate_color(identity: str) -> str:
@@ -142,3 +151,52 @@ def generate_s3_authorization_headers(key):
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)
class NotificationError(Exception):
"""Notification delivery to room participants fails."""
@async_to_sync
async def notify_participants(room_name: str, notification_data: dict):
"""Send notification data to all participants in a LiveKit room."""
lkapi = create_livekit_client()
try:
room_response = await lkapi.room.list_rooms(
ListRoomsRequest(
names=[room_name],
)
)
# Check if the room exists
if not room_response.rooms:
return
await lkapi.room.send_data(
SendDataRequest(
room=room_name,
data=json.dumps(notification_data).encode("utf-8"),
kind="RELIABLE",
)
)
except TwirpError as e:
raise NotificationError("Failed to notify room participants") from e
finally:
await lkapi.aclose()
Binary file not shown.
@@ -0,0 +1,455 @@
# 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-07-11 11:33+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:147
msgid "No owner"
msgstr "Kein Eigentümer"
#: core/admin.py:150
msgid "Multiple owners"
msgstr "Mehrere Eigentümer"
#: core/api/serializers.py:67
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:245
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:23
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " 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:200
#: core/templates/mail/text/screen_recording.txt:9
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
" Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Organisatoren können sie herunterladen. "
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:"
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
msgid "Click the \"Open\" button below "
msgstr "Klicken Sie auf den Button „Öffnen“ unten "
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface "
msgstr "Verwenden Sie den Button „Herunterladen“ in der Oberfläche "
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location"
msgstr "Speichern Sie die Datei an einem gewünschten Ort"
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
msgid "Open"
msgstr "Öffnen"
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
msgstr ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. "
#: meet/settings.py:163
msgid "English"
msgstr "Englisch"
#: meet/settings.py:164
msgid "French"
msgstr "Französisch"
#: meet/settings.py:165
msgid "Dutch"
msgstr "Niederländisch"
#: meet/settings.py:166
msgid "German"
msgstr "Deutsch"
Binary file not shown.
+108 -107
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-23 14:04+0000\n"
"POT-Creation-Date: 2025-07-11 11:33+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"
@@ -29,111 +29,107 @@ msgstr "Permissions"
msgid "Important dates"
msgstr "Important dates"
#: core/api/serializers.py:63
#: core/admin.py:147
msgid "No owner"
msgstr "No owner"
#: core/admin.py:150
msgid "Multiple owners"
msgstr "Multiple owners"
#: core/api/serializers.py:67
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/authentication/backends.py:77
msgid "User info contained no recognizable user identification"
msgstr "User info contained no recognizable user identification"
#: core/authentication/backends.py:102
msgid "User account is disabled"
msgstr "User account is disabled"
#: core/authentication/backends.py:142
msgid "Multiple user accounts share a common email."
msgstr "Multiple user accounts share a common email."
#: core/models.py:30
#: core/models.py:31
msgid "Member"
msgstr "Member"
#: core/models.py:31
#: core/models.py:32
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:32
#: core/models.py:33
msgid "Owner"
msgstr "Owner"
#: core/models.py:48
#: core/models.py:49
msgid "Initiated"
msgstr "Initiated"
#: core/models.py:49
#: core/models.py:50
msgid "Active"
msgstr "Active"
#: core/models.py:50
#: core/models.py:51
msgid "Stopped"
msgstr "Stopped"
#: core/models.py:51
#: core/models.py:52
msgid "Saved"
msgstr "Saved"
#: core/models.py:52
#: core/models.py:53
msgid "Aborted"
msgstr "Aborted"
#: core/models.py:53
#: core/models.py:54
msgid "Failed to Start"
msgstr "Failed to Start"
#: core/models.py:54
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Failed to Stop"
#: core/models.py:55
#: core/models.py:56
msgid "Notification succeeded"
msgstr "Notification succeeded"
#: core/models.py:82
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING"
#: core/models.py:83
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:89
#: core/models.py:90
msgid "Public Access"
msgstr "Public Access"
#: core/models.py:90
#: core/models.py:91
msgid "Trusted Access"
msgstr "Trusted Access"
#: core/models.py:91
#: core/models.py:92
msgid "Restricted Access"
msgstr "Restricted Access"
#: core/models.py:103
#: core/models.py:104
msgid "id"
msgstr "id"
#: core/models.py:104
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr "primary key for the record as UUID"
#: core/models.py:110
#: core/models.py:111
msgid "created on"
msgstr "created on"
#: core/models.py:111
#: 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:116
#: core/models.py:117
msgid "updated on"
msgstr "updated on"
#: core/models.py:117
#: 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:137
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -141,11 +137,11 @@ msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: core/models.py:143
#: core/models.py:144
msgid "sub"
msgstr "sub"
#: core/models.py:145
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
@@ -153,55 +149,55 @@ msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:153
#: core/models.py:154
msgid "identity email address"
msgstr "identity email address"
#: core/models.py:158
#: core/models.py:159
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:160
#: core/models.py:161
msgid "full name"
msgstr "full name"
#: core/models.py:162
#: core/models.py:163
msgid "short name"
msgstr "short name"
#: core/models.py:168
#: core/models.py:169
msgid "language"
msgstr "language"
#: core/models.py:169
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr "The language in which the user wants to see the interface."
#: core/models.py:175
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "The timezone in which the user wants to see times."
#: core/models.py:178
#: core/models.py:179
msgid "device"
msgstr "device"
#: core/models.py:180
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Whether the user is a device or a real user."
#: core/models.py:183
#: core/models.py:184
msgid "staff status"
msgstr "staff status"
#: core/models.py:185
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Whether the user can log into this admin site."
#: core/models.py:188
#: core/models.py:189
msgid "active"
msgstr "active"
#: core/models.py:191
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -209,55 +205,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:204
#: core/models.py:205
msgid "user"
msgstr "user"
#: core/models.py:205
#: core/models.py:206
msgid "users"
msgstr "users"
#: core/models.py:264
#: core/models.py:265
msgid "Resource"
msgstr "Resource"
#: core/models.py:265
#: core/models.py:266
msgid "Resources"
msgstr "Resources"
#: core/models.py:319
#: core/models.py:320
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:320
#: core/models.py:321
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:326
#: 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:382
#: core/models.py:383
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:383
#: 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:389 core/models.py:509
#: core/models.py:391
msgid "Room PIN code"
msgstr "Room PIN code"
#: 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:398 core/models.py:552
msgid "Room"
msgstr "Room"
#: core/models.py:390
#: core/models.py:399
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:520
#: core/models.py:563
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:522
#: 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."
@@ -265,58 +269,42 @@ msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:530
#: core/models.py:573
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:531
#: core/models.py:574
msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called."
#: core/models.py:537
#: core/models.py:580
msgid "Recording"
msgstr "Recording"
#: core/models.py:538
#: core/models.py:581
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:646
#: core/models.py:689
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:647
#: core/models.py:690
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:653
#: core/models.py:696
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:659
#: core/models.py:702
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:665
#: core/models.py:708
msgid "Either user or team must be set, not both."
msgstr "Either user or team must be set, not both."
#: core/recording/event/authentication.py:58
msgid "Authentication is enabled but token is not configured."
msgstr "Authentication is enabled but token is not configured."
#: core/recording/event/authentication.py:70
msgid "Authorization header is required"
msgstr "Authorization header is required"
#: core/recording/event/authentication.py:78
msgid "Invalid authorization header."
msgstr "Invalid authorization header."
#: core/recording/event/authentication.py:88
msgid "Invalid token"
msgstr "Invalid token"
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Your recording is ready"
@@ -373,9 +361,9 @@ 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/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#: core/templates/mail/text/screen_recording.txt:23
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Thank you for using %(brandname)s. "
@@ -401,33 +389,42 @@ msgstr ""
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
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
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
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
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
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface "
msgstr "Use the \"Download\" button in the interface "
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location"
msgstr "Save the file to your preferred location"
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
msgid "Open"
msgstr "Open"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
@@ -436,14 +433,18 @@ msgstr ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
#: meet/settings.py:162
#: meet/settings.py:163
msgid "English"
msgstr "English"
#: meet/settings.py:163
#: meet/settings.py:164
msgid "French"
msgstr "French"
#: meet/settings.py:164
#: meet/settings.py:165
msgid "Dutch"
msgstr "Dutch"
#: meet/settings.py:166
msgid "German"
msgstr "German"
Binary file not shown.
+113 -115
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-23 14:04+0000\n"
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,129 +29,123 @@ msgstr "Permissions"
msgid "Important dates"
msgstr "Dates importantes"
#: core/api/serializers.py:63
#: core/admin.py:147
msgid "No owner"
msgstr "Pas de propriétaire"
#: core/admin.py:150
msgid "Multiple owners"
msgstr "Plusieurs propriétaires"
#: core/api/serializers.py:67
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/authentication/backends.py:77
msgid "User info contained no recognizable user identification"
msgstr ""
"Les informations utilisateur ne contiennent aucune identification "
"utilisateur reconnaissable"
#: core/authentication/backends.py:102
msgid "User account is disabled"
msgstr "Le compte utilisateur est désactivé"
#: core/authentication/backends.py:142
msgid "Multiple user accounts share a common email."
msgstr "Plusieurs comptes utilisateur partagent une adresse e-mail commune."
#: core/models.py:30
#: core/models.py:31
msgid "Member"
msgstr "Membre"
#: core/models.py:31
#: core/models.py:32
msgid "Administrator"
msgstr "Administrateur"
#: core/models.py:32
#: core/models.py:33
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:48
#: core/models.py:49
msgid "Initiated"
msgstr "Initié"
#: core/models.py:49
#: core/models.py:50
msgid "Active"
msgstr "Actif"
#: core/models.py:50
#: core/models.py:51
msgid "Stopped"
msgstr "Arrêté"
#: core/models.py:51
#: core/models.py:52
msgid "Saved"
msgstr "Enregistré"
#: core/models.py:52
#: core/models.py:53
msgid "Aborted"
msgstr "Abandonné"
#: core/models.py:53
#: core/models.py:54
msgid "Failed to Start"
msgstr "Échec au démarrage"
#: core/models.py:54
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Échec à l'arrêt"
#: core/models.py:55
#: core/models.py:56
msgid "Notification succeeded"
msgstr "Notification réussie"
#: core/models.py:82
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:83
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION"
#: core/models.py:89
#: core/models.py:90
msgid "Public Access"
msgstr "Accès public"
#: core/models.py:90
#: core/models.py:91
msgid "Trusted Access"
msgstr "Accès de confiance"
#: core/models.py:91
#: core/models.py:92
msgid "Restricted Access"
msgstr "Accès restreint"
#: core/models.py:103
#: core/models.py:104
msgid "id"
msgstr "id"
#: core/models.py:104
#: 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:110
#: core/models.py:111
msgid "created on"
msgstr "créé le"
#: core/models.py:111
#: 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:116
#: core/models.py:117
msgid "updated on"
msgstr "mis à jour le"
#: core/models.py:117
#: 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:137
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Entrez un identifiant valide. Cette valeur ne peut contenir que des lettres, "
"des chiffres et les caractères @/./+/-/_."
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
"chiffres et les caractères @/./+/-/_."
#: core/models.py:143
#: core/models.py:144
msgid "sub"
msgstr "identifiant"
msgstr "sub"
#: core/models.py:145
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
@@ -159,55 +153,55 @@ msgstr ""
"Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./"
"+/-/_ uniquement."
#: core/models.py:153
#: core/models.py:154
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: core/models.py:158
#: core/models.py:159
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:160
#: core/models.py:161
msgid "full name"
msgstr "nom complet"
#: core/models.py:162
#: core/models.py:163
msgid "short name"
msgstr "nom court"
#: core/models.py:168
#: core/models.py:169
msgid "language"
msgstr "langue"
#: core/models.py:169
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:175
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:178
#: core/models.py:179
msgid "device"
msgstr "appareil"
#: core/models.py:180
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:183
#: core/models.py:184
msgid "staff status"
msgstr "statut du personnel"
#: core/models.py:185
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:188
#: core/models.py:189
msgid "active"
msgstr "actif"
#: core/models.py:191
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -215,116 +209,108 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:204
#: core/models.py:205
msgid "user"
msgstr "utilisateur"
#: core/models.py:205
#: core/models.py:206
msgid "users"
msgstr "utilisateurs"
#: core/models.py:264
#: core/models.py:265
msgid "Resource"
msgstr "Ressource"
#: core/models.py:265
#: core/models.py:266
msgid "Resources"
msgstr "Ressources"
#: core/models.py:319
#: core/models.py:320
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:320
#: core/models.py:321
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:326
#: 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:382
#: core/models.py:383
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:383
#: 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:389 core/models.py:509
#: 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:390
#: core/models.py:399
msgid "Rooms"
msgstr "Salles"
#: core/models.py:520
#: core/models.py:563
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:522
#: 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 travailleur. Cet identifiant "
"est conservé même lorsque le travailleur s'arrête, permettant un suivi "
"facile."
"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:530
#: core/models.py:573
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:531
#: core/models.py:574
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:537
#: core/models.py:580
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:538
#: core/models.py:581
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:646
#: core/models.py:689
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:647
#: core/models.py:690
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:653
#: core/models.py:696
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:659
#: core/models.py:702
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:665
#: core/models.py:708
msgid "Either user or team must be set, not both."
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/recording/event/authentication.py:58
msgid "Authentication is enabled but token is not configured."
msgstr "L'authentification est activée mais le jeton n'est pas configuré."
#: core/recording/event/authentication.py:70
msgid "Authorization header is required"
msgstr "L'en-tête d'autorisation est requis"
#: core/recording/event/authentication.py:78
msgid "Invalid authorization header."
msgstr "En-tête d'autorisation invalide."
#: core/recording/event/authentication.py:88
msgid "Invalid token"
msgstr "Jeton invalide"
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
@@ -381,9 +367,9 @@ 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/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#: core/templates/mail/text/screen_recording.txt:23
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Merci d'utiliser %(brandname)s. "
@@ -409,33 +395,41 @@ msgstr ""
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
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
"Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls les organisateurs peuvent le télécharger."
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
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
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
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
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface "
msgstr "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
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location"
msgstr "Enregistrez le fichier à l'emplacement de votre choix"
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
@@ -444,14 +438,18 @@ msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. "
#: meet/settings.py:162
#: meet/settings.py:163
msgid "English"
msgstr "Anglais"
#: meet/settings.py:163
#: meet/settings.py:164
msgid "French"
msgstr "Français"
#: meet/settings.py:164
#: meet/settings.py:165
msgid "Dutch"
msgstr "Néerlandais"
#: meet/settings.py:166
msgid "German"
msgstr "Allemand"
Binary file not shown.
+108 -107
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-23 14:04+0000\n"
"POT-Creation-Date: 2025-07-11 11:33+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"
@@ -29,112 +29,108 @@ msgstr "Rechten"
msgid "Important dates"
msgstr "Belangrijke datums"
#: core/api/serializers.py:63
#: core/admin.py:147
msgid "No owner"
msgstr "Geen eigenaar"
#: core/admin.py:150
msgid "Multiple owners"
msgstr "Meerdere eigenaren"
#: core/api/serializers.py:67
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/authentication/backends.py:77
msgid "User info contained no recognizable user identification"
msgstr "Gebruikersinformatie bevatte geen herkenbare gebruikersidentificatie"
#: core/authentication/backends.py:102
msgid "User account is disabled"
msgstr "Gebruikersaccount is uitgeschakeld"
#: core/authentication/backends.py:142
msgid "Multiple user accounts share a common email."
msgstr "Meerdere gebruikersaccounts delen een gemeenschappelijk e-mailadres."
#: core/models.py:30
#: core/models.py:31
msgid "Member"
msgstr "Lid"
#: core/models.py:31
#: core/models.py:32
msgid "Administrator"
msgstr "Beheerder"
#: core/models.py:32
#: core/models.py:33
msgid "Owner"
msgstr "Eigenaar"
#: core/models.py:48
#: core/models.py:49
msgid "Initiated"
msgstr "Gestart"
#: core/models.py:49
#: core/models.py:50
msgid "Active"
msgstr "Actief"
#: core/models.py:50
#: core/models.py:51
msgid "Stopped"
msgstr "Gestopt"
#: core/models.py:51
#: core/models.py:52
msgid "Saved"
msgstr "Opgeslagen"
#: core/models.py:52
#: core/models.py:53
msgid "Aborted"
msgstr "Afgebroken"
#: core/models.py:53
#: core/models.py:54
msgid "Failed to Start"
msgstr "Starten mislukt"
#: core/models.py:54
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Stoppen mislukt"
#: core/models.py:55
#: core/models.py:56
msgid "Notification succeeded"
msgstr "Notificatie geslaagd"
#: core/models.py:82
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME"
#: core/models.py:83
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:89
#: core/models.py:90
msgid "Public Access"
msgstr "Openbare toegang"
#: core/models.py:90
#: core/models.py:91
msgid "Trusted Access"
msgstr "Vertrouwde toegang"
#: core/models.py:91
#: core/models.py:92
msgid "Restricted Access"
msgstr "Beperkte toegang"
#: core/models.py:103
#: core/models.py:104
msgid "id"
msgstr "id"
#: core/models.py:104
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:110
#: core/models.py:111
msgid "created on"
msgstr "aangemaakt op"
#: core/models.py:111
#: 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:116
#: core/models.py:117
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:117
#: 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:137
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -142,66 +138,66 @@ msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten."
#: core/models.py:143
#: core/models.py:144
msgid "sub"
msgstr "sub"
#: core/models.py:145
#: 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:153
#: core/models.py:154
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:158
#: core/models.py:159
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:160
#: core/models.py:161
msgid "full name"
msgstr "volledige naam"
#: core/models.py:162
#: core/models.py:163
msgid "short name"
msgstr "korte naam"
#: core/models.py:168
#: core/models.py:169
msgid "language"
msgstr "taal"
#: core/models.py:169
#: 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:175
#: 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:178
#: core/models.py:179
msgid "device"
msgstr "apparaat"
#: core/models.py:180
#: 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:183
#: core/models.py:184
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:185
#: 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:188
#: core/models.py:189
msgid "active"
msgstr "actief"
#: core/models.py:191
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -209,55 +205,64 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:204
#: core/models.py:205
msgid "user"
msgstr "gebruiker"
#: core/models.py:205
#: core/models.py:206
msgid "users"
msgstr "gebruikers"
#: core/models.py:264
#: core/models.py:265
msgid "Resource"
msgstr "Bron"
#: core/models.py:265
#: core/models.py:266
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:319
#: core/models.py:320
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:320
#: core/models.py:321
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:326
#: 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:382
#: core/models.py:383
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:383
#: 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:389 core/models.py:509
#: 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:390
#: core/models.py:399
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:520
#: core/models.py:563
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:522
#: 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."
@@ -265,58 +270,42 @@ 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:530
#: core/models.py:573
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:531
#: core/models.py:574
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:537
#: core/models.py:580
msgid "Recording"
msgstr "Opname"
#: core/models.py:538
#: core/models.py:581
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:646
#: core/models.py:689
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:647
#: core/models.py:690
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:653
#: core/models.py:696
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:659
#: core/models.py:702
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:665
#: 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/authentication.py:58
msgid "Authentication is enabled but token is not configured."
msgstr "Authenticatie is ingeschakeld maar token is niet geconfigureerd."
#: core/recording/event/authentication.py:70
msgid "Authorization header is required"
msgstr "Autorisatie-header is vereist"
#: core/recording/event/authentication.py:78
msgid "Invalid authorization header."
msgstr "Ongeldige autorisatie-header."
#: core/recording/event/authentication.py:88
msgid "Invalid token"
msgstr "Ongeldig token"
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Je opname is klaar"
@@ -373,9 +362,9 @@ 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/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#: core/templates/mail/text/screen_recording.txt:23
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Bedankt voor het gebruik van %(brandname)s. "
@@ -401,33 +390,41 @@ msgstr ""
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
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
"Het delen van de opname via een link is nog niet beschikbaar. Alleen organisatoren kunnen deze downloaden."
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
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
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
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
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface "
msgstr "Gebruik de \"Download\"-knop in de interface "
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location"
msgstr "Sla het bestand op naar je gewenste locatie"
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
msgid "Open"
msgstr "Openen"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
@@ -436,14 +433,18 @@ 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
#: meet/settings.py:163
msgid "English"
msgstr "Engels"
#: meet/settings.py:163
#: meet/settings.py:164
msgid "French"
msgstr "Frans"
#: meet/settings.py:164
#: meet/settings.py:165
msgid "Dutch"
msgstr "Nederlands"
#: meet/settings.py:166
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)
+122 -31
View File
@@ -11,18 +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__)))
BASE_DIR = path.dirname(path.dirname(path.abspath(__file__)))
def get_release():
@@ -38,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
@@ -69,11 +70,11 @@ class Base(Configuration):
API_VERSION = "v1.0"
DATA_DIR = values.Value(os.path.join("/", "data"), environ_name="DATA_DIR")
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
@@ -93,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(
@@ -106,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
@@ -128,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(
@@ -162,10 +163,11 @@ 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
@@ -175,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",
@@ -263,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,
@@ -300,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
),
@@ -310,7 +320,22 @@ class Base(Configuration):
False, environ_name="FRONTEND_SILENCE_LIVEKIT_DEBUG", environ_prefix=None
),
"is_silent_login_enabled": values.BooleanValue(
True, environ_name="FRONTEND_IS_SILENT_LOGING_ENABLED", environ_prefix=None
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
),
}
@@ -318,7 +343,7 @@ class Base(Configuration):
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)
@@ -333,7 +358,7 @@ class Base(Configuration):
# 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([])
@@ -353,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
)
@@ -374,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,
@@ -391,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
)
@@ -400,6 +430,17 @@ class Base(Configuration):
OIDC_RP_SCOPES = values.Value(
"openid email", environ_name="OIDC_RP_SCOPES", environ_prefix=None
)
OIDC_USE_PKCE = values.BooleanValue(
default=False, environ_name="OIDC_USE_PKCE", environ_prefix=None
)
OIDC_PKCE_CODE_CHALLENGE_METHOD = values.Value(
default="S256",
environ_name="OIDC_PKCE_CODE_CHALLENGE_METHOD",
environ_prefix=None,
)
OIDC_PKCE_CODE_VERIFIER_SIZE = values.IntegerValue(
default=64, environ_name="OIDC_PKCE_CODE_VERIFIER_SIZE", environ_prefix=None
)
LOGIN_REDIRECT_URL = values.Value(
None, environ_name="LOGIN_REDIRECT_URL", environ_prefix=None
)
@@ -437,15 +478,31 @@ 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_FORCE_WSS_PROTOCOL = values.BooleanValue(
False, environ_name="LIVEKIT_FORCE_WSS_PROTOCOL", environ_prefix=None
)
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND = values.BooleanValue(
environ_name="LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND",
environ_prefix=None,
default=False,
)
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
)
@@ -460,9 +517,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",
@@ -482,7 +536,7 @@ 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
@@ -490,10 +544,15 @@ class Base(Configuration):
RECORDING_EXPIRATION_DAYS = values.IntegerValue(
None, environ_name="RECORDING_EXPIRATION_DAYS", environ_prefix=None
)
# Recording max duration in milliseconds - must be synced with LiveKit Egress configuration
# Set to None for no max duration
RECORDING_MAX_DURATION = values.IntegerValue(
None, environ_name="RECORDING_MAX_DURATION", 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(
@@ -511,7 +570,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(
@@ -558,6 +617,33 @@ class Base(Configuration):
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):
@@ -604,10 +690,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")
@@ -771,6 +854,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 -27
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.19"
version = "0.1.33"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,38 +25,38 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.37.24",
"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.8",
"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",
"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]
@@ -67,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]
+1
View File
@@ -1 +1,2 @@
VITE_API_BASE_URL=http://localhost:8071/
VITE_APP_TITLE=LaSuite Meet
+1
View File
@@ -0,0 +1 @@
VITE_APP_TITLE=LaSuite Meet
+1 -1
View File
@@ -35,7 +35,7 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN npm run build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
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
+1
View File
@@ -1,6 +1,7 @@
server {
listen 8080;
server_name localhost;
server_tokens off;
root /usr/share/nginx/html;
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/assets/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Visio</title>
<title>%VITE_APP_TITLE%</title>
</head>
<body>
<div id="root"></div>
+1717 -1525
View File
File diff suppressed because it is too large Load Diff
+32 -27
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.19",
"version": "0.1.33",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -13,48 +13,53 @@
"check": "prettier --check ./src"
},
"dependencies": {
"@livekit/components-react": "2.8.1",
"@livekit/components-styles": "1.1.4",
"@livekit/track-processors": "0.3.3",
"@pandacss/preset-panda": "0.53.3",
"@react-aria/toast": "3.0.1",
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
"@livekit/track-processors": "0.5.7",
"@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.5",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.69.0",
"@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.25",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"i18next": "24.2.3",
"i18next-browser-languagedetector": "8.0.4",
"humanize-duration": "3.33.0",
"i18next": "25.3.1",
"i18next-browser-languagedetector": "8.2.0",
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.9.8",
"posthog-js": "1.232.6",
"libphonenumber-js": "1.12.10",
"livekit-client": "2.15.2",
"posthog-js": "1.256.2",
"react": "18.3.1",
"react-aria-components": "1.7.1",
"react-aria-components": "1.10.1",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "5.0.0",
"valtio": "2.1.4",
"wouter": "3.6.0"
"valtio": "2.1.5",
"wouter": "3.7.1"
},
"devDependencies": {
"@pandacss/dev": "0.53.3",
"@tanstack/eslint-plugin-query": "5.68.0",
"@tanstack/react-query-devtools": "5.69.0",
"@types/node": "22.13.13",
"@pandacss/dev": "0.54.0",
"@tanstack/eslint-plugin-query": "5.81.2",
"@tanstack/react-query-devtools": "5.81.5",
"@types/humanize-duration": "3.27.4",
"@types/node": "22.16.0",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@typescript-eslint/eslint-plugin": "8.28.0",
"@typescript-eslint/parser": "8.28.0",
"@vitejs/plugin-react": "4.3.4",
"@typescript-eslint/eslint-plugin": "8.35.1",
"@typescript-eslint/parser": "8.35.1",
"@vitejs/plugin-react": "4.6.0",
"eslint": "8.57.0",
"eslint-config-prettier": "10.1.1",
"eslint-config-prettier": "10.1.5",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.19",
"postcss": "8.5.3",
"prettier": "3.5.3",
"typescript": "5.8.2",
"vite": "6.2.6",
"eslint-plugin-react-refresh": "0.4.20",
"postcss": "8.5.6",
"prettier": "3.6.2",
"typescript": "5.8.3",
"vite": "7.0.2",
"vite-tsconfig-paths": "5.1.4"
}
}
+4 -3
View File
@@ -190,9 +190,6 @@ const config: Config = {
fonts: {
sans: {
value: [
'Marianne',
'Source Sans',
'Source Sans fallback',
'ui-sans-serif',
'system-ui',
'-apple-system',
@@ -318,6 +315,10 @@ const config: Config = {
'subtle-text': { value: '{colors.red.700}' },
...pandaPreset.theme.tokens.colors.red,
},
alert: {
DEFAULT: { value: '{colors.blue.700}' },
notification: { value: '{colors.red.600}' },
},
success: {
DEFAULT: { value: '{colors.green.700}' },
hover: { value: '{colors.green.800}' },
@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Calque_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1080 1080">
<defs>
<style>
.st0, .st1, .st2, .st3, .st4, .st5 {
fill: none;
}
.st6 {
fill: #6dd58c;
}
.st6, .st1, .st2, .st7, .st8, .st9, .st10 {
stroke-linecap: round;
stroke-linejoin: round;
}
.st6, .st1, .st2, .st7, .st8, .st10 {
stroke-width: 3px;
}
.st6, .st1, .st7, .st8, .st4, .st5, .st10 {
stroke: #191c1e;
}
.st11, .st7, .st12, .st9 {
fill: #fff;
}
.st11, .st3 {
stroke: #000;
stroke-miterlimit: 10;
}
.st11, .st3, .st9 {
stroke-width: 5px;
}
.st2, .st9 {
stroke: #898989;
}
.st8 {
fill: #ff8669;
}
.st13 {
fill: #e3e3fb;
}
.st14 {
fill: #f7f7f7;
}
.st4, .st5 {
stroke-width: 4px;
}
.st5 {
stroke-linecap: square;
}
.st15 {
fill: #ca3632;
}
.st10 {
fill: #ffb929;
}
</style>
</defs>
<rect class="st12" x=".53" y=".53" width="1078.61" height="1080.36"/>
<path class="st12" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
<path class="st1" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
<path class="st12" d="M960.08,256.93h-110c-12.94,0-23.43-10.49-23.43-23.43v-71.56c0-12.94-10.48-23.43-23.42-23.44h-266.94c-12.94,0-23.43,10.49-23.43,23.44h0v71.56c0,12.94-10.49,23.43-23.43,23.43H120.5v137.62h839.58"/>
<path class="st8" d="M210.81,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st10" d="M274.32,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st6" d="M337.84,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st1" d="M236.18,329.44h-63.69M201.62,358.57l-29.13-29.13,29.13-29.12M274.32,329.44h63.69M308.88,358.57l29.13-29.13-29.13-29.12"/>
<path class="st7" d="M625.31,322.08h-27.15c-4.35,0-7.88,3.53-7.88,7.88h0v17.19c0,4.35,3.53,7.88,7.88,7.88h27.15c4.35,0,7.88-3.53,7.88-7.88h0v-17.19c0-4.35-3.53-7.88-7.88-7.88h0Z"/>
<path class="st12" d="M595.13,322.09v-12.86c-.02-9.26,7.47-16.78,16.73-16.8h.03c9.26,0,16.76,7.5,16.76,16.76v12.86"/>
<path class="st1" d="M595.13,322.09v-12.86c-.02-9.26,7.47-16.78,16.73-16.8h.03c9.26,0,16.76,7.5,16.76,16.76v12.86"/>
<g>
<ellipse class="st4" cx="707.63" cy="299.5" rx="18.37" ry="18.5"/>
<path class="st5" d="M624,299h63"/>
<circle class="st4" cx="643.5" cy="339.5" r="18.5"/>
<path class="st5" d="M728,339h-63"/>
</g>
<path class="st14" d="M192.29,138.5h767.79v118.43H120.5v-46.64c0-39.62,32.17-71.79,71.79-71.79h0Z"/>
<path class="st0" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
<path class="st2" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
<path class="st2" d="M210.81,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st2" d="M274.32,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st2" d="M337.84,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st2" d="M236.18,329.44h-63.69M201.62,358.57l-29.13-29.13,29.13-29.12M274.32,329.44h63.69M308.88,358.57l29.13-29.13-29.13-29.12"/>
<path class="st15" d="M382.41,636.74l39.92,39.92,13.47-13.47-188.53-188.53-13.47,13.47,11.35,11.35h-5.58c-5.26,0-9.52,4.26-9.52,9.52v133.31c0,5.26,4.26,9.52,9.52,9.52h133.31c5.26,0,9.52-4.26,9.52-9.52v-5.58h.01ZM363.37,617.69v15.1h-114.27v-114.27h15.1l99.17,99.17h0ZM439.55,633.17c0,2.02-1.19,3.6-2.78,4.33l-16.27-16.27v-75.65l-38.09,26.66v10.9l-19.04-19.04v-45.57h-45.57l-19.04-19.04h74.14c5.26,0,9.52,4.26,9.52,9.52v39.99l49.64-34.75c3.16-2.21,7.49.05,7.49,3.9v115.02h0Z"/>
<path class="st15" d="M375.25,866.34l43.58,43.58,12.93-12.93-180.97-180.97-12.93,12.93,51.25,51.25v14.49c0,25.24,20.46,45.7,45.7,45.7,4.41,0,8.67-.62,12.7-1.79l14.17,14.17c-8.17,3.79-17.28,5.9-26.87,5.9-32.23,0-58.9-23.84-63.33-54.84h-18.43c4.21,38.13,34.49,68.4,72.62,72.62v37.06h18.28v-37.06c11.28-1.25,21.87-4.77,31.3-10.11h0ZM330.71,821.81c-11.87-1.78-21.25-11.16-23.03-23.03l23.03,23.03ZM402.21,841.85l-13.18-13.18c4.65-7.4,7.82-15.82,9.11-24.84h18.43c-1.55,14.04-6.64,27.02-14.35,38.03h0ZM375.62,815.27l-14.15-14.15c.5-2.06.76-4.21.76-6.43v-36.56c0-15.14-12.28-27.42-27.42-27.42-11.83,0-21.91,7.49-25.76,17.99l-13.68-13.68c7.94-13.52,22.63-22.59,39.44-22.59,25.24,0,45.7,20.46,45.7,45.7v36.56c0,7.4-1.76,14.39-4.88,20.58h-.01Z"/>
<path d="M597.6,312.73c0-2.65,2.15-4.79,4.79-4.79s4.79,2.15,4.79,4.79-2.15,4.79-4.79,4.79-4.79-2.15-4.79-4.79ZM602.4,301.55c-6.18,0-11.19,5.01-11.19,11.19s5.01,11.19,11.19,11.19,11.19-5.01,11.19-11.19-5.01-11.19-11.19-11.19ZM619.98,315.93h25.57v-6.39h-25.57v6.39ZM632.76,344.69c0-2.65,2.15-4.79,4.79-4.79s4.79,2.15,4.79,4.79-2.15,4.79-4.79,4.79-4.79-2.15-4.79-4.79ZM637.56,333.51c-6.18,0-11.19,5.01-11.19,11.19s5.01,11.19,11.19,11.19,11.19-5.01,11.19-11.19-5.01-11.19-11.19-11.19ZM594.41,341.5v6.39h25.57v-6.39h-25.57Z"/>
<line class="st2" x1="960.08" y1="394.55" x2="836.91" y2="394.55"/>
<path class="st9" d="M527.76,394.55H120.5v-137.62h368.93c12.94,0,23.43-10.49,23.43-23.43v-71.56c0-12.95,10.49-23.44,23.43-23.44h266.94c12.94,0,23.43,10.5,23.42,23.44v71.56c0,2.09.27,4.12.79,6.05,1,3.77,2.92,7.17,5.51,9.94,4.28,4.58,10.37,7.44,17.13,7.44h110"/>
<path class="st13" d="M645.52,274.8h314.56v101.2h-314.56c-23.81,0-43.12-22.66-43.12-50.61h0c.01-27.94,19.31-50.59,43.12-50.59h0Z"/>
<g>
<path class="st13" d="M851.32,326.18c0,28.02-6.82,54.45-18.9,77.71h-181.11c-43.65,0-79.03-35.38-79.03-79.03,0-21.82,8.86-41.56,23.16-55.86,14.3-14.29,34.06-23.13,55.87-23.13h179.74c12.93,23.89,20.27,51.24,20.27,80.31Z"/>
<path d="M656.78,350.02v9.98h39.93v-9.98h-39.93ZM724.16,337.54c-9.65,0-17.47,7.82-17.47,17.47s7.82,17.46,17.47,17.46,17.47-7.82,17.47-17.46-7.82-17.47-17.47-17.47ZM724.16,362.49c-4.13,0-7.49-3.35-7.49-7.48s3.36-7.49,7.49-7.49,7.49,3.35,7.49,7.49-3.36,7.48-7.49,7.48ZM696.71,300.11v9.98h39.93v-9.98h-39.93ZM669.26,287.63c-9.65,0-17.47,7.82-17.47,17.47s7.82,17.47,17.47,17.47,17.47-7.82,17.47-17.47-7.82-17.47-17.47-17.47ZM669.26,312.59c-4.13,0-7.49-3.36-7.49-7.49s3.36-7.49,7.49-7.49,7.49,3.35,7.49,7.49-3.35,7.49-7.49,7.49Z"/>
</g>
<line class="st2" x1="813.4" y1="432.85" x2="551.26" y2="432.85"/>
<path class="st3" d="M851.32,326.18c0,28.02-6.82,54.45-18.9,77.71-5.35,10.32-11.74,20.02-19.02,28.96-30.98,38.03-78.19,62.32-131.07,62.32s-100.09-24.29-131.07-62.32c-23.7-29.09-37.92-66.22-37.92-106.67,0-93.33,75.66-168.99,168.99-168.99,64.26,0,120.15,35.87,148.72,88.68,12.93,23.89,20.27,51.24,20.27,80.31h0Z"/>
<polygon class="st11" points="705.12 406.95 681.79 637.91 746.21 597.51 802.28 751.56 852.2 733.39 796.14 579.34 871.46 568.88 705.12 406.95"/>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

Before

Width:  |  Height:  |  Size: 651 KiB

After

Width:  |  Height:  |  Size: 651 KiB

Before

Width:  |  Height:  |  Size: 702 KiB

After

Width:  |  Height:  |  Size: 702 KiB

Before

Width:  |  Height:  |  Size: 599 KiB

After

Width:  |  Height:  |  Size: 599 KiB

Before

Width:  |  Height:  |  Size: 704 KiB

After

Width:  |  Height:  |  Size: 704 KiB

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