Compare commits

..

176 Commits

Author SHA1 Message Date
lebaudantoine e4e4fcbbfc 🔖(minor) bump release to 0.1.27
White label frontend. Docs is missing, will be
written shortly after the v0.1.27 release.
2025-07-04 23:41:47 +02:00
lebaudantoine 8929ec5714 ♻️(frontend) use LiveKit mobile detection in noise reduction hook
Replace custom mobile browser detection with livekit-client's built-in
isMobile() function for consistency and better reliability.
2025-07-02 11:07:53 +02:00
soyouzpanda 26045bbffa 🔧(backend) support _FILE for secret environment variables
Allow configuration variables that handles secrets, like
`DJANGO_SECRET_KEY` to be able to read from a file which is given
through an environment file.

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

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

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

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

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

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

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

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

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

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

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

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

Remove failing test cases affected by stricter validation.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Prioritize simple, maintainable implementation over complex code that would
form subgroups based on user preferences. Note: Changes individual email
sending instead of batch processing, which may impact performance for large
groups but is acceptable for typical recording access patterns.
2025-04-23 15:36:44 +02:00
lebaudantoine df55fb2424 🐛(backend) rename copy-pasted unit tests
Fix inconsistent test naming resulting from copy-pasted examples. Rename
tests to properly reflect their actual testing purpose and improve code
maintainability.
2025-04-23 14:17:09 +02:00
lebaudantoine ec114808b2 (frontend) add hook to sync user preferences
Implement new hook that synchronizes user language and timezone preferences
from frontend to backend. Ensures consistent localization across and
notifications. Note: Current implementation works but auth code requires
future refactoring.
2025-04-23 14:17:09 +02:00
lebaudantoine 7f0e866eac ♻️(frontend) rename isLoading to isConfigLoading for clarity
Rename generic "isLoading" variable to more specific "isConfigLoading" to
accurately reflect its purpose. Improves code readability by making state
variable names more explicit.
2025-04-23 14:17:09 +02:00
lebaudantoine 8e0e286bc4 (backend) serialize user language and timezone for frontend use
Add user language and timezone to serialized user data to enable frontend
customization. Allows backend email notifications to respect user's
localization preferences for improved communication relevance.
2025-04-23 14:17:09 +02:00
lebaudantoine ee9148fe9f 🌐(backend) add and compile Dutch translations for backend
Add Dutch language translations for backend text strings and compile
translation files for production use. Improves localization support for
Dutch-speaking users.
2025-04-23 14:17:09 +02:00
lebaudantoine c863bc7456 🌐(backend) add and compile English translations for backend
Add English language translations for backend text strings and compile
translation files for production use. Improves localization support for
English-speaking users.
2025-04-23 14:17:09 +02:00
lebaudantoine eab361326f 🌐(backend) add and compile French translations for backend
Add French language translations for backend text strings and compile
translation files for production use. Improves localization support for
French-speaking users.
2025-04-23 14:17:09 +02:00
lebaudantoine 86c82f584c 🌐(backend) convert translation tags to blocktrans for quoted strings
Replace simple trans tags with blocktrans tags in download instructions
to properly handle quoted text in translations. Ensures quotes within
translated strings are correctly preserved during localization.
2025-04-23 14:17:09 +02:00
lebaudantoine af6ac954e9 🌐(backend) add Dutch language support to backend
Add Dutch (nl) language configuration to backend to match available frontend
languages. Ensures consistent language options across the entire application.
2025-04-23 14:17:09 +02:00
lebaudantoine 35cc6cd902 🌐(backend) regenerate translation files including email templates
Update translation files to include previously missed strings from email
templates. Ensures complete localization coverage across all backend
components including notification emails.
2025-04-23 14:17:09 +02:00
lebaudantoine 9cb7b0f154 ️(frontend) improve mic button accessibility in participant list
Add aria-label to microphone buttons in participant list to clearly indicate
mute functionality for current user and other participants. Set
aria-hidden on SVG icons to prevent redundant screen reader announcements.
2025-04-23 10:02:41 +02:00
lebaudantoine d105603a9b 🔖(helm) bump Helm chart version after job name modifications
Increment Helm chart version to reflect changes to backend job component
naming. Ensures proper versioning of configuration changes in deployment
pipeline.
2025-04-22 18:26:40 +02:00
lebaudantoine d2da1e37b9 🚚(helm) specify unique component names for all backend jobs
Update backend job configurations to use distinct component names instead of
sharing names with deployments. Prevents conflicts during cluster updates
and migrations that were causing unexpected behavior. Improves deployment
reliability and resource identification.
2025-04-22 18:26:40 +02:00
lebaudantoine 0206762e6d 🚚(helm) rename migration job to more explicit 'backend_job_migrate'
Rename backend migration job to a more descriptive name that clearly
indicates its purpose. Improves code clarity and makes deployment
configuration more self-documenting.
2025-04-22 18:26:40 +02:00
lebaudantoine c4fe341a74 🔧(backend) make data directory location configurable via env var
Add environment variable to control data directory location when building
outside of Docker. Improves flexibility for non-containerized deployments
where storing data at filesystem root is inappropriate or undesirable.
2025-04-22 16:25:59 +02:00
265 changed files with 8118 additions and 5835 deletions
+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
+1 -1
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">
+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"
+56
View File
@@ -0,0 +1,56 @@
# ---- 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"
RUN npm run build
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

+131 -96
View File
@@ -2,7 +2,7 @@
This document is a step-by-step guide that describes how to install Visio on a k8s cluster without AI features.
## Prerequisites
## Prerequisites for a kubernetes setup
- k8s cluster with an nginx-ingress controller
- an OIDC provider (if you don't have one, we will provide an example)
@@ -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,7 +110,7 @@ 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 ?
@@ -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
@@ -232,99 +243,123 @@ meet <none> meet.127.0.0.1.nip.io localhost 80, 44
meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 443 52m
```
You can use Visio on https://meet.127.0.0.1.nip.io. The provisioning user in keycloak is meet/meet.
You can use Visio on https://meet.127.0.0.1.nip.io from the local device. The provisioning user in keycloak is meet/meet.
## All options
These are the environmental options available on meet backend.
| Option | Description | default |
| ----------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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 | [] |
| LOGIN_REDIRECT_URL | Login redirect URL | |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect URL for failure | |
| LOGOUT_REDIRECT_URL | URL to redirect to on logout | |
| ALLOW_LOGOUT_GET_METHOD | Allow logout through GET method | true |
| LIVEKIT_API_KEY | LiveKit API key | |
| LIVEKIT_API_SECRET | LiveKit API secret | |
| LIVEKIT_API_URL | LiveKit API URL | |
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| RECORDING_ENABLE | Record meeting option | false |
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
| RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser |
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events | false |
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
| BREVO_API_KEY | Brevo API key for marketing emails | |
| BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] |
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} |
| BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 |
| LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby |
| LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 |
| LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 |
| LOBBY_ACCEPTED_TIMEOUT | Lobby accept timeout in seconds | 21600 (6 hours) |
| LOBBY_NOTIFICATION_TYPE | Lobby notification types | participantWaiting |
| LOBBY_COOKIE_NAME | Lobby cookie name | lobbyParticipantId |
| ROOM_CREATION_CALLBACK_CACHE_TIMEOUT | Room creation callback cache timeout | 600 (10 minutes) |
| ROOM_TELEPHONY_ENABLED | Enable SIP telephony feature | false |
| ROOM_TELEPHONY_PIN_LENGTH | Telephony PIN length | 10 |
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Telephony PIN maximum retries | 5 |
+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)
+1
View File
@@ -40,6 +40,7 @@ def get_frontend_configuration(request):
"recording": {
"is_enabled": settings.RECORDING_ENABLE,
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
},
}
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):
+36 -9
View File
@@ -1,9 +1,12 @@
"""Client serializers for the Meet core app."""
import uuid
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
from core import models, utils
@@ -11,9 +14,11 @@ from core import models, utils
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
timezone = TimeZoneSerializerField()
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name"]
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"]
@@ -34,10 +39,10 @@ class ResourceAccessSerializerMixin:
# Update
self.instance
and (
data["role"] == models.RoleChoices.OWNER
data.get("role") == models.RoleChoices.OWNER
and not self.instance.resource.is_owner(user)
or self.instance.role == models.RoleChoices.OWNER
and not self.instance.user == user
and self.instance.user != user
)
) or (
# Create
@@ -55,7 +60,9 @@ class ResourceAccessSerializerMixin:
request = self.context.get("request", None)
user = getattr(request, "user", None)
if not (user and user.is_authenticated and resource.is_administrator(user)):
if not (
user and user.is_authenticated and resource.is_administrator_or_owner(user)
):
raise PermissionDenied(
_("You must be administrator or owner of a room to add accesses to it.")
)
@@ -115,9 +122,11 @@ class RoomSerializer(serializers.ModelSerializer):
return output
role = instance.get_role(request.user)
is_admin = models.RoleChoices.check_administrator_role(role)
is_admin_or_owner = models.RoleChoices.check_administrator_role(
role
) or models.RoleChoices.check_owner_role(role)
if role is not None:
if is_admin_or_owner:
access_serializer = NestedResourceAccessSerializer(
instance.accesses.select_related("resource", "user").all(),
context=self.context,
@@ -125,7 +134,7 @@ class RoomSerializer(serializers.ModelSerializer):
)
output["accesses"] = access_serializer.data
if not is_admin:
if not is_admin_or_owner:
del output["configuration"]
should_access_room = (
@@ -144,7 +153,7 @@ class RoomSerializer(serializers.ModelSerializer):
room_id=room_id, user=request.user, username=username
)
output["is_administrable"] = is_admin
output["is_administrable"] = is_admin_or_owner
return output
@@ -156,7 +165,17 @@ class RecordingSerializer(serializers.ModelSerializer):
class Meta:
model = models.Recording
fields = ["id", "room", "created_at", "updated_at", "status", "mode", "key"]
fields = [
"id",
"room",
"created_at",
"updated_at",
"status",
"mode",
"key",
"is_expired",
"expired_at",
]
read_only_fields = fields
@@ -202,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,18 @@
# Generated by Django 5.1.8 on 2025-04-22 14:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0012_alter_room_access_level'),
]
operations = [
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('nl-nl', 'Dutch'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
@@ -0,0 +1,45 @@
# Generated by Django 5.1.9 on 2025-05-13 08:22
import secrets
from django.db import migrations, models
def generate_pin_for_rooms(apps, schema_editor):
"""Generate unique 10-digit PIN codes for existing rooms.
The PIN code is required for SIP telephony features.
"""
Room = apps.get_model('core', 'Room')
rooms_without_pin_code = Room.objects.filter(pin_code__isnull=True)
existing_pins = set(Room.objects.values_list('pin_code', flat=True))
length = 10
def generate_pin_code():
while True:
pin_code = str(secrets.randbelow(10 ** length)).zfill(length)
if pin_code not in existing_pins:
return pin_code
for room in rooms_without_pin_code:
room.pin_code = generate_pin_code()
room.save()
class Migration(migrations.Migration):
dependencies = [
('core', '0013_alter_user_language'),
]
operations = [
migrations.AddField(
model_name='room',
name='pin_code',
field=models.CharField(blank=True, help_text='Unique n-digit code that identifies this room in telephony mode.', max_length=None, null=True, unique=True, verbose_name='Room PIN code'),
),
migrations.RunPython(
generate_pin_for_rooms,
reverse_code=migrations.RunPython.noop
),
]
+82 -10
View File
@@ -2,9 +2,11 @@
Declare and configure the models for the Meet core application
"""
import secrets
import uuid
from datetime import datetime, timedelta
from logging import getLogger
from typing import List
from typing import List, Optional
from django.conf import settings
from django.contrib.auth import models as auth_models
@@ -12,7 +14,7 @@ from django.contrib.auth.base_user import AbstractBaseUser
from django.core import mail, validators
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import models
from django.utils.functional import lazy
from django.utils import timezone
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
@@ -33,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):
@@ -162,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."),
@@ -286,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."""
@@ -381,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"
@@ -391,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.
@@ -405,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
@@ -412,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."""
@@ -601,6 +645,34 @@ class Recording(BaseModel):
return f"{settings.RECORDING_OUTPUT_FOLDER}/{self.id}.{self.extension}"
@property
def expired_at(self) -> Optional[datetime]:
"""
Calculate the expiration date based on created_at and RECORDING_EXPIRATION_DAYS.
Returns None if no expiration is configured.
Note: This is a naive and imperfect implementation since recordings are actually
saved to the bucket after created_at timestamp is set. The actual expiration
will be determined by the bucket lifecycle policy which operates on the object's
timestamp in the storage system, not this value.
"""
if not settings.RECORDING_EXPIRATION_DAYS:
return None
return self.created_at + timedelta(days=settings.RECORDING_EXPIRATION_DAYS)
@property
def is_expired(self) -> bool:
"""
Determine if the recording has expired by comparing expired_at with current UTC time.
Returns False if no expiration is configured or if expiration date is in the future.
"""
if not self.expired_at:
return False
return self.expired_at < timezone.now()
class RecordingAccess(BaseAccess):
"""Relation model to give access to a recording for a user or a team with a role."""
@@ -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
@@ -44,49 +44,69 @@ class NotificationService:
page in the frontend where they can access their specific recording.
"""
owner_accesses = models.RecordingAccess.objects.select_related("user").filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
owner_accesses = (
models.RecordingAccess.objects.select_related("user")
.filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
)
.order_by("created_at")
)
if not owner_accesses:
logger.error("No owner found for recording %s", recording.id)
return False
language = get_language()
context = {
"brandname": settings.EMAIL_BRAND_NAME,
"support_email": settings.EMAIL_SUPPORT_EMAIL,
"logo_img": settings.EMAIL_LOGO_IMG,
"domain": settings.EMAIL_DOMAIN,
"room_name": recording.room.name,
"recording_date": recording.created_at.strftime("%A %d %B %Y"),
"recording_time": recording.created_at.strftime("%H:%M"),
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"link": f"{settings.SCREEN_RECORDING_BASE_URL}/{recording.id}",
}
emails = [access.user.email for access in owner_accesses]
has_failures = False
with override(language):
msg_html = render_to_string("mail/html/screen_recording.html", context)
msg_plain = render_to_string("mail/text/screen_recording.txt", context)
subject = str(_("Your recording is ready")) # Force translation
try:
send_mail(
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
emails,
html_message=msg_html,
fail_silently=False,
# We process emails individually rather than in batch because:
# 1. Each email requires personalization (timezone, language)
# 2. The number of recipients per recording is typically small (not thousands)
for access in owner_accesses:
user = access.user
language = user.language or get_language()
with override(language):
personalized_context = {
"recording_date": recording.created_at.astimezone(
user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
user.timezone
).strftime("%H:%M"),
**context,
}
msg_html = render_to_string(
"mail/html/screen_recording.html", personalized_context
)
except smtplib.SMTPException as exception:
logger.error("notification could not be sent: %s", exception)
return False
msg_plain = render_to_string(
"mail/text/screen_recording.txt", personalized_context
)
subject = str(_("Your recording is ready")) # Force translation
return True
try:
send_mail(
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
[user.email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("notification could not be sent: %s", exception)
has_failures = True
return not has_failures
@staticmethod
def _notify_summary_service(recording):
+2 -2
View File
@@ -86,7 +86,7 @@ class MinioParser:
# pylint: disable=line-too-long
self._filepath_regex = re.compile(
r"(?P<url_encoded_folder_path>(?:[^%]+%2F)*)?(?P<recording_id>[0-9a-fA-F\-]{36})\.(?P<extension>[a-zA-Z0-9]+)"
r"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?(?P<recording_id>[0-9a-fA-F\-]{36})\.(?P<extension>[a-zA-Z0-9]+)"
)
@staticmethod
@@ -123,7 +123,7 @@ class MinioParser:
f"Invalid bucket: expected {self._bucket_name}, got {event_data.bucket_name}"
)
if not event_data.filetype in self._allowed_filetypes:
if event_data.filetype not in self._allowed_filetypes:
raise InvalidFileTypeError(
f"Invalid file type, expected {self._allowed_filetypes},"
f"got '{event_data.filetype}'"
@@ -17,7 +17,6 @@ class WorkerServiceConfig:
output_folder: str
server_configurations: Dict[str, Any]
verify_ssl: Optional[bool]
bucket_args: Optional[dict]
@classmethod
@@ -29,7 +28,6 @@ class WorkerServiceConfig:
return cls(
output_folder=settings.RECORDING_OUTPUT_FOLDER,
server_configurations=settings.LIVEKIT_CONFIGURATION,
verify_ssl=settings.RECORDING_VERIFY_SSL,
bucket_args={
"endpoint": settings.AWS_S3_ENDPOINT_URL,
"access_key": settings.AWS_S3_ACCESS_KEY_ID,
+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.
+1 -2
View File
@@ -14,7 +14,6 @@ from django.core.cache import cache
from asgiref.sync import async_to_sync
from livekit.api import ( # pylint: disable=E0611
ListRoomsRequest,
LiveKitAPI,
SendDataRequest,
TwirpError,
)
@@ -347,7 +346,7 @@ class LobbyService:
"type": settings.LOBBY_NOTIFICATION_TYPE,
}
lkapi = LiveKitAPI(**settings.LIVEKIT_CONFIGURATION)
lkapi = utils.create_livekit_client()
try:
room_response = await lkapi.room.list_rooms(
+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
@@ -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")
@@ -4,6 +4,7 @@ Test event notification.
# pylint: disable=E1128,W0621,W0613,W0212
import datetime
import smtplib
from unittest import mock
@@ -74,6 +75,7 @@ def test_notify_external_services_unknown_mode(caplog):
assert f"Unknown recording mode unknown for recording {recording.id}" in caplog.text
# pylint: disable=too-many-locals
def test_notify_user_by_email_success(mocked_current_site, settings):
"""Test successful email notification to recording owners."""
settings.EMAIL_BRAND_NAME = "ACME"
@@ -84,15 +86,28 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
recording = factories.RecordingFactory(room__name="Conference Room A")
owners = [
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
).user,
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
).user,
]
owner_emails = [owner.email for owner in owners]
# Fix recording test to avoid flaky tests
recording.created_at = datetime.datetime(2023, 5, 15, 14, 30, 0)
french_user = factories.UserFactory(
email="franc@test.com", language="fr-fr", timezone="Europe/Paris"
)
dutch_user = factories.UserFactory(
email="berry@test.com", language="nl-nl", timezone="Europe/Amsterdam"
)
english_user = factories.UserFactory(
email="john@test.com", language="en-us", timezone="America/Phoenix"
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=french_user
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=dutch_user
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=english_user
)
# Create non-owner users to verify they don't receive emails
factories.UserRecordingAccessFactory(
@@ -108,30 +123,69 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
result = notification_service._notify_user_by_email(recording)
assert result is True
mock_send_mail.assert_called_once()
assert mock_send_mail.call_count == 3
subject, body, sender, recipients = mock_send_mail.call_args[0]
call_args_list = mock_send_mail.call_args_list
assert subject == "Your recording is ready"
# Verify email contains expected content
required_content = [
base_content = [
"ACME", # Brand name
"support@acme.com", # Support email
"https://acme.com/logo", # Logo URL
f"https://acme.com/recordings/{recording.id}", # Recording link
"Conference Room A", # Room name
recording.created_at.strftime("%A %d %B %Y"), # Formatted date
recording.created_at.strftime("%H:%M"), # Formatted time
]
for content in required_content:
assert content in body
# First call verification
subject1, body1, sender1, recipients1 = call_args_list[0][0]
assert subject1 == "Votre enregistrement est prêt"
assert sender == "notifications@acme.com"
# Verify email contains expected content
personalized_content1 = [
*base_content,
"2023-05-15", # Formatted date
"16:30", # Formatted time
"Votre enregistrement est prêt !", # Intro
]
for content in personalized_content1:
assert content in body1
# Verify all owners received the email (order-independent comparison)
assert sorted(recipients) == sorted(owner_emails)
assert recipients1 == ["franc@test.com"]
assert sender1 == "notifications@acme.com"
# Second call verification
subject2, body2, sender2, recipients2 = call_args_list[1][0]
assert subject2 == "Je opname is klaar"
# Verify second email content (if needed)
personalized_content2 = [
*base_content,
"2023-05-15", # Formatted date
"16:30", # Formatted time
"Je opname is klaar!", # Intro
]
for content in personalized_content2:
assert content in body2
assert recipients2 == ["berry@test.com"]
assert sender2 == "notifications@acme.com"
# Third call verification
subject3, body3, sender3, recipients3 = call_args_list[2][0]
assert subject3 == "Your recording is ready"
# Verify second email content (if needed)
personalized_content3 = [
*base_content,
"Conference Room A", # Room name
"2023-05-15", # Formatted date
"07:30", # Formatted time
"Your recording is ready!", # Intro
]
for content in personalized_content3:
assert content in body3
assert recipients3 == ["john@test.com"]
assert sender3 == "notifications@acme.com"
def test_notify_user_by_email_no_owners(mocked_current_site, caplog):
@@ -153,6 +207,9 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
notification_service = NotificationService()
@@ -163,5 +220,5 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
result = notification_service._notify_user_by_email(recording)
assert result is False
mock_send_mail.assert_called_once()
assert mock_send_mail.call_count == 2
assert "notification could not be sent:" in caplog.text
@@ -129,8 +129,11 @@ def test_validate_invalid_filetype(minio_parser):
@pytest.mark.parametrize(
"invalid_filepath",
[
"invalid_filepath",
"invalid_filepath", # totally invalid string
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
"46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing url_encoded_folder_path and extension
"", # empty string
"recording%2F46d1a1212426484d8fb309b5d886f7a8.ogg",
],
)
@@ -47,11 +47,12 @@ def test_api_recordings_list_authenticated_no_recording():
"role",
["administrator", "member", "owner"],
)
def test_api_recordings_list_authenticated_direct(role):
def test_api_recordings_list_authenticated_direct(role, settings):
"""
Authenticated users listing recordings, should only see the recordings
to which they are related.
"""
settings.RECORDING_EXPIRATIONS_DAYS = None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -89,6 +90,8 @@ def test_api_recordings_list_authenticated_direct(role):
},
"status": "initiated",
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"expired_at": None,
"is_expired": False,
}
@@ -20,7 +20,7 @@ from core.factories import RecordingFactory, UserFactory, UserRecordingAccessFac
pytestmark = pytest.mark.django_db
def test_api_documents_media_auth_unauthenticated():
def test_api_recordings_media_auth_unauthenticated():
"""
Test that unauthenticated requests to download media are rejected
"""
@@ -33,7 +33,7 @@ def test_api_documents_media_auth_unauthenticated():
assert response.status_code == 401
def test_api_documents_media_auth_wrong_path():
def test_api_recordings_media_auth_wrong_path():
"""
Test that media URLs with incorrect path structures are rejected
"""
@@ -51,7 +51,7 @@ def test_api_documents_media_auth_wrong_path():
assert response.status_code == 403
def test_api_documents_media_auth_unknown_recording():
def test_api_recordings_media_auth_unknown_recording():
"""
Test that requests for non-existent recordings are properly handled
"""
@@ -69,7 +69,7 @@ def test_api_documents_media_auth_unknown_recording():
assert response.status_code == 404
def test_api_documents_media_auth_no_abilities():
def test_api_recordings_media_auth_no_abilities():
"""
Test that users without any access permissions cannot download recordings
"""
@@ -88,7 +88,7 @@ def test_api_documents_media_auth_no_abilities():
assert response.status_code == 403
def test_api_documents_media_auth_wrong_abilities():
def test_api_recordings_media_auth_wrong_abilities():
"""
Test that users with insufficient role permissions cannot download recordings
"""
@@ -111,7 +111,7 @@ def test_api_documents_media_auth_wrong_abilities():
@pytest.mark.parametrize("wrong_status", ["initiated", "active", "failed_to_stop"])
def test_api_documents_media_auth_unsaved(wrong_status):
def test_api_recordings_media_auth_unsaved(wrong_status):
"""
Test that recordings that aren't in 'saved' status cannot be downloaded
"""
@@ -132,7 +132,7 @@ def test_api_documents_media_auth_unsaved(wrong_status):
assert response.status_code == 403
def test_api_documents_media_auth_mismatched_extension():
def test_api_recordings_media_auth_mismatched_extension():
"""
Test that requests with mismatched file extensions are rejected
"""
@@ -160,7 +160,7 @@ def test_api_documents_media_auth_mismatched_extension():
@pytest.mark.parametrize(
"wrong_extension", ["jpg", "txt", "mp3"], ids=["image", "text", "audio"]
)
def test_api_documents_media_auth_wrong_extension(wrong_extension):
def test_api_recordings_media_auth_wrong_extension(wrong_extension):
"""
Trying to download a recording with an unsupported extension should return
a validation error (400) with details about allowed extensions.
@@ -185,7 +185,7 @@ def test_api_documents_media_auth_wrong_extension(wrong_extension):
@pytest.mark.parametrize("mode", ["screen_recording", "transcript"])
def test_api_documents_media_auth_success_owner(mode):
def test_api_recordings_media_auth_success_owner(mode):
"""
Test downloading a recording when logged in and authorized.
Verifies S3 authentication headers and successful file retrieval.
@@ -235,7 +235,7 @@ def test_api_documents_media_auth_success_owner(mode):
@pytest.mark.parametrize("mode", ["screen_recording", "transcript"])
def test_api_documents_media_auth_success_administrator(mode):
def test_api_recordings_media_auth_success_administrator(mode):
"""
Test downloading a recording when logged in and authorized.
Verifies S3 authentication headers and successful file retrieval.
@@ -2,7 +2,10 @@
Test recordings API endpoints in the Meet core app: retrieve.
"""
import random
import pytest
from freezegun import freeze_time
from rest_framework.test import APIClient
from ...factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
@@ -61,8 +64,9 @@ def test_api_recording_retrieve_members():
}
def test_api_recording_retrieve_administrators():
def test_api_recording_retrieve_administrators(settings):
"""A user who is an administrator of a recording should be able to retrieve it."""
settings.RECORDING_EXPIRATION_DAYS = None
user = UserFactory()
recording = RecordingFactory()
@@ -91,11 +95,14 @@ def test_api_recording_retrieve_administrators():
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": None,
"is_expired": False,
}
def test_api_recording_retrieve_owners():
def test_api_recording_retrieve_owners(settings):
"""A user who is an owner of a recording should be able to retrieve it."""
settings.RECORDING_EXPIRATION_DAYS = None
user = UserFactory()
recording = RecordingFactory()
@@ -123,6 +130,87 @@ def test_api_recording_retrieve_owners():
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": None,
"is_expired": False,
}
@freeze_time("2023-01-15 12:00:00")
def test_api_recording_retrieve_compute_expiration_date_correctly(settings):
"""Test that the API returns the correct expiration date for a non-expired recording."""
settings.RECORDING_EXPIRATION_DAYS = 1
user = UserFactory()
recording = RecordingFactory()
UserRecordingAccessFactory(
recording=recording, user=user, role=random.choice(["administrator", "owner"])
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
room = recording.room
assert content == {
"id": str(recording.id),
"key": recording.key,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"created_at": "2023-01-15T12:00:00Z",
"updated_at": "2023-01-15T12:00:00Z",
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": "2023-01-16T12:00:00Z",
"is_expired": False, # Ensure the recording is still valid and hasn't expired
}
def test_api_recording_retrieve_expired(settings):
"""Test that the API returns the correct expiration date and flag for an expired recording."""
settings.RECORDING_EXPIRATION_DAYS = 2
user = UserFactory()
with freeze_time("2023-01-15 12:00:00"):
recording = RecordingFactory()
UserRecordingAccessFactory(
recording=recording, user=user, role=random.choice(["administrator", "owner"])
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
room = recording.room
assert content == {
"id": str(recording.id),
"key": recording.key,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"created_at": "2023-01-15T12:00:00Z",
"updated_at": "2023-01-15T12:00:00Z",
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": "2023-01-17T12:00:00Z",
"is_expired": True, # Ensure the recording has expired
}
@@ -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")
@@ -132,18 +132,18 @@ def test_request_entry_with_existing_participants(settings):
# Add two participants already waiting in the lobby
cache.set(
f"mocked-cache-prefix_{room.id}_participant1",
f"mocked-cache-prefix_{room.id}_2f7f162fe7d1421b90e702bfbfbf8def",
{
"id": "participant1",
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
},
)
cache.set(
f"mocked-cache-prefix_{room.id}_participant2",
f"mocked-cache-prefix_{room.id}_f4ca3ab8a6c04ad88097b8da33f60f10",
{
"id": "participant2",
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
"username": "user2",
"status": "accepted",
"color": "#654321",
@@ -257,7 +257,9 @@ def test_request_entry_authenticated_user_public_room(settings):
with (
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(
LobbyService, "_get_or_create_participant_id", return_value="123"
LobbyService,
"_get_or_create_participant_id",
return_value="2f7f162fe7d1421b90e702bfbfbf8def",
),
mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
@@ -274,11 +276,11 @@ def test_request_entry_authenticated_user_public_room(settings):
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "123"
assert cookie.value == "2f7f162fe7d1421b90e702bfbfbf8def"
# Verify response content matches expected structure and values
assert response.json() == {
"id": "123",
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"username": "test_user",
"status": "accepted",
"color": "mocked-color",
@@ -300,9 +302,9 @@ def test_request_entry_waiting_participant_public_room(settings):
# Add a waiting participant to the room's lobby cache
cache.set(
f"mocked-cache-prefix_{room.id}_participant1",
f"mocked-cache-prefix_{room.id}_2f7f162fe7d1421b90e702bfbfbf8def",
{
"id": "participant1",
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
@@ -310,7 +312,7 @@ def test_request_entry_waiting_participant_public_room(settings):
)
# Simulate a browser with existing participant cookie
client.cookies.load({"mocked-cookie": "participant1"})
client.cookies.load({"mocked-cookie": "2f7f162fe7d1421b90e702bfbfbf8def"})
with (
mock.patch.object(LobbyService, "notify_participants", return_value=None),
@@ -328,11 +330,11 @@ def test_request_entry_waiting_participant_public_room(settings):
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "participant1"
assert cookie.value == "2f7f162fe7d1421b90e702bfbfbf8def"
# Verify response content matches expected structure and values
assert response.json() == {
"id": "participant1",
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"username": "user1",
"status": "accepted",
"color": "#123456",
@@ -379,7 +381,7 @@ def test_allow_participant_to_enter_anonymous():
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "test-id", "allow_entry": True},
{"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def", "allow_entry": True},
)
assert response.status_code == 401
@@ -394,7 +396,7 @@ def test_allow_participant_to_enter_non_owner():
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "test-id", "allow_entry": True},
{"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def", "allow_entry": True},
)
assert response.status_code == 403
@@ -412,7 +414,7 @@ def test_allow_participant_to_enter_public_room():
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "test-id", "allow_entry": True},
{"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def", "allow_entry": True},
)
assert response.status_code == 404
@@ -435,9 +437,9 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
cache.set(
f"mocked-cache-prefix_{room.id!s}_participant1",
f"mocked-cache-prefix_{room.id!s}_2f7f162fe7d1421b90e702bfbfbf8def",
{
"id": "test-id",
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"status": "waiting",
"username": "foo",
"color": "123",
@@ -446,13 +448,18 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "participant1", "allow_entry": allow_entry},
{
"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def",
"allow_entry": allow_entry,
},
)
assert response.status_code == 200
assert response.json() == {"message": "Participant was updated."}
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_participant1")
participant_data = cache.get(
f"mocked-cache-prefix_{room.id!s}_2f7f162fe7d1421b90e702bfbfbf8def"
)
assert participant_data.get("status") == updated_status
@@ -468,12 +475,14 @@ def test_allow_participant_to_enter_participant_not_found(settings):
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_test-id")
participant_data = cache.get(
f"mocked-cache-prefix_{room.id!s}_2f7f162fe7d1421b90e702bfbfbf8def"
)
assert participant_data is None
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "test-id", "allow_entry": True},
{"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def", "allow_entry": True},
)
assert response.status_code == 404
@@ -563,18 +572,18 @@ def test_list_waiting_participants_success(settings):
# Add participants in the lobby
cache.set(
f"mocked-cache-prefix_{room.id}_participant1",
f"mocked-cache-prefix_{room.id}_2f7f162fe7d1421b90e702bfbfbf8def",
{
"id": "participant1",
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
},
)
cache.set(
f"mocked-cache-prefix_{room.id}_participant2",
f"mocked-cache-prefix_{room.id}_f4ca3ab8a6c04ad88097b8da33f60f10",
{
"id": "participant2",
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
"username": "user2",
"status": "waiting",
"color": "#654321",
@@ -588,13 +597,13 @@ def test_list_waiting_participants_success(settings):
participants = response.json().get("participants")
assert sorted(participants, key=lambda p: p["id"]) == [
{
"id": "participant1",
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
},
{
"id": "participant2",
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
"username": "user2",
"status": "waiting",
"color": "#654321",
@@ -336,23 +336,22 @@ def test_api_rooms_retrieve_authenticated():
"url": "test_url_value",
}
)
def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, settings):
"""
Users who are members of a room should be allowed to see related users.
Users who are members of a room should not be allowed to see related users.
"""
settings.TIME_ZONE = "UTC"
user = UserFactory()
other_user = UserFactory()
room = RoomFactory()
user_access = UserResourceAccessFactory(resource=room, user=user, role="member")
other_user_access = UserResourceAccessFactory(
resource=room, user=other_user, role="member"
)
UserResourceAccessFactory(resource=room, user=user, role="member")
UserResourceAccessFactory(resource=room, user=other_user, role="member")
client = APIClient()
client.force_login(user)
with django_assert_num_queries(4):
with django_assert_num_queries(3):
response = client.get(
f"/api/v1.0/rooms/{room.id!s}/",
)
@@ -360,33 +359,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
assert response.status_code == 200
content_dict = response.json()
assert sorted(content_dict.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
"user": {
"id": str(user_access.user.id),
"email": user_access.user.email,
"full_name": user_access.user.full_name,
"short_name": user_access.user.short_name,
},
"resource": str(room.id),
"role": user_access.role,
},
{
"id": str(other_user_access.id),
"user": {
"id": str(other_user_access.user.id),
"email": other_user_access.user.email,
"full_name": other_user_access.user.full_name,
"short_name": other_user_access.user.short_name,
},
"resource": str(room.id),
"role": other_user_access.role,
},
],
key=lambda x: x["id"],
)
assert "accesses" not in content_dict
expected_name = str(room.id)
assert content_dict == {
@@ -415,11 +388,14 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
"url": "test_url_value",
}
)
def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries):
def test_api_rooms_retrieve_administrators(
mock_token, django_assert_num_queries, settings
):
"""
A user who is an administrator or owner of a room should be allowed
to see related users.
"""
settings.TIME_ZONE = "UTC"
user = UserFactory()
other_user = UserFactory()
room = RoomFactory()
@@ -448,6 +424,8 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
"email": other_user_access.user.email,
"full_name": other_user_access.user.full_name,
"short_name": other_user_access.user.short_name,
"timezone": "UTC",
"language": other_user_access.user.language,
},
"resource": str(room.id),
"role": other_user_access.role,
@@ -459,6 +437,8 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
"email": user_access.user.email,
"full_name": user_access.user.full_name,
"short_name": user_access.user.short_name,
"timezone": "UTC",
"language": user_access.user.language,
},
"resource": str(room.id),
"role": user_access.role,
@@ -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"},
+11 -20
View File
@@ -776,9 +776,10 @@ 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):
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success_no_room(mock_create_livekit_client, lobby_service):
"""Test the notify_participants method when the LiveKit room doesn't exist yet."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
@@ -794,15 +795,11 @@ def test_notify_participants_success_no_room(mock_livekit_api, lobby_service):
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_livekit_api.return_value = mock_api_instance
mock_create_livekit_client.return_value = mock_api_instance
# 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()
@@ -813,8 +810,8 @@ def test_notify_participants_success_no_room(mock_livekit_api, lobby_service):
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.services.lobby.LiveKitAPI")
def test_notify_participants_success(mock_livekit_api, lobby_service):
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success(mock_create_livekit_client, lobby_service):
"""Test successful participant notification."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
@@ -830,14 +827,11 @@ def test_notify_participants_success(mock_livekit_api, lobby_service):
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_livekit_api.return_value = mock_api_instance
mock_create_livekit_client.return_value = mock_api_instance
# Call the function
lobby_service.notify_participants(room.id)
# Verify the API was called correctly
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
@@ -855,15 +849,15 @@ def test_notify_participants_success(mock_livekit_api, lobby_service):
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.services.lobby.LiveKitAPI")
def test_notify_participants_error(mock_livekit_api, lobby_service):
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_error(mock_create_livekit_client, lobby_service):
"""Test participant notification with API error."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock(
side_effect=TwirpError(msg="test error", code=123)
side_effect=TwirpError(msg="test error", code=123, status=123)
)
class MockResponse:
@@ -874,7 +868,7 @@ def test_notify_participants_error(mock_livekit_api, lobby_service):
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_livekit_api.return_value = mock_api_instance
mock_create_livekit_client.return_value = mock_api_instance
# Call the function and expect an exception
with pytest.raises(
@@ -882,9 +876,6 @@ def test_notify_participants_error(mock_livekit_api, lobby_service):
):
lobby_service.notify_participants(room.id)
# Verify the API was called correctly
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
@@ -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."""
+5 -1
View File
@@ -103,8 +103,10 @@ def test_api_users_retrieve_me_anonymous():
}
def test_api_users_retrieve_me_authenticated():
def test_api_users_retrieve_me_authenticated(settings):
"""Authenticated users should be able to retrieve their own user via the "/users/me" path."""
settings.TIME_ZONE = "UTC"
user = factories.UserFactory()
client = APIClient()
@@ -121,6 +123,8 @@ def test_api_users_retrieve_me_authenticated():
"email": user.email,
"full_name": user.full_name,
"short_name": user.short_name,
"language": user.language,
"timezone": "UTC",
}
+178 -6
View File
@@ -2,6 +2,12 @@
Unit tests for the Room model
"""
# pylint: disable=W0613
import secrets
from logging import Logger
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
@@ -96,7 +102,7 @@ def test_models_rooms_access_rights_none(django_assert_num_queries):
with django_assert_num_queries(0):
assert room.get_role(None) is None
with django_assert_num_queries(0):
assert room.is_administrator(None) is False
assert room.is_administrator_or_owner(None) is False
with django_assert_num_queries(0):
assert room.is_owner(None) is False
@@ -109,7 +115,7 @@ def test_models_rooms_access_rights_anonymous(django_assert_num_queries):
with django_assert_num_queries(0):
assert room.get_role(user) is None
with django_assert_num_queries(0):
assert room.is_administrator(user) is False
assert room.is_administrator_or_owner(user) is False
with django_assert_num_queries(0):
assert room.is_owner(user) is False
@@ -122,7 +128,7 @@ def test_models_rooms_access_rights_authenticated(django_assert_num_queries):
with django_assert_num_queries(1):
assert room.get_role(user) is None
with django_assert_num_queries(1):
assert room.is_administrator(user) is False
assert room.is_administrator_or_owner(user) is False
with django_assert_num_queries(1):
assert room.is_owner(user) is False
@@ -135,7 +141,7 @@ def test_models_rooms_access_rights_member_direct(django_assert_num_queries):
with django_assert_num_queries(1):
assert room.get_role(user) == "member"
with django_assert_num_queries(1):
assert room.is_administrator(user) is False
assert room.is_administrator_or_owner(user) is False
with django_assert_num_queries(1):
assert room.is_owner(user) is False
@@ -148,7 +154,7 @@ def test_models_rooms_access_rights_administrator_direct(django_assert_num_queri
with django_assert_num_queries(1):
assert room.get_role(user) == "administrator"
with django_assert_num_queries(1):
assert room.is_administrator(user) is True
assert room.is_administrator_or_owner(user) is True
with django_assert_num_queries(1):
assert room.is_owner(user) is False
@@ -161,7 +167,7 @@ def test_models_rooms_access_rights_owner_direct(django_assert_num_queries):
with django_assert_num_queries(1):
assert room.get_role(user) == "owner"
with django_assert_num_queries(1):
assert room.is_administrator(user) is True
assert room.is_administrator_or_owner(user) is True
with django_assert_num_queries(1):
assert room.is_owner(user) is True
@@ -175,3 +181,169 @@ def test_models_rooms_is_public_property():
# Test non-public room
private_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
assert private_room.is_public is False
@mock.patch.object(Room, "generate_unique_pin_code")
def test_telephony_disabled_skips_pin_generation(
mock_generate_unique_pin_code, settings
):
"""Telephony disabled should not generate pin codes."""
settings.ROOM_TELEPHONY_ENABLED = False
room = RoomFactory()
mock_generate_unique_pin_code.assert_not_called()
assert room.pin_code is None
def test_default_and_custom_pin_length(settings):
"""Pin codes should be created with correct configured length."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
# Assert default value is 10 for collision reasons
assert len(room.pin_code) == 10
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
room = RoomFactory()
# Assert custom size
assert len(room.pin_code) == 5
def test_room_updates_preserve_pin_code(settings):
"""Room updates should preserve existing pin code."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
# Store the original pin code to compare after updates
previous_pin_code = room.pin_code
# If this method is called, it would indicate the pin is being regenerated unnecessarily
with mock.patch.object(
Room, "generate_unique_pin_code"
) as mock_generate_unique_pin_code:
# Explicitly call save to persist the changes to the room
room.slug = "aaa-aaaa-aaa"
room.save()
assert room.pin_code == previous_pin_code
mock_generate_unique_pin_code.assert_not_called()
@pytest.mark.parametrize("is_telephony_enabled", [True, False])
def test_manual_pin_code_updates(is_telephony_enabled, settings):
"""Manual pin code changes should persist regardless of telephony setting."""
settings.ROOM_TELEPHONY_ENABLED = is_telephony_enabled
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
room = RoomFactory()
assert room.pin_code != "12345"
room.pin_code = "12345"
room.save()
assert room.pin_code == "12345"
# No data validation when manual updates are made
room.pin_code = "123"
room.save()
assert room.pin_code == "123"
def test_pin_code_uniqueness(settings):
"""Duplicate pin codes should raise validation error."""
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
RoomFactory(pin_code="12345")
with pytest.raises(ValidationError) as excinfo:
RoomFactory(pin_code="12345")
assert "Room with this Room PIN code already exists." in str(excinfo.value)
@pytest.mark.parametrize("invalid_length", [0, 1, 2, 3])
def test_pin_code_minimum_length(invalid_length, settings):
"""Pin codes should enforce minimum length for security."""
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PIN_LENGTH = 4
# Assert no exception is raised with a valid length
RoomFactory()
settings.ROOM_TELEPHONY_PIN_LENGTH = invalid_length
with pytest.raises(ValueError) as excinfo:
RoomFactory()
assert "PIN code length must be at least 4 digits for minimal security" in str(
excinfo.value
)
@mock.patch.object(Logger, "warning")
@mock.patch.object(secrets, "randbelow", return_value=12345)
def test_pin_generation_max_retries(mock_randbelow, mock_logger, settings):
"""Pin generation should give up after max retries."""
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
RoomFactory(pin_code="12345")
# Assert default max retries is low, 5
room1 = RoomFactory()
assert mock_randbelow.call_count == 5
assert room1.pin_code is None
mock_logger.assert_called_once_with(
"Failed to generate unique PIN code of length %s after %s attempts", 5, 5
)
mock_logger.reset_mock()
mock_randbelow.reset_mock()
settings.ROOM_TELEPHONY_PIN_MAX_RETRIES = 3
room2 = RoomFactory()
assert mock_randbelow.call_count == 3
assert room2.pin_code is None
mock_logger.assert_called_once_with(
"Failed to generate unique PIN code of length %s after %s attempts", 5, 3
)
@mock.patch.object(secrets, "randbelow", return_value=12345)
def test_pin_code_zero_padding(mock_randbelow, settings):
"""Pin codes should be zero-padded to meet required length."""
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PIN_LENGTH = 10
room = RoomFactory()
assert room.pin_code == "0000012345"
@mock.patch.object(secrets, "randbelow", return_value=12345)
def test_pin_generation_upper_bound(mock_randbelow, settings):
"""Random number generator should use correct upper bound based on pin length."""
settings.ROOM_TELEPHONY_ENABLED = False
room = RoomFactory()
room.generate_unique_pin_code(length=5)
# Assert called with the right exclusive upper bound, 10^5
mock_randbelow.assert_called_with(100000)
+62
View File
@@ -0,0 +1,62 @@
"""
Test utils functions
"""
from unittest import mock
from core.utils import create_livekit_client
@mock.patch("asyncio.get_running_loop")
@mock.patch("core.utils.LiveKitAPI")
def test_create_livekit_client_ssl_enabled(
mock_livekit_api, mock_get_running_loop, settings
):
"""Test LiveKitAPI client creation with SSL verification enabled."""
mock_get_running_loop.return_value = mock.MagicMock()
settings.LIVEKIT_VERIFY_SSL = True
create_livekit_client()
mock_livekit_api.assert_called_once_with(
**settings.LIVEKIT_CONFIGURATION, session=None
)
@mock.patch("core.utils.aiohttp.ClientSession")
@mock.patch("asyncio.get_running_loop")
@mock.patch("core.utils.LiveKitAPI")
def test_create_livekit_client_ssl_disabled(
mock_livekit_api, mock_get_running_loop, mock_client_session, settings
):
"""Test LiveKitAPI client creation with SSL verification disabled."""
mock_get_running_loop.return_value = mock.MagicMock()
mock_session_instance = mock.MagicMock()
mock_client_session.return_value = mock_session_instance
settings.LIVEKIT_VERIFY_SSL = False
create_livekit_client()
mock_livekit_api.assert_called_once_with(
**settings.LIVEKIT_CONFIGURATION, session=mock_session_instance
)
@mock.patch("asyncio.get_running_loop")
@mock.patch("core.utils.LiveKitAPI")
def test_create_livekit_client_custom_configuration(
mock_livekit_api, mock_get_running_loop, settings
):
"""Test LiveKitAPI client creation with custom configuration."""
settings.LIVEKIT_VERIFY_SSL = True
mock_get_running_loop.return_value = mock.MagicMock()
custom_configuration = {
"api_key": "mock_key",
"api_secret": "mock_secret",
"url": "http://mock-url.com",
}
create_livekit_client(custom_configuration)
mock_livekit_api.assert_called_once_with(**custom_configuration, session=None)
+1 -1
View File
@@ -3,10 +3,10 @@
from django.conf import settings
from django.urls import include, path
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from rest_framework.routers import DefaultRouter
from core.api import get_frontend_configuration, viewsets
from core.authentication.urls import urlpatterns as oidc_urls
# - Main endpoints
router = DefaultRouter()
+17 -1
View File
@@ -13,8 +13,9 @@ 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 livekit.api import AccessToken, LiveKitAPI, VideoGrants
def generate_color(identity: str) -> str:
@@ -142,3 +143,18 @@ 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)
Binary file not shown.
@@ -0,0 +1,446 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-26 10:50+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:26
msgid "Personal info"
msgstr "Persönliche Informationen"
#: core/admin.py:39
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:51
msgid "Important dates"
msgstr "Wichtige Daten"
#: core/admin.py:143
msgid "No owner"
msgstr "Kein Eigentümer"
#: core/admin.py:146
msgid "Multiple owners"
msgstr "Mehrere Eigentümer"
#: core/api/serializers.py:63
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
"hinzuzufügen."
#: core/models.py:31
msgid "Member"
msgstr "Mitglied"
#: core/models.py:32
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:33
msgid "Owner"
msgstr "Eigentümer"
#: core/models.py:49
msgid "Initiated"
msgstr "Gestartet"
#: core/models.py:50
msgid "Active"
msgstr "Aktiv"
#: core/models.py:51
msgid "Stopped"
msgstr "Beendet"
#: core/models.py:52
msgid "Saved"
msgstr "Gespeichert"
#: core/models.py:53
msgid "Aborted"
msgstr "Abgebrochen"
#: core/models.py:54
msgid "Failed to Start"
msgstr "Start fehlgeschlagen"
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Stopp fehlgeschlagen"
#: core/models.py:56
msgid "Notification succeeded"
msgstr "Benachrichtigung erfolgreich"
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr "BILDSCHIRMAUFZEICHNUNG"
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr "TRANSKRIPT"
#: core/models.py:90
msgid "Public Access"
msgstr "Öffentlicher Zugriff"
#: core/models.py:91
msgid "Trusted Access"
msgstr "Vertrauenswürdiger Zugriff"
#: core/models.py:92
msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff"
#: core/models.py:104
msgid "id"
msgstr "ID"
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr "Primärschlüssel des Eintrags als UUID"
#: core/models.py:111
msgid "created on"
msgstr "erstellt am"
#: core/models.py:112
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
#: core/models.py:117
msgid "updated on"
msgstr "aktualisiert am"
#: core/models.py:118
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
"und die Zeichen @/./+/-/_ enthalten."
#: core/models.py:144
msgid "sub"
msgstr "Sub"
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Erforderlich. Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ sind "
"erlaubt."
#: core/models.py:154
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:159
msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:161
msgid "full name"
msgstr "Vollständiger Name"
#: core/models.py:163
msgid "short name"
msgstr "Kurzname"
#: core/models.py:169
msgid "language"
msgstr "Sprache"
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:179
msgid "device"
msgstr "Gerät"
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:184
msgid "staff status"
msgstr "Mitarbeiterstatus"
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:189
msgid "active"
msgstr "aktiv"
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos."
#: core/models.py:205
msgid "user"
msgstr "Benutzer"
#: core/models.py:206
msgid "users"
msgstr "Benutzer"
#: core/models.py:265
msgid "Resource"
msgstr "Ressource"
#: core/models.py:266
msgid "Resources"
msgstr "Ressourcen"
#: core/models.py:320
msgid "Resource access"
msgstr "Ressourcenzugriff"
#: core/models.py:321
msgid "Resource accesses"
msgstr "Ressourcenzugriffe"
#: core/models.py:327
msgid "Resource access with this User and Resource already exists."
msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits."
#: core/models.py:383
msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:384
msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:391
msgid "Room PIN code"
msgstr "PIN-Code für den Raum"
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:398 core/models.py:552
msgid "Room"
msgstr "Raum"
#: core/models.py:399
msgid "Rooms"
msgstr "Räume"
#: core/models.py:563
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:565
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr ""
"Geben Sie eine ID für die Aufzeichnung des Workers ein. Diese ID bleibt "
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht."
#: core/models.py:573
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:574
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:580
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:581
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:689
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:690
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:696
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:702
msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:708
msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit"
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email"
msgstr "Logo-E-Mail"
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call"
msgstr "lädt Sie zu einem laufenden Videoanruf ein"
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "AM ANRUF TEILNEHMEN"
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
msgid ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
msgstr ""
"Wenn Sie den Button nicht anklicken können, kopieren Sie die URL und fügen "
"Sie sie in Ihren Browser ein, um am Anruf teilzunehmen."
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:"
msgstr "Tipps für ein besseres Erlebnis:"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality"
msgstr "Verwenden Sie Chrome oder Firefox für eine bessere Anrufqualität"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining"
msgstr "Testen Sie Ihr Mikrofon und Ihre Kamera vor dem Beitritt"
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Stellen Sie sicher, dass Sie eine stabile Internetverbindung haben"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Vielen Dank für die Nutzung von %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr "Ihre Aufzeichnung ist fertig!"
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
msgstr ""
" Ihre Aufzeichnung von \"%(room_name)s\" am %(recording_date)s um "
"%(recording_time)s steht nun zum Herunterladen bereit. "
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid " The recording will expire in %(days)s days. "
msgstr " Die Aufzeichnung wird in %(days)s Tagen ablaufen. "
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \"Open\" button below "
msgstr "Klicken Sie auf den Button „Öffnen“ unten "
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \"Download\" button in the interface "
msgstr "Verwenden Sie den Button „Herunterladen“ in der Oberfläche "
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
msgid "Save the file to your preferred location"
msgstr "Speichern Sie die Datei an einem gewünschten Ort"
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr "Öffnen"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
msgstr ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. "
#: meet/settings.py:162
msgid "English"
msgstr "Englisch"
#: meet/settings.py:163
msgid "French"
msgstr "Französisch"
#: meet/settings.py:164
msgid "Dutch"
msgstr "Niederländisch"
#: meet/settings.py:165
msgid "German"
msgstr "Deutsch"
Binary file not shown.
+229 -256
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-14 19:04+0000\n"
"POT-Creation-Date: 2025-05-26 10:50+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -23,390 +23,355 @@ msgstr ""
#: core/admin.py:39
msgid "Permissions"
msgstr ""
msgstr "Permissions"
#: core/admin.py:51
msgid "Important dates"
msgstr ""
msgstr "Important dates"
#: core/api/serializers.py:60
#: core/admin.py:143
msgid "No owner"
msgstr "No owner"
#: core/admin.py:146
msgid "Multiple owners"
msgstr "Multiple owners"
#: core/api/serializers.py:63
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
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 ""
#: core/authentication/backends.py:102
msgid "User account is disabled"
msgstr ""
#: core/authentication/backends.py:142
msgid "Multiple user accounts share a common email."
msgstr ""
#: core/models.py:27
#: core/models.py:31
msgid "Member"
msgstr ""
msgstr "Member"
#: core/models.py:28
#: core/models.py:32
msgid "Administrator"
msgstr ""
msgstr "Administrator"
#: core/models.py:29
#: core/models.py:33
msgid "Owner"
msgstr ""
#: core/models.py:45
msgid "Initiated"
msgstr ""
#: core/models.py:46
msgid "Active"
msgstr ""
#: core/models.py:47
msgid "Stopped"
msgstr ""
#: core/models.py:48
msgid "Saved"
msgstr ""
msgstr "Owner"
#: core/models.py:49
msgid "Aborted"
msgstr ""
msgid "Initiated"
msgstr "Initiated"
#: core/models.py:50
msgid "Failed to Start"
msgstr ""
msgid "Active"
msgstr "Active"
#: core/models.py:51
msgid "Failed to Stop"
msgstr ""
msgid "Stopped"
msgstr "Stopped"
#: core/models.py:52
msgid "Saved"
msgstr "Saved"
#: core/models.py:53
msgid "Aborted"
msgstr "Aborted"
#: core/models.py:54
msgid "Failed to Start"
msgstr "Failed to Start"
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Failed to Stop"
#: core/models.py:56
msgid "Notification succeeded"
msgstr ""
msgstr "Notification succeeded"
#: core/models.py:79
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr ""
msgstr "SCREEN_RECORDING"
#: core/models.py:80
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr ""
msgstr "TRANSCRIPT"
#: core/models.py:86
#: core/models.py:90
msgid "Public Access"
msgstr ""
msgstr "Public Access"
#: core/models.py:87
#: core/models.py:91
msgid "Trusted Access"
msgstr ""
msgstr "Trusted Access"
#: core/models.py:88
#: core/models.py:92
msgid "Restricted Access"
msgstr ""
msgstr "Restricted Access"
#: core/models.py:100
#: core/models.py:104
msgid "id"
msgstr ""
msgstr "id"
#: core/models.py:101
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr ""
msgstr "primary key for the record as UUID"
#: core/models.py:107
#: core/models.py:111
msgid "created on"
msgstr ""
msgstr "created on"
#: core/models.py:108
#: core/models.py:112
msgid "date and time at which a record was created"
msgstr ""
msgstr "date and time at which a record was created"
#: core/models.py:113
#: core/models.py:117
msgid "updated on"
msgstr ""
msgstr "updated on"
#: core/models.py:114
#: core/models.py:118
msgid "date and time at which a record was last updated"
msgstr ""
msgstr "date and time at which a record was last updated"
#: core/models.py:134
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: core/models.py:140
#: core/models.py:144
msgid "sub"
msgstr ""
msgstr "sub"
#: core/models.py:142
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:150
#: core/models.py:154
msgid "identity email address"
msgstr ""
#: core/models.py:155
msgid "admin email address"
msgstr ""
#: core/models.py:157
msgid "full name"
msgstr ""
msgstr "identity email address"
#: core/models.py:159
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:161
msgid "full name"
msgstr "full name"
#: core/models.py:163
msgid "short name"
msgstr ""
msgstr "short name"
#: core/models.py:165
#: core/models.py:169
msgid "language"
msgstr ""
msgstr "language"
#: core/models.py:166
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr ""
msgstr "The language in which the user wants to see the interface."
#: core/models.py:172
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr ""
msgstr "The timezone in which the user wants to see times."
#: core/models.py:175
#: core/models.py:179
msgid "device"
msgstr ""
msgstr "device"
#: core/models.py:177
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr ""
msgstr "Whether the user is a device or a real user."
#: core/models.py:180
#: core/models.py:184
msgid "staff status"
msgstr ""
msgstr "staff status"
#: core/models.py:182
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr ""
msgstr "Whether the user can log into this admin site."
#: core/models.py:185
#: core/models.py:189
msgid "active"
msgstr ""
msgstr "active"
#: core/models.py:188
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:201
#: core/models.py:205
msgid "user"
msgstr ""
msgstr "user"
#: core/models.py:202
#: core/models.py:206
msgid "users"
msgstr ""
msgstr "users"
#: core/models.py:261
#: core/models.py:265
msgid "Resource"
msgstr ""
msgstr "Resource"
#: core/models.py:262
#: core/models.py:266
msgid "Resources"
msgstr ""
msgstr "Resources"
#: core/models.py:316
#: core/models.py:320
msgid "Resource access"
msgstr ""
msgstr "Resource access"
#: core/models.py:317
#: core/models.py:321
msgid "Resource accesses"
msgstr ""
msgstr "Resource accesses"
#: core/models.py:323
#: core/models.py:327
msgid "Resource access with this User and Resource already exists."
msgstr ""
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:379
#: core/models.py:383
msgid "Visio room configuration"
msgstr ""
msgstr "Visio room configuration"
#: core/models.py:380
#: core/models.py:384
msgid "Values for Visio parameters to configure the room."
msgstr ""
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:386 core/models.py:506
#: 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 ""
msgstr "Room"
#: core/models.py:387
#: core/models.py:399
msgid "Rooms"
msgstr ""
msgstr "Rooms"
#: core/models.py:517
#: core/models.py:563
msgid "Worker ID"
msgstr ""
msgstr "Worker ID"
#: core/models.py:519
#: core/models.py:565
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:527
#: core/models.py:573
msgid "Recording mode"
msgstr ""
msgstr "Recording mode"
#: core/models.py:528
#: core/models.py:574
msgid "Defines the mode of recording being called."
msgstr ""
msgstr "Defines the mode of recording being called."
#: core/models.py:534
#: core/models.py:580
msgid "Recording"
msgstr ""
msgstr "Recording"
#: core/models.py:535
#: core/models.py:581
msgid "Recordings"
msgstr ""
msgstr "Recordings"
#: core/models.py:592
#: core/models.py:689
msgid "Recording/user relation"
msgstr ""
msgstr "Recording/user relation"
#: core/models.py:593
#: core/models.py:690
msgid "Recording/user relations"
msgstr ""
msgstr "Recording/user relations"
#: core/models.py:599
#: core/models.py:696
msgid "This user is already in this recording."
msgstr ""
msgstr "This user is already in this recording."
#: core/models.py:605
#: core/models.py:702
msgid "This team is already in this recording."
msgstr ""
msgstr "This team is already in this recording."
#: core/models.py:611
#: core/models.py:708
msgid "Either user or team must be set, not both."
msgstr ""
msgstr "Either user or team must be set, not both."
#: core/recording/event/authentication.py:58
msgid "Authentication is enabled but token is not configured."
msgstr ""
#: core/recording/event/authentication.py:70
msgid "Authorization header is required"
msgstr ""
#: core/recording/event/authentication.py:78
msgid "Invalid authorization header."
msgstr ""
#: core/recording/event/authentication.py:88
msgid "Invalid token"
msgstr ""
#: core/recording/event/notification.py:81
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr ""
msgstr "Your recording is ready"
#: core/templates/mail/html/invitation.html:160
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video call in progress: {sender.email} is waiting for you to connect"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email"
msgstr "Logo email"
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a team"
msgstr ""
msgid "invites you to join an ongoing video call"
msgstr "invites you to join an ongoing video call"
#: core/templates/mail/html/invitation.html:198
msgid "Welcome to <strong>Meet</strong>"
msgstr ""
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "JOIN THE CALL"
#: core/templates/mail/html/invitation.html:216
#: core/templates/mail/text/invitation.txt:12
msgid "Logo"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
msgid ""
"We are delighted to welcome you to our community on Meet, your new companion "
"to collaborate on documents efficiently, intuitively, and securely."
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
msgstr ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
#: core/templates/mail/html/invitation.html:231
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid ""
"Our application is designed to help you organize, collaborate, and manage "
"permissions."
msgstr ""
#: core/templates/mail/html/invitation.html:236
#: core/templates/mail/text/invitation.txt:16
msgid "With Meet, you will be able to:"
msgstr ""
msgid "Tips for a better experience:"
msgstr "Tips for a better experience:"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Create documents."
msgstr ""
msgid "Use Chrome or Firefox for better call quality"
msgstr "Use Chrome or Firefox for better call quality"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Invite members of your document or community in just a few clicks."
msgstr ""
msgid "Test your microphone and camera before joining"
msgstr "Test your microphone and camera before joining"
#: core/templates/mail/html/invitation.html:249
#: core/templates/mail/text/invitation.txt:20
msgid "Visit Meet"
msgstr ""
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Make sure you have a stable internet connection"
#: core/templates/mail/html/invitation.html:258
#: core/templates/mail/text/invitation.txt:22
msgid ""
"We are confident that Meet will help you increase efficiency and "
"productivity while strengthening the bond among members."
msgstr ""
#: core/templates/mail/html/invitation.html:263
#: core/templates/mail/text/invitation.txt:23
msgid ""
"Feel free to explore all the features of the application and share your "
"feedback and suggestions with us. Your feedback is valuable to us and will "
"enable us to continually improve our service."
msgstr ""
#: core/templates/mail/html/invitation.html:268
#: core/templates/mail/text/invitation.txt:24
msgid ""
"Once again, welcome aboard! We are eager to accompany you on you "
"collaboration adventure."
msgstr ""
#: core/templates/mail/html/invitation.html:275
#: core/templates/mail/text/invitation.txt:26
msgid "Sincerely,"
msgstr ""
#: core/templates/mail/html/invitation.html:276
#: core/templates/mail/text/invitation.txt:28
msgid "The La Suite Numérique Team"
msgstr ""
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Thank you for using %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr ""
msgstr "Your recording is ready!"
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
@@ -415,31 +380,39 @@ msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
msgstr ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid " The recording will expire in %(days)s days. "
msgstr " The recording will expire in %(days)s days. "
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr ""
msgstr "To keep this recording permanently:"
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \\"
msgstr ""
msgid "Click the \"Open\" button below "
msgstr "Click the \"Open\" button below "
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \\"
msgstr ""
msgid "Use the \"Download\" button in the interface "
msgstr "Use the \"Download\" button in the interface "
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
msgid "Save the file to your preferred location"
msgstr ""
msgstr "Save the file to your preferred location"
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr ""
msgstr "Open"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
@@ -448,21 +421,21 @@ msgid ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
msgstr ""
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr ""
#: core/templates/mail/text/invitation.txt:8
msgid "Welcome to Meet"
msgstr ""
#: meet/settings.py:161
msgid "English"
msgstr ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
#: meet/settings.py:162
msgid "English"
msgstr "English"
#: meet/settings.py:163
msgid "French"
msgstr ""
msgstr "French"
#: meet/settings.py:164
msgid "Dutch"
msgstr "Dutch"
#: meet/settings.py:165
msgid "German"
msgstr "German"
Binary file not shown.
+234 -255
View File
@@ -8,9 +8,9 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-14 19:04+0000\n"
"POT-Creation-Date: 2025-05-26 10:50+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
@@ -19,394 +19,365 @@ msgstr ""
#: core/admin.py:26
msgid "Personal info"
msgstr ""
msgstr "Informations personnelles"
#: core/admin.py:39
msgid "Permissions"
msgstr ""
msgstr "Permissions"
#: core/admin.py:51
msgid "Important dates"
msgstr ""
msgstr "Dates importantes"
#: core/api/serializers.py:60
#: core/admin.py:143
msgid "No owner"
msgstr "Pas de propriétaire"
#: core/admin.py:146
msgid "Multiple owners"
msgstr "Plusieurs propriétaires"
#: core/api/serializers.py:63
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
"des accès."
#: core/authentication/backends.py:77
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:102
msgid "User account is disabled"
msgstr ""
#: core/authentication/backends.py:142
msgid "Multiple user accounts share a common email."
msgstr ""
#: core/models.py:27
#: core/models.py:31
msgid "Member"
msgstr ""
msgstr "Membre"
#: core/models.py:28
#: core/models.py:32
msgid "Administrator"
msgstr ""
msgstr "Administrateur"
#: core/models.py:29
#: core/models.py:33
msgid "Owner"
msgstr ""
#: core/models.py:45
msgid "Initiated"
msgstr ""
#: core/models.py:46
msgid "Active"
msgstr ""
#: core/models.py:47
msgid "Stopped"
msgstr ""
#: core/models.py:48
msgid "Saved"
msgstr ""
msgstr "Propriétaire"
#: core/models.py:49
msgid "Aborted"
msgstr ""
msgid "Initiated"
msgstr "Initié"
#: core/models.py:50
msgid "Failed to Start"
msgstr ""
msgid "Active"
msgstr "Actif"
#: core/models.py:51
msgid "Failed to Stop"
msgstr ""
msgid "Stopped"
msgstr "Arrêté"
#: core/models.py:52
msgid "Saved"
msgstr "Enregistré"
#: core/models.py:53
msgid "Aborted"
msgstr "Abandonné"
#: core/models.py:54
msgid "Failed to Start"
msgstr "Échec au démarrage"
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Échec à l'arrêt"
#: core/models.py:56
msgid "Notification succeeded"
msgstr ""
msgstr "Notification réussie"
#: core/models.py:79
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr ""
msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:80
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr ""
msgstr "TRANSCRIPTION"
#: core/models.py:86
#: core/models.py:90
msgid "Public Access"
msgstr ""
msgstr "Accès public"
#: core/models.py:87
#: core/models.py:91
msgid "Trusted Access"
msgstr ""
msgstr "Accès de confiance"
#: core/models.py:88
#: core/models.py:92
msgid "Restricted Access"
msgstr ""
msgstr "Accès restreint"
#: core/models.py:100
#: core/models.py:104
msgid "id"
msgstr ""
msgstr "id"
#: core/models.py:101
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr ""
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:107
#: core/models.py:111
msgid "created on"
msgstr ""
msgstr "créé le"
#: core/models.py:108
#: core/models.py:112
msgid "date and time at which a record was created"
msgstr ""
msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:113
#: core/models.py:117
msgid "updated on"
msgstr ""
msgstr "mis à jour le"
#: core/models.py:114
#: 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:134
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, "
"des chiffres et les caractères @/./+/-/_."
#: core/models.py:140
#: core/models.py:144
msgid "sub"
msgstr ""
msgstr "sub"
#: core/models.py:142
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./"
"+/-/_ uniquement."
#: core/models.py:150
#: core/models.py:154
msgid "identity email address"
msgstr ""
#: core/models.py:155
msgid "admin email address"
msgstr ""
#: core/models.py:157
msgid "full name"
msgstr ""
msgstr "adresse e-mail d'identité"
#: core/models.py:159
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:161
msgid "full name"
msgstr "nom complet"
#: core/models.py:163
msgid "short name"
msgstr ""
msgstr "nom court"
#: core/models.py:165
#: core/models.py:169
msgid "language"
msgstr ""
msgstr "langue"
#: core/models.py:166
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr ""
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:172
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr ""
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:175
#: core/models.py:179
msgid "device"
msgstr ""
msgstr "appareil"
#: core/models.py:177
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr ""
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:180
#: core/models.py:184
msgid "staff status"
msgstr ""
msgstr "statut du personnel"
#: core/models.py:182
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr ""
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:185
#: core/models.py:189
msgid "active"
msgstr ""
msgstr "actif"
#: core/models.py:188
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:201
#: core/models.py:205
msgid "user"
msgstr ""
msgstr "utilisateur"
#: core/models.py:202
#: core/models.py:206
msgid "users"
msgstr ""
msgstr "utilisateurs"
#: core/models.py:261
#: core/models.py:265
msgid "Resource"
msgstr ""
msgstr "Ressource"
#: core/models.py:262
#: core/models.py:266
msgid "Resources"
msgstr ""
msgstr "Ressources"
#: core/models.py:316
#: core/models.py:320
msgid "Resource access"
msgstr ""
msgstr "Accès aux ressources"
#: core/models.py:317
#: core/models.py:321
msgid "Resource accesses"
msgstr ""
msgstr "Accès aux ressources"
#: core/models.py:323
#: 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:379
#: core/models.py:383
msgid "Visio room configuration"
msgstr ""
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:380
#: core/models.py:384
msgid "Values for Visio parameters to configure the room."
msgstr ""
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:386 core/models.py:506
#: 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 ""
msgstr "Salle"
#: core/models.py:387
#: core/models.py:399
msgid "Rooms"
msgstr ""
msgstr "Salles"
#: core/models.py:517
#: core/models.py:563
msgid "Worker ID"
msgstr ""
msgstr "ID du Worker"
#: core/models.py:519
#: core/models.py:565
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant "
"est conservé même lorsque le Worker s'arrête, permettant un suivi "
"facile."
#: core/models.py:527
#: core/models.py:573
msgid "Recording mode"
msgstr ""
msgstr "Mode d'enregistrement"
#: core/models.py:528
#: core/models.py:574
msgid "Defines the mode of recording being called."
msgstr ""
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:534
#: core/models.py:580
msgid "Recording"
msgstr ""
msgstr "Enregistrement"
#: core/models.py:535
#: core/models.py:581
msgid "Recordings"
msgstr ""
msgstr "Enregistrements"
#: core/models.py:592
#: core/models.py:689
msgid "Recording/user relation"
msgstr ""
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:593
#: core/models.py:690
msgid "Recording/user relations"
msgstr ""
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:599
#: core/models.py:696
msgid "This user is already in this recording."
msgstr ""
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:605
#: core/models.py:702
msgid "This team is already in this recording."
msgstr ""
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:611
#: core/models.py:708
msgid "Either user or team must be set, not both."
msgstr ""
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/recording/event/authentication.py:58
msgid "Authentication is enabled but token is not configured."
msgstr ""
#: core/recording/event/authentication.py:70
msgid "Authorization header is required"
msgstr ""
#: core/recording/event/authentication.py:78
msgid "Invalid authorization header."
msgstr ""
#: core/recording/event/authentication.py:88
msgid "Invalid token"
msgstr ""
#: core/recording/event/notification.py:81
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr ""
msgstr "Votre enregistrement est prêt"
#: core/templates/mail/html/invitation.html:160
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email"
msgstr "Logo email"
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a team"
msgstr ""
msgid "invites you to join an ongoing video call"
msgstr "vous invite à rejoindre un appel vidéo en cours"
#: core/templates/mail/html/invitation.html:198
msgid "Welcome to <strong>Meet</strong>"
msgstr ""
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "REJOINDRE L'APPEL"
#: core/templates/mail/html/invitation.html:216
#: core/templates/mail/text/invitation.txt:12
msgid "Logo"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
msgid ""
"We are delighted to welcome you to our community on Meet, your new companion "
"to collaborate on documents efficiently, intuitively, and securely."
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
msgstr ""
"Si vous ne pouvez pas cliquer sur le bouton, copiez et collez l'URL dans "
"votre navigateur pour rejoindre l'appel."
#: core/templates/mail/html/invitation.html:231
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid ""
"Our application is designed to help you organize, collaborate, and manage "
"permissions."
msgstr ""
#: core/templates/mail/html/invitation.html:236
#: core/templates/mail/text/invitation.txt:16
msgid "With Meet, you will be able to:"
msgstr ""
msgid "Tips for a better experience:"
msgstr "Conseils pour une meilleure expérience :"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Create documents."
msgstr ""
msgid "Use Chrome or Firefox for better call quality"
msgstr "Utilisez Chrome ou Firefox pour une meilleure qualité d'appel"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Invite members of your document or community in just a few clicks."
msgstr ""
msgid "Test your microphone and camera before joining"
msgstr "Testez votre microphone et votre caméra avant de rejoindre"
#: core/templates/mail/html/invitation.html:249
#: core/templates/mail/text/invitation.txt:20
msgid "Visit Meet"
msgstr ""
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Assurez-vous d'avoir une connexion Internet stable"
#: core/templates/mail/html/invitation.html:258
#: core/templates/mail/text/invitation.txt:22
msgid ""
"We are confident that Meet will help you increase efficiency and "
"productivity while strengthening the bond among members."
msgstr ""
#: core/templates/mail/html/invitation.html:263
#: core/templates/mail/text/invitation.txt:23
msgid ""
"Feel free to explore all the features of the application and share your "
"feedback and suggestions with us. Your feedback is valuable to us and will "
"enable us to continually improve our service."
msgstr ""
#: core/templates/mail/html/invitation.html:268
#: core/templates/mail/text/invitation.txt:24
msgid ""
"Once again, welcome aboard! We are eager to accompany you on you "
"collaboration adventure."
msgstr ""
#: core/templates/mail/html/invitation.html:275
#: core/templates/mail/text/invitation.txt:26
msgid "Sincerely,"
msgstr ""
#: core/templates/mail/html/invitation.html:276
#: core/templates/mail/text/invitation.txt:28
msgid "The La Suite Numérique Team"
msgstr ""
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Merci d'utiliser %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr ""
msgstr "Votre enregistrement est prêt !"
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
@@ -415,31 +386,39 @@ msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
msgstr ""
" Votre enregistrement de \"%(room_name)s\" du %(recording_date)s à "
"%(recording_time)s est maintenant prêt à être téléchargé. "
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid " The recording will expire in %(days)s days. "
msgstr " L'enregistrement expirera dans %(days)s jours. "
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr ""
msgstr "Pour conserver cet enregistrement de façon permanente :"
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \\"
msgstr ""
msgid "Click the \"Open\" button below "
msgstr "Cliquez sur le bouton \"Ouvrir\" ci-dessous "
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \\"
msgstr ""
msgid "Use the \"Download\" button in the interface "
msgstr "Utilisez le bouton \"Télécharger\" dans l'interface "
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
msgid "Save the file to your preferred location"
msgstr ""
msgstr "Enregistrez le fichier à l'emplacement de votre choix"
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr ""
msgstr "Ouvrir"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
@@ -448,21 +427,21 @@ msgid ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
msgstr ""
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr ""
#: core/templates/mail/text/invitation.txt:8
msgid "Welcome to Meet"
msgstr ""
#: meet/settings.py:161
msgid "English"
msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. "
#: meet/settings.py:162
msgid "English"
msgstr "Anglais"
#: meet/settings.py:163
msgid "French"
msgstr ""
msgstr "Français"
#: meet/settings.py:164
msgid "Dutch"
msgstr "Néerlandais"
#: meet/settings.py:165
msgid "German"
msgstr "Allemand"
Binary file not shown.
@@ -0,0 +1,441 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-26 10:50+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:26
msgid "Personal info"
msgstr "Persoonlijke informatie"
#: core/admin.py:39
msgid "Permissions"
msgstr "Rechten"
#: core/admin.py:51
msgid "Important dates"
msgstr "Belangrijke datums"
#: core/admin.py:143
msgid "No owner"
msgstr "Geen eigenaar"
#: core/admin.py:146
msgid "Multiple owners"
msgstr "Meerdere eigenaren"
#: core/api/serializers.py:63
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
#: core/models.py:31
msgid "Member"
msgstr "Lid"
#: core/models.py:32
msgid "Administrator"
msgstr "Beheerder"
#: core/models.py:33
msgid "Owner"
msgstr "Eigenaar"
#: core/models.py:49
msgid "Initiated"
msgstr "Gestart"
#: core/models.py:50
msgid "Active"
msgstr "Actief"
#: core/models.py:51
msgid "Stopped"
msgstr "Gestopt"
#: core/models.py:52
msgid "Saved"
msgstr "Opgeslagen"
#: core/models.py:53
msgid "Aborted"
msgstr "Afgebroken"
#: core/models.py:54
msgid "Failed to Start"
msgstr "Starten mislukt"
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Stoppen mislukt"
#: core/models.py:56
msgid "Notification succeeded"
msgstr "Notificatie geslaagd"
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME"
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:90
msgid "Public Access"
msgstr "Openbare toegang"
#: core/models.py:91
msgid "Trusted Access"
msgstr "Vertrouwde toegang"
#: core/models.py:92
msgid "Restricted Access"
msgstr "Beperkte toegang"
#: core/models.py:104
msgid "id"
msgstr "id"
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:111
msgid "created on"
msgstr "aangemaakt op"
#: core/models.py:112
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record werd aangemaakt"
#: core/models.py:117
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:118
msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten."
#: core/models.py:144
msgid "sub"
msgstr "sub"
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Vereist. 255 tekens of minder. Alleen letters, cijfers en @/./+/-/_ tekens."
#: core/models.py:154
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:159
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:161
msgid "full name"
msgstr "volledige naam"
#: core/models.py:163
msgid "short name"
msgstr "korte naam"
#: core/models.py:169
msgid "language"
msgstr "taal"
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:179
msgid "device"
msgstr "apparaat"
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:184
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:189
msgid "active"
msgstr "actief"
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:205
msgid "user"
msgstr "gebruiker"
#: core/models.py:206
msgid "users"
msgstr "gebruikers"
#: core/models.py:265
msgid "Resource"
msgstr "Bron"
#: core/models.py:266
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:320
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:321
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:327
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:383
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:384
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:391
msgid "Room PIN code"
msgstr "Pincode van de kamer"
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:398 core/models.py:552
msgid "Room"
msgstr "Ruimte"
#: core/models.py:399
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:563
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:565
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:573
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:574
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:580
msgid "Recording"
msgstr "Opname"
#: core/models.py:581
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:689
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:690
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:696
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:702
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:708
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Je opname is klaar"
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video-oproep bezig: {sender.email} wacht op je verbinding"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email"
msgstr "Logo e-mail"
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call"
msgstr "nodigt je uit om deel te nemen aan een lopende video-oproep"
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "NEEM DEEL AAN DE OPROEP"
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
msgid ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
msgstr ""
"Als je niet op de knop kunt klikken, kopieer en plak dan de URL in je "
"browser om deel te nemen aan de oproep."
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:"
msgstr "Tips voor een betere ervaring:"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality"
msgstr "Gebruik Chrome of Firefox voor betere gesprekskwaliteit"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining"
msgstr "Test je microfoon en camera voordat je deelneemt"
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Zorg ervoor dat je een stabiele internetverbinding hebt"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Bedankt voor het gebruik van %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr "Je opname is klaar!"
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
msgstr ""
" Je opname van \"%(room_name)s\" op %(recording_date)s om %(recording_time)s "
"is nu klaar om te downloaden. "
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid " The recording will expire in %(days)s days. "
msgstr " De opname verloopt over %(days)s dagen. "
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr "Om deze opname permanent te bewaren:"
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \"Open\" button below "
msgstr "Klik op de \"Openen\"-knop hieronder "
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \"Download\" button in the interface "
msgstr "Gebruik de \"Download\"-knop in de interface "
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
msgid "Save the file to your preferred location"
msgstr "Sla het bestand op naar je gewenste locatie"
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr "Openen"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. "
#: meet/settings.py:162
msgid "English"
msgstr "Engels"
#: meet/settings.py:163
msgid "French"
msgstr "Frans"
#: meet/settings.py:164
msgid "Dutch"
msgstr "Nederlands"
#: meet/settings.py:165
msgid "German"
msgstr "Duits"
+3 -3
View File
@@ -3,12 +3,12 @@
meet's sandbox management script.
"""
import os
import sys
from os import environ
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "meet.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
environ.setdefault("DJANGO_SETTINGS_MODULE", "meet.settings")
environ.setdefault("DJANGO_CONFIGURATION", "Development")
from configurations.management import execute_from_command_line
+3 -3
View File
@@ -1,13 +1,13 @@
"""Meet celery configuration file."""
import os
from os import environ
from celery import Celery
from configurations.importer import install
# Set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "meet.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
environ.setdefault("DJANGO_SETTINGS_MODULE", "meet.settings")
environ.setdefault("DJANGO_CONFIGURATION", "Development")
install(check_options=True)
+95 -31
View File
@@ -11,19 +11,19 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
"""
import json
import os
from os import path
from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
import sentry_sdk
from configurations import Configuration, values
from lasuite.configuration.values import SecretFileValue
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.logging import ignore_logger
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.join("/", "data")
BASE_DIR = path.dirname(path.dirname(path.abspath(__file__)))
def get_release():
@@ -39,7 +39,7 @@ def get_release():
# Try to get the current release from the version.json file generated by the
# CI during the Docker image build
try:
with open(os.path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
with open(path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
return json.load(version)["version"]
except FileNotFoundError:
return "NA" # Default: not available
@@ -70,9 +70,11 @@ class Base(Configuration):
API_VERSION = "v1.0"
DATA_DIR = values.Value(path.join("/", "data"), environ_name="DATA_DIR")
# Security
ALLOWED_HOSTS = values.ListValue([])
SECRET_KEY = values.Value(None)
SECRET_KEY = SecretFileValue(None)
SILENCED_SYSTEM_CHECKS = values.ListValue([])
ALLOW_UNSECURE_USER_LISTING = values.BooleanValue(
False, environ_name="ALLOW_UNSECURE_USER_LISTING", environ_prefix=None
@@ -92,7 +94,7 @@ class Base(Configuration):
),
"NAME": values.Value("meet", environ_name="DB_NAME", environ_prefix=None),
"USER": values.Value("dinum", environ_name="DB_USER", environ_prefix=None),
"PASSWORD": values.Value(
"PASSWORD": SecretFileValue(
"pass", environ_name="DB_PASSWORD", environ_prefix=None
),
"HOST": values.Value(
@@ -105,9 +107,9 @@ class Base(Configuration):
# Static files (CSS, JavaScript, Images)
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(DATA_DIR, "static")
STATIC_ROOT = path.join(DATA_DIR, "static")
MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(DATA_DIR, "media")
MEDIA_ROOT = path.join(DATA_DIR, "media")
SITE_ID = 1
@@ -127,10 +129,10 @@ class Base(Configuration):
AWS_S3_ENDPOINT_URL = values.Value(
environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None
)
AWS_S3_ACCESS_KEY_ID = values.Value(
AWS_S3_ACCESS_KEY_ID = SecretFileValue(
environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None
)
AWS_S3_SECRET_ACCESS_KEY = values.Value(
AWS_S3_SECRET_ACCESS_KEY = SecretFileValue(
environ_name="AWS_S3_SECRET_ACCESS_KEY", environ_prefix=None
)
AWS_S3_REGION_NAME = values.Value(
@@ -160,10 +162,12 @@ class Base(Configuration):
(
("en-us", _("English")),
("fr-fr", _("French")),
("nl-nl", _("Dutch")),
("de-de", _("German")),
)
)
LOCALE_PATHS = (os.path.join(BASE_DIR, "locale"),)
LOCALE_PATHS = (path.join(BASE_DIR, "locale"),)
TIME_ZONE = "UTC"
USE_I18N = True
@@ -173,7 +177,7 @@ class Base(Configuration):
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "templates")],
"DIRS": [path.join(BASE_DIR, "templates")],
"OPTIONS": {
"context_processors": [
"django.contrib.auth.context_processors.auth",
@@ -261,6 +265,9 @@ class Base(Configuration):
"rest_framework.parsers.JSONParser",
"nested_multipart_parser.drf.DrfNestedParser",
],
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
],
"EXCEPTION_HANDLER": "core.api.exception_handler",
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
@@ -298,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
),
@@ -308,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
),
}
@@ -316,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)
@@ -331,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([])
@@ -351,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
)
@@ -372,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,
@@ -389,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
)
@@ -435,15 +467,23 @@ class Base(Configuration):
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
environ_prefix=None,
)
OIDC_USERINFO_ESSENTIAL_CLAIMS = values.ListValue(
default=[],
environ_name="OIDC_USERINFO_ESSENTIAL_CLAIMS",
environ_prefix=None,
)
# Video conference configuration
LIVEKIT_CONFIGURATION = {
"api_key": values.Value(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
"api_secret": values.Value(
"api_key": SecretFileValue(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
"api_secret": SecretFileValue(
environ_name="LIVEKIT_API_SECRET", environ_prefix=None
),
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
}
LIVEKIT_VERIFY_SSL = values.BooleanValue(
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
)
RESOURCE_DEFAULT_ACCESS_LEVEL = values.Value(
"public", environ_name="RESOURCE_DEFAULT_ACCESS_LEVEL", environ_prefix=None
)
@@ -458,9 +498,6 @@ class Base(Configuration):
RECORDING_OUTPUT_FOLDER = values.Value(
"recordings", environ_name="RECORDING_OUTPUT_FOLDER", environ_prefix=None
)
RECORDING_VERIFY_SSL = values.BooleanValue(
True, environ_name="RECORDING_VERIFY_SSL", environ_prefix=None
)
RECORDING_WORKER_CLASSES = values.DictValue(
{
"screen_recording": "core.recording.worker.services.VideoCompositeEgressService",
@@ -480,13 +517,18 @@ class Base(Configuration):
RECORDING_STORAGE_EVENT_ENABLE = values.BooleanValue(
False, environ_name="RECORDING_STORAGE_EVENT_ENABLE", environ_prefix=None
)
RECORDING_STORAGE_EVENT_TOKEN = values.Value(
RECORDING_STORAGE_EVENT_TOKEN = SecretFileValue(
None, environ_name="RECORDING_STORAGE_EVENT_TOKEN", environ_prefix=None
)
# Number of days before recordings expire - must be synced with bucket lifecycle policy
# Set to None for no expiration
RECORDING_EXPIRATION_DAYS = values.IntegerValue(
None, environ_name="RECORDING_EXPIRATION_DAYS", environ_prefix=None
)
SUMMARY_SERVICE_ENDPOINT = values.Value(
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
)
SUMMARY_SERVICE_API_TOKEN = values.Value(
SUMMARY_SERVICE_API_TOKEN = SecretFileValue(
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
)
SCREEN_RECORDING_BASE_URL = values.Value(
@@ -504,7 +546,7 @@ class Base(Configuration):
environ_name="MARKETING_SERVICE_CLASS",
environ_prefix=None,
)
BREVO_API_KEY = values.Value(
BREVO_API_KEY = SecretFileValue(
None, environ_name="BREVO_API_KEY", environ_prefix=None
)
BREVO_API_CONTACT_LIST_IDS = values.ListValue(
@@ -551,6 +593,23 @@ 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,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -597,10 +656,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")
@@ -764,6 +820,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.18"
version = "0.1.27"
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>
+1414 -1340
View File
File diff suppressed because it is too large Load Diff
+26 -25
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.18",
"version": "0.1.27",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -13,48 +13,49 @@
"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.12",
"@livekit/components-styles": "1.1.6",
"@livekit/track-processors": "0.5.7",
"@pandacss/preset-panda": "0.53.6",
"@react-aria/toast": "3.0.2",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.69.0",
"@tanstack/react-query": "5.76.0",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.25",
"hoofd": "1.7.3",
"i18next": "24.2.3",
"i18next-browser-languagedetector": "8.0.4",
"i18next": "25.1.2",
"i18next-browser-languagedetector": "8.1.0",
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.9.8",
"posthog-js": "1.232.6",
"livekit-client": "2.13.8",
"posthog-js": "1.240.6",
"react": "18.3.1",
"react-aria-components": "1.7.1",
"react-aria-components": "1.8.0",
"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.0"
},
"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.53.6",
"@tanstack/eslint-plugin-query": "5.74.7",
"@tanstack/react-query-devtools": "5.76.0",
"@types/node": "22.15.17",
"@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.32.0",
"@typescript-eslint/parser": "8.32.0",
"@vitejs/plugin-react": "4.4.1",
"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",
"eslint-plugin-react-refresh": "0.4.20",
"postcss": "8.5.3",
"prettier": "3.5.3",
"typescript": "5.8.2",
"vite": "6.2.4",
"typescript": "5.8.3",
"vite": "6.2.7",
"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}' },

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

+102
View File
@@ -0,0 +1,102 @@
<svg width="160" height="31" viewBox="0 0 160 31" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.12361 11.0152C4 11.6494 4 12.4305 4 13.7388V16.886C4 18.5092 4 19.3208 4.23607 20.0474C4.44495 20.6902 4.78662 21.282 5.23892 21.7844C5.7501 22.3521 6.45297 22.7579 7.85872 23.5695L10.5842 25.1431C11.8283 25.8614 12.5219 26.2618 13.1829 26.4545V16.7441C13.1829 16.2587 12.9178 15.812 12.4917 15.5796L4.12361 11.0152Z" fill="#202328"/>
<path d="M4.12361 11.0152C4 11.6494 4 12.4305 4 13.7388V16.886C4 18.5092 4 19.3208 4.23607 20.0474C4.44495 20.6902 4.78662 21.282 5.23892 21.7844C5.7501 22.3521 6.45297 22.7579 7.85872 23.5695L10.5842 25.1431C11.8283 25.8614 12.5219 26.2618 13.1829 26.4545V16.7441C13.1829 16.2587 12.9178 15.812 12.4917 15.5796L4.12361 11.0152Z" fill="url(#paint0_linear_5970_3325)" fill-opacity="0.3"/>
<path d="M4.12361 11.0152C4 11.6494 4 12.4305 4 13.7388V16.886C4 18.5092 4 19.3208 4.23607 20.0474C4.44495 20.6902 4.78662 21.282 5.23892 21.7844C5.7501 22.3521 6.45297 22.7579 7.85872 23.5695L10.5842 25.1431C11.8283 25.8614 12.5219 26.2618 13.1829 26.4545V16.7441C13.1829 16.2587 12.9178 15.812 12.4917 15.5796L4.12361 11.0152Z" fill="#252525"/>
<path d="M26.9113 12.4414V17.5045C26.9113 18.2046 27.2155 18.8701 27.7448 19.3282L31.3889 22.4817C31.6022 22.6622 31.8236 22.8098 32.0532 22.9246C32.291 23.0312 32.5207 23.0845 32.7421 23.0845C33.2177 23.0845 33.5991 22.9287 33.8861 22.6171C34.1813 22.2972 34.3289 21.8831 34.3289 21.3746V8.58126C34.3289 8.07281 34.1813 7.66276 33.8861 7.35113C33.5991 7.0313 33.2177 6.87138 32.7421 6.87138C32.5207 6.87138 32.291 6.92469 32.0532 7.0313C31.8236 7.13791 31.6022 7.28552 31.3889 7.47414L27.7477 10.6152C27.2166 11.0734 26.9113 11.74 26.9113 12.4414Z" fill="#202328"/>
<path d="M26.9113 12.4414V17.5045C26.9113 18.2046 27.2155 18.8701 27.7448 19.3282L31.3889 22.4817C31.6022 22.6622 31.8236 22.8098 32.0532 22.9246C32.291 23.0312 32.5207 23.0845 32.7421 23.0845C33.2177 23.0845 33.5991 22.9287 33.8861 22.6171C34.1813 22.2972 34.3289 21.8831 34.3289 21.3746V8.58126C34.3289 8.07281 34.1813 7.66276 33.8861 7.35113C33.5991 7.0313 33.2177 6.87138 32.7421 6.87138C32.5207 6.87138 32.291 6.92469 32.0532 7.0313C31.8236 7.13791 31.6022 7.28552 31.3889 7.47414L27.7477 10.6152C27.2166 11.0734 26.9113 11.74 26.9113 12.4414Z" fill="url(#paint1_linear_5970_3325)" fill-opacity="0.3"/>
<path d="M26.9113 12.4414V17.5045C26.9113 18.2046 27.2155 18.8701 27.7448 19.3282L31.3889 22.4817C31.6022 22.6622 31.8236 22.8098 32.0532 22.9246C32.291 23.0312 32.5207 23.0845 32.7421 23.0845C33.2177 23.0845 33.5991 22.9287 33.8861 22.6171C34.1813 22.2972 34.3289 21.8831 34.3289 21.3746V8.58126C34.3289 8.07281 34.1813 7.66276 33.8861 7.35113C33.5991 7.0313 33.2177 6.87138 32.7421 6.87138C32.5207 6.87138 32.291 6.92469 32.0532 7.0313C31.8236 7.13791 31.6022 7.28552 31.3889 7.47414L27.7477 10.6152C27.2166 11.0734 26.9113 11.74 26.9113 12.4414Z" fill="#252525"/>
<path d="M5.34145 9.20693C5.24012 9.15166 5.13386 9.11423 5.02627 9.09356C5.09416 9.0069 5.16509 8.92246 5.23897 8.8404C5.75015 8.27267 6.45303 7.86686 7.85877 7.05525L10.5843 5.48166C11.99 4.67005 12.6929 4.26424 13.4402 4.1054C14.1014 3.96487 14.7847 3.96487 15.4459 4.1054C16.1931 4.26424 16.896 4.67005 18.3017 5.48166L20.9951 7.03669C21.0059 7.04286 21.0166 7.04905 21.0273 7.05526C22.4331 7.86687 23.136 8.27268 23.6471 8.84041C24.0994 9.34274 24.4411 9.93452 24.65 10.5774C24.8861 11.304 24.8861 12.1156 24.8861 13.7388V16.886C24.8861 18.5092 24.8861 19.3208 24.65 20.0474C24.4411 20.6903 24.0994 21.282 23.6471 21.7844C23.136 22.3521 22.4331 22.7579 21.0274 23.5695C21.0154 23.5764 21.0032 23.5834 20.9912 23.5903L18.3017 25.1431C16.896 25.9547 16.1931 26.3605 15.4459 26.5194C15.4151 26.5259 15.3844 26.5321 15.3536 26.538L15.3536 16.7441C15.3536 15.4644 14.6546 14.2869 13.5311 13.6741L5.34145 9.20693Z" fill="#202328"/>
<path d="M5.34145 9.20693C5.24012 9.15166 5.13386 9.11423 5.02627 9.09356C5.09416 9.0069 5.16509 8.92246 5.23897 8.8404C5.75015 8.27267 6.45303 7.86686 7.85877 7.05525L10.5843 5.48166C11.99 4.67005 12.6929 4.26424 13.4402 4.1054C14.1014 3.96487 14.7847 3.96487 15.4459 4.1054C16.1931 4.26424 16.896 4.67005 18.3017 5.48166L20.9951 7.03669C21.0059 7.04286 21.0166 7.04905 21.0273 7.05526C22.4331 7.86687 23.136 8.27268 23.6471 8.84041C24.0994 9.34274 24.4411 9.93452 24.65 10.5774C24.8861 11.304 24.8861 12.1156 24.8861 13.7388V16.886C24.8861 18.5092 24.8861 19.3208 24.65 20.0474C24.4411 20.6903 24.0994 21.282 23.6471 21.7844C23.136 22.3521 22.4331 22.7579 21.0274 23.5695C21.0154 23.5764 21.0032 23.5834 20.9912 23.5903L18.3017 25.1431C16.896 25.9547 16.1931 26.3605 15.4459 26.5194C15.4151 26.5259 15.3844 26.5321 15.3536 26.538L15.3536 16.7441C15.3536 15.4644 14.6546 14.2869 13.5311 13.6741L5.34145 9.20693Z" fill="url(#paint2_linear_5970_3325)" fill-opacity="0.3"/>
<path d="M5.34145 9.20693C5.24012 9.15166 5.13386 9.11423 5.02627 9.09356C5.09416 9.0069 5.16509 8.92246 5.23897 8.8404C5.75015 8.27267 6.45303 7.86686 7.85877 7.05525L10.5843 5.48166C11.99 4.67005 12.6929 4.26424 13.4402 4.1054C14.1014 3.96487 14.7847 3.96487 15.4459 4.1054C16.1931 4.26424 16.896 4.67005 18.3017 5.48166L20.9951 7.03669C21.0059 7.04286 21.0166 7.04905 21.0273 7.05526C22.4331 7.86687 23.136 8.27268 23.6471 8.84041C24.0994 9.34274 24.4411 9.93452 24.65 10.5774C24.8861 11.304 24.8861 12.1156 24.8861 13.7388V16.886C24.8861 18.5092 24.8861 19.3208 24.65 20.0474C24.4411 20.6903 24.0994 21.282 23.6471 21.7844C23.136 22.3521 22.4331 22.7579 21.0274 23.5695C21.0154 23.5764 21.0032 23.5834 20.9912 23.5903L18.3017 25.1431C16.896 25.9547 16.1931 26.3605 15.4459 26.5194C15.4151 26.5259 15.3844 26.5321 15.3536 26.538L15.3536 16.7441C15.3536 15.4644 14.6546 14.2869 13.5311 13.6741L5.34145 9.20693Z" fill="#252525"/>
<path d="M42.1726 21.992C41.8807 21.992 41.6441 21.7553 41.6441 21.4634V8.89637C41.6441 8.60445 41.8807 8.36781 42.1726 8.36781H43.8793C44.1712 8.36781 44.4078 8.60446 44.4078 8.89637V18.9527C44.4078 19.2446 44.6445 19.4812 44.9364 19.4812H49.0565C49.3484 19.4812 49.585 19.7179 49.585 20.0098V21.4634C49.585 21.7553 49.3484 21.992 49.0565 21.992H42.1726Z" fill="#202328"/>
<path d="M42.1726 21.992C41.8807 21.992 41.6441 21.7553 41.6441 21.4634V8.89637C41.6441 8.60445 41.8807 8.36781 42.1726 8.36781H43.8793C44.1712 8.36781 44.4078 8.60446 44.4078 8.89637V18.9527C44.4078 19.2446 44.6445 19.4812 44.9364 19.4812H49.0565C49.3484 19.4812 49.585 19.7179 49.585 20.0098V21.4634C49.585 21.7553 49.3484 21.992 49.0565 21.992H42.1726Z" fill="url(#paint3_linear_5970_3325)" fill-opacity="0.3"/>
<path d="M42.1726 21.992C41.8807 21.992 41.6441 21.7553 41.6441 21.4634V8.89637C41.6441 8.60445 41.8807 8.36781 42.1726 8.36781H43.8793C44.1712 8.36781 44.4078 8.60446 44.4078 8.89637V18.9527C44.4078 19.2446 44.6445 19.4812 44.9364 19.4812H49.0565C49.3484 19.4812 49.585 19.7179 49.585 20.0098V21.4634C49.585 21.7553 49.3484 21.992 49.0565 21.992H42.1726Z" fill="#252525"/>
<path d="M54.3225 22.2839C52.3567 22.2839 51.0137 21.1551 51.0137 19.345C51.0137 17.8658 52.1621 16.7564 54.2641 16.4061L57.0119 15.9421C57.1559 15.9178 57.2614 15.7931 57.2614 15.647V15.647C57.2614 14.6155 56.4829 13.9537 55.354 13.9537C54.5968 13.9537 53.9746 14.2359 53.4873 14.7126C53.2754 14.9199 52.9415 14.9736 52.7059 14.7937L51.7352 14.0527C51.5072 13.8786 51.4574 13.5517 51.6513 13.3402C52.5387 12.3718 53.8483 11.7933 55.4319 11.7933C58.2735 11.7933 59.7332 13.4866 59.7332 15.647V21.4634C59.7332 21.7553 59.4966 21.992 59.2046 21.992H57.6592C57.4395 21.992 57.2614 21.8139 57.2614 21.5942V21.5942C57.2614 21.4074 57.0113 21.3103 56.8662 21.4279C56.2099 21.9598 55.2597 22.2839 54.3225 22.2839ZM53.4661 19.2282C53.4661 19.89 53.9916 20.3181 54.8285 20.3181C55.8951 20.3181 56.6837 19.849 57.1778 19.1406C57.2345 19.0594 57.2614 18.9616 57.2614 18.8625V17.9442C57.2614 17.7809 57.1148 17.6567 56.9537 17.6835L54.9258 18.0215C53.8943 18.1967 53.4661 18.6249 53.4661 19.2282Z" fill="#202328"/>
<path d="M54.3225 22.2839C52.3567 22.2839 51.0137 21.1551 51.0137 19.345C51.0137 17.8658 52.1621 16.7564 54.2641 16.4061L57.0119 15.9421C57.1559 15.9178 57.2614 15.7931 57.2614 15.647V15.647C57.2614 14.6155 56.4829 13.9537 55.354 13.9537C54.5968 13.9537 53.9746 14.2359 53.4873 14.7126C53.2754 14.9199 52.9415 14.9736 52.7059 14.7937L51.7352 14.0527C51.5072 13.8786 51.4574 13.5517 51.6513 13.3402C52.5387 12.3718 53.8483 11.7933 55.4319 11.7933C58.2735 11.7933 59.7332 13.4866 59.7332 15.647V21.4634C59.7332 21.7553 59.4966 21.992 59.2046 21.992H57.6592C57.4395 21.992 57.2614 21.8139 57.2614 21.5942V21.5942C57.2614 21.4074 57.0113 21.3103 56.8662 21.4279C56.2099 21.9598 55.2597 22.2839 54.3225 22.2839ZM53.4661 19.2282C53.4661 19.89 53.9916 20.3181 54.8285 20.3181C55.8951 20.3181 56.6837 19.849 57.1778 19.1406C57.2345 19.0594 57.2614 18.9616 57.2614 18.8625V17.9442C57.2614 17.7809 57.1148 17.6567 56.9537 17.6835L54.9258 18.0215C53.8943 18.1967 53.4661 18.6249 53.4661 19.2282Z" fill="url(#paint4_linear_5970_3325)" fill-opacity="0.3"/>
<path d="M54.3225 22.2839C52.3567 22.2839 51.0137 21.1551 51.0137 19.345C51.0137 17.8658 52.1621 16.7564 54.2641 16.4061L57.0119 15.9421C57.1559 15.9178 57.2614 15.7931 57.2614 15.647V15.647C57.2614 14.6155 56.4829 13.9537 55.354 13.9537C54.5968 13.9537 53.9746 14.2359 53.4873 14.7126C53.2754 14.9199 52.9415 14.9736 52.7059 14.7937L51.7352 14.0527C51.5072 13.8786 51.4574 13.5517 51.6513 13.3402C52.5387 12.3718 53.8483 11.7933 55.4319 11.7933C58.2735 11.7933 59.7332 13.4866 59.7332 15.647V21.4634C59.7332 21.7553 59.4966 21.992 59.2046 21.992H57.6592C57.4395 21.992 57.2614 21.8139 57.2614 21.5942V21.5942C57.2614 21.4074 57.0113 21.3103 56.8662 21.4279C56.2099 21.9598 55.2597 22.2839 54.3225 22.2839ZM53.4661 19.2282C53.4661 19.89 53.9916 20.3181 54.8285 20.3181C55.8951 20.3181 56.6837 19.849 57.1778 19.1406C57.2345 19.0594 57.2614 18.9616 57.2614 18.8625V17.9442C57.2614 17.7809 57.1148 17.6567 56.9537 17.6835L54.9258 18.0215C53.8943 18.1967 53.4661 18.6249 53.4661 19.2282Z" fill="#252525"/>
<path d="M61.615 20.4798C61.4201 20.2758 61.4422 19.9537 61.6496 19.7624L62.8612 18.6448C63.0887 18.4351 63.4451 18.467 63.6604 18.6893C64.4215 19.4754 65.354 19.9289 66.3006 19.9289C67.3906 19.9289 68.0523 19.345 68.0523 18.3718C68.0523 15.8611 61.863 16.5423 61.863 11.9296C61.863 9.76915 63.6536 7.97855 66.2617 7.97855C68.1118 7.97855 69.5631 8.72827 70.6879 9.89556C70.8856 10.1008 70.8624 10.4269 70.6509 10.618L69.4293 11.7221C69.201 11.9285 68.847 11.8949 68.641 11.6661C67.9369 10.8845 67.1583 10.392 66.2812 10.392C65.308 10.392 64.6657 11.0148 64.6657 11.8322C64.6657 14.3041 70.855 13.6034 70.855 18.2745C70.8161 20.8437 68.8308 22.3812 66.3201 22.3812C64.2311 22.3812 62.7702 21.6888 61.615 20.4798Z" fill="#202328"/>
<path d="M61.615 20.4798C61.4201 20.2758 61.4422 19.9537 61.6496 19.7624L62.8612 18.6448C63.0887 18.4351 63.4451 18.467 63.6604 18.6893C64.4215 19.4754 65.354 19.9289 66.3006 19.9289C67.3906 19.9289 68.0523 19.345 68.0523 18.3718C68.0523 15.8611 61.863 16.5423 61.863 11.9296C61.863 9.76915 63.6536 7.97855 66.2617 7.97855C68.1118 7.97855 69.5631 8.72827 70.6879 9.89556C70.8856 10.1008 70.8624 10.4269 70.6509 10.618L69.4293 11.7221C69.201 11.9285 68.847 11.8949 68.641 11.6661C67.9369 10.8845 67.1583 10.392 66.2812 10.392C65.308 10.392 64.6657 11.0148 64.6657 11.8322C64.6657 14.3041 70.855 13.6034 70.855 18.2745C70.8161 20.8437 68.8308 22.3812 66.3201 22.3812C64.2311 22.3812 62.7702 21.6888 61.615 20.4798Z" fill="url(#paint5_linear_5970_3325)" fill-opacity="0.3"/>
<path d="M61.615 20.4798C61.4201 20.2758 61.4422 19.9537 61.6496 19.7624L62.8612 18.6448C63.0887 18.4351 63.4451 18.467 63.6604 18.6893C64.4215 19.4754 65.354 19.9289 66.3006 19.9289C67.3906 19.9289 68.0523 19.345 68.0523 18.3718C68.0523 15.8611 61.863 16.5423 61.863 11.9296C61.863 9.76915 63.6536 7.97855 66.2617 7.97855C68.1118 7.97855 69.5631 8.72827 70.6879 9.89556C70.8856 10.1008 70.8624 10.4269 70.6509 10.618L69.4293 11.7221C69.201 11.9285 68.847 11.8949 68.641 11.6661C67.9369 10.8845 67.1583 10.392 66.2812 10.392C65.308 10.392 64.6657 11.0148 64.6657 11.8322C64.6657 14.3041 70.855 13.6034 70.855 18.2745C70.8161 20.8437 68.8308 22.3812 66.3201 22.3812C64.2311 22.3812 62.7702 21.6888 61.615 20.4798Z" fill="#252525"/>
<path d="M79.2618 17.749V12.7111C79.2618 12.4192 79.4985 12.1826 79.7904 12.1826H81.2245C81.5165 12.1826 81.7531 12.4192 81.7531 12.7111V17.6322C81.7531 20.5712 80.0404 22.3812 77.1598 22.3812C74.2793 22.3812 72.586 20.5712 72.586 17.6322V12.7111C72.586 12.4192 72.8226 12.1826 73.1145 12.1826H74.5487C74.8406 12.1826 75.0773 12.4192 75.0773 12.7111V17.749C75.0773 19.1698 75.8169 20.0457 77.1793 20.0457C78.5222 20.0457 79.2618 19.1698 79.2618 17.749Z" fill="#202328"/>
<path d="M79.2618 17.749V12.7111C79.2618 12.4192 79.4985 12.1826 79.7904 12.1826H81.2245C81.5165 12.1826 81.7531 12.4192 81.7531 12.7111V17.6322C81.7531 20.5712 80.0404 22.3812 77.1598 22.3812C74.2793 22.3812 72.586 20.5712 72.586 17.6322V12.7111C72.586 12.4192 72.8226 12.1826 73.1145 12.1826H74.5487C74.8406 12.1826 75.0773 12.4192 75.0773 12.7111V17.749C75.0773 19.1698 75.8169 20.0457 77.1793 20.0457C78.5222 20.0457 79.2618 19.1698 79.2618 17.749Z" fill="url(#paint6_linear_5970_3325)" fill-opacity="0.3"/>
<path d="M79.2618 17.749V12.7111C79.2618 12.4192 79.4985 12.1826 79.7904 12.1826H81.2245C81.5165 12.1826 81.7531 12.4192 81.7531 12.7111V17.6322C81.7531 20.5712 80.0404 22.3812 77.1598 22.3812C74.2793 22.3812 72.586 20.5712 72.586 17.6322V12.7111C72.586 12.4192 72.8226 12.1826 73.1145 12.1826H74.5487C74.8406 12.1826 75.0773 12.4192 75.0773 12.7111V17.749C75.0773 19.1698 75.8169 20.0457 77.1793 20.0457C78.5222 20.0457 79.2618 19.1698 79.2618 17.749Z" fill="#252525"/>
<path d="M85.1511 10.4698C84.2753 10.4698 83.5357 9.73023 83.5357 8.85439C83.5357 7.97855 84.2753 7.23895 85.1511 7.23895C86.0269 7.23895 86.7471 7.97855 86.7471 8.85439C86.7471 9.73023 86.0269 10.4698 85.1511 10.4698ZM84.434 21.992C84.1421 21.992 83.9055 21.7553 83.9055 21.4634V12.7111C83.9055 12.4192 84.1421 12.1826 84.434 12.1826H85.8487C86.1406 12.1826 86.3773 12.4192 86.3773 12.7111V21.4634C86.3773 21.7553 86.1406 21.992 85.8487 21.992H84.434Z" fill="#202328"/>
<path d="M85.1511 10.4698C84.2753 10.4698 83.5357 9.73023 83.5357 8.85439C83.5357 7.97855 84.2753 7.23895 85.1511 7.23895C86.0269 7.23895 86.7471 7.97855 86.7471 8.85439C86.7471 9.73023 86.0269 10.4698 85.1511 10.4698ZM84.434 21.992C84.1421 21.992 83.9055 21.7553 83.9055 21.4634V12.7111C83.9055 12.4192 84.1421 12.1826 84.434 12.1826H85.8487C86.1406 12.1826 86.3773 12.4192 86.3773 12.7111V21.4634C86.3773 21.7553 86.1406 21.992 85.8487 21.992H84.434Z" fill="url(#paint7_linear_5970_3325)" fill-opacity="0.3"/>
<path d="M85.1511 10.4698C84.2753 10.4698 83.5357 9.73023 83.5357 8.85439C83.5357 7.97855 84.2753 7.23895 85.1511 7.23895C86.0269 7.23895 86.7471 7.97855 86.7471 8.85439C86.7471 9.73023 86.0269 10.4698 85.1511 10.4698ZM84.434 21.992C84.1421 21.992 83.9055 21.7553 83.9055 21.4634V12.7111C83.9055 12.4192 84.1421 12.1826 84.434 12.1826H85.8487C86.1406 12.1826 86.3773 12.4192 86.3773 12.7111V21.4634C86.3773 21.7553 86.1406 21.992 85.8487 21.992H84.434Z" fill="#252525"/>
<path d="M89.3804 18.4108V14.9299C89.3804 14.638 89.1437 14.4014 88.8518 14.4014H88.0794C87.7875 14.4014 87.5508 14.1647 87.5508 13.8728V12.7111C87.5508 12.4192 87.7875 12.1826 88.0794 12.1826H88.8518C89.1437 12.1826 89.3804 11.9459 89.3804 11.654V10.2588C89.3804 9.96687 89.617 9.73023 89.9089 9.73023H91.3431C91.635 9.73023 91.8716 9.96687 91.8716 10.2588V11.654C91.8716 11.9459 92.1083 12.1826 92.4002 12.1826H94.3404C94.6323 12.1826 94.869 12.4192 94.869 12.7111V13.8728C94.869 14.1647 94.6323 14.4014 94.3404 14.4014H92.4002C92.1083 14.4014 91.8716 14.638 91.8716 14.9299V18.4108C91.8716 19.5007 92.4555 19.9289 93.4287 19.9289C93.6929 19.9289 93.922 19.9172 94.1227 19.8949C94.4967 19.8534 94.8884 20.1278 94.8884 20.5041V21.5303C94.8884 21.7399 94.7656 21.9334 94.5654 21.9955C94.1445 22.1261 93.6451 22.1866 93.0005 22.1866C90.5481 22.1866 89.3804 20.8047 89.3804 18.4108Z" fill="#202328"/>
<path d="M89.3804 18.4108V14.9299C89.3804 14.638 89.1437 14.4014 88.8518 14.4014H88.0794C87.7875 14.4014 87.5508 14.1647 87.5508 13.8728V12.7111C87.5508 12.4192 87.7875 12.1826 88.0794 12.1826H88.8518C89.1437 12.1826 89.3804 11.9459 89.3804 11.654V10.2588C89.3804 9.96687 89.617 9.73023 89.9089 9.73023H91.3431C91.635 9.73023 91.8716 9.96687 91.8716 10.2588V11.654C91.8716 11.9459 92.1083 12.1826 92.4002 12.1826H94.3404C94.6323 12.1826 94.869 12.4192 94.869 12.7111V13.8728C94.869 14.1647 94.6323 14.4014 94.3404 14.4014H92.4002C92.1083 14.4014 91.8716 14.638 91.8716 14.9299V18.4108C91.8716 19.5007 92.4555 19.9289 93.4287 19.9289C93.6929 19.9289 93.922 19.9172 94.1227 19.8949C94.4967 19.8534 94.8884 20.1278 94.8884 20.5041V21.5303C94.8884 21.7399 94.7656 21.9334 94.5654 21.9955C94.1445 22.1261 93.6451 22.1866 93.0005 22.1866C90.5481 22.1866 89.3804 20.8047 89.3804 18.4108Z" fill="url(#paint8_linear_5970_3325)" fill-opacity="0.3"/>
<path d="M89.3804 18.4108V14.9299C89.3804 14.638 89.1437 14.4014 88.8518 14.4014H88.0794C87.7875 14.4014 87.5508 14.1647 87.5508 13.8728V12.7111C87.5508 12.4192 87.7875 12.1826 88.0794 12.1826H88.8518C89.1437 12.1826 89.3804 11.9459 89.3804 11.654V10.2588C89.3804 9.96687 89.617 9.73023 89.9089 9.73023H91.3431C91.635 9.73023 91.8716 9.96687 91.8716 10.2588V11.654C91.8716 11.9459 92.1083 12.1826 92.4002 12.1826H94.3404C94.6323 12.1826 94.869 12.4192 94.869 12.7111V13.8728C94.869 14.1647 94.6323 14.4014 94.3404 14.4014H92.4002C92.1083 14.4014 91.8716 14.638 91.8716 14.9299V18.4108C91.8716 19.5007 92.4555 19.9289 93.4287 19.9289C93.6929 19.9289 93.922 19.9172 94.1227 19.8949C94.4967 19.8534 94.8884 20.1278 94.8884 20.5041V21.5303C94.8884 21.7399 94.7656 21.9334 94.5654 21.9955C94.1445 22.1261 93.6451 22.1866 93.0005 22.1866C90.5481 22.1866 89.3804 20.8047 89.3804 18.4108Z" fill="#252525"/>
<path d="M104.937 20.0772C105.161 20.2547 105.205 20.581 105.01 20.7898C104.081 21.7826 102.7 22.3812 101.058 22.3812C97.5737 22.3812 95.6079 19.9483 95.6079 17.0873C95.6079 14.1873 97.4375 11.7933 100.649 11.7933C103.374 11.7933 105.164 13.6423 105.164 16.2114C105.164 16.5636 105.132 16.8995 105.088 17.1824C105.05 17.4248 104.835 17.5933 104.59 17.5933H98.4383C98.2791 17.5933 98.1553 17.7338 98.1856 17.8901C98.4992 19.5033 99.5865 20.1624 101.038 20.1624C101.891 20.1624 102.657 19.8659 103.2 19.391C103.423 19.1951 103.761 19.1468 103.994 19.3312L104.937 20.0772ZM100.59 13.8175C99.4333 13.8175 98.6344 14.4037 98.298 15.4868C98.2481 15.6473 98.3742 15.8027 98.5422 15.8027H102.457C102.607 15.8027 102.728 15.6774 102.703 15.5295C102.554 14.6358 101.845 13.8175 100.59 13.8175Z" fill="#202328"/>
<path d="M104.937 20.0772C105.161 20.2547 105.205 20.581 105.01 20.7898C104.081 21.7826 102.7 22.3812 101.058 22.3812C97.5737 22.3812 95.6079 19.9483 95.6079 17.0873C95.6079 14.1873 97.4375 11.7933 100.649 11.7933C103.374 11.7933 105.164 13.6423 105.164 16.2114C105.164 16.5636 105.132 16.8995 105.088 17.1824C105.05 17.4248 104.835 17.5933 104.59 17.5933H98.4383C98.2791 17.5933 98.1553 17.7338 98.1856 17.8901C98.4992 19.5033 99.5865 20.1624 101.038 20.1624C101.891 20.1624 102.657 19.8659 103.2 19.391C103.423 19.1951 103.761 19.1468 103.994 19.3312L104.937 20.0772ZM100.59 13.8175C99.4333 13.8175 98.6344 14.4037 98.298 15.4868C98.2481 15.6473 98.3742 15.8027 98.5422 15.8027H102.457C102.607 15.8027 102.728 15.6774 102.703 15.5295C102.554 14.6358 101.845 13.8175 100.59 13.8175Z" fill="url(#paint9_linear_5970_3325)" fill-opacity="0.3"/>
<path d="M104.937 20.0772C105.161 20.2547 105.205 20.581 105.01 20.7898C104.081 21.7826 102.7 22.3812 101.058 22.3812C97.5737 22.3812 95.6079 19.9483 95.6079 17.0873C95.6079 14.1873 97.4375 11.7933 100.649 11.7933C103.374 11.7933 105.164 13.6423 105.164 16.2114C105.164 16.5636 105.132 16.8995 105.088 17.1824C105.05 17.4248 104.835 17.5933 104.59 17.5933H98.4383C98.2791 17.5933 98.1553 17.7338 98.1856 17.8901C98.4992 19.5033 99.5865 20.1624 101.038 20.1624C101.891 20.1624 102.657 19.8659 103.2 19.391C103.423 19.1951 103.761 19.1468 103.994 19.3312L104.937 20.0772ZM100.59 13.8175C99.4333 13.8175 98.6344 14.4037 98.298 15.4868C98.2481 15.6473 98.3742 15.8027 98.5422 15.8027H102.457C102.607 15.8027 102.728 15.6774 102.703 15.5295C102.554 14.6358 101.845 13.8175 100.59 13.8175Z" fill="#252525"/>
<path d="M112.698 21.992C112.406 21.992 112.169 21.7553 112.169 21.4634V8.89637C112.169 8.60445 112.406 8.36781 112.698 8.36781H114.207C114.392 8.36781 114.563 8.46433 114.659 8.62236L118.444 14.8655C118.547 15.0352 118.793 15.0352 118.896 14.8655L122.681 8.62236C122.777 8.46433 122.948 8.36781 123.133 8.36781H124.642C124.934 8.36781 125.171 8.60446 125.171 8.89637V21.4634C125.171 21.7553 124.934 21.992 124.642 21.992H123.87C123.578 21.992 123.341 21.7553 123.341 21.4634V11.7599C123.341 11.4929 122.99 11.3943 122.851 11.6221L119.525 17.0678C119.429 17.225 119.258 17.3208 119.074 17.3208H118.266C118.082 17.3208 117.911 17.225 117.815 17.0678L114.489 11.6221C114.349 11.3943 113.999 11.4929 113.999 11.7599V21.4634C113.999 21.7553 113.762 21.992 113.47 21.992H112.698Z" fill="#202328"/>
<path d="M112.698 21.992C112.406 21.992 112.169 21.7553 112.169 21.4634V8.89637C112.169 8.60445 112.406 8.36781 112.698 8.36781H114.207C114.392 8.36781 114.563 8.46433 114.659 8.62236L118.444 14.8655C118.547 15.0352 118.793 15.0352 118.896 14.8655L122.681 8.62236C122.777 8.46433 122.948 8.36781 123.133 8.36781H124.642C124.934 8.36781 125.171 8.60446 125.171 8.89637V21.4634C125.171 21.7553 124.934 21.992 124.642 21.992H123.87C123.578 21.992 123.341 21.7553 123.341 21.4634V11.7599C123.341 11.4929 122.99 11.3943 122.851 11.6221L119.525 17.0678C119.429 17.225 119.258 17.3208 119.074 17.3208H118.266C118.082 17.3208 117.911 17.225 117.815 17.0678L114.489 11.6221C114.349 11.3943 113.999 11.4929 113.999 11.7599V21.4634C113.999 21.7553 113.762 21.992 113.47 21.992H112.698Z" fill="url(#paint10_linear_5970_3325)" fill-opacity="0.3"/>
<path d="M112.698 21.992C112.406 21.992 112.169 21.7553 112.169 21.4634V8.89637C112.169 8.60445 112.406 8.36781 112.698 8.36781H114.207C114.392 8.36781 114.563 8.46433 114.659 8.62236L118.444 14.8655C118.547 15.0352 118.793 15.0352 118.896 14.8655L122.681 8.62236C122.777 8.46433 122.948 8.36781 123.133 8.36781H124.642C124.934 8.36781 125.171 8.60446 125.171 8.89637V21.4634C125.171 21.7553 124.934 21.992 124.642 21.992H123.87C123.578 21.992 123.341 21.7553 123.341 21.4634V11.7599C123.341 11.4929 122.99 11.3943 122.851 11.6221L119.525 17.0678C119.429 17.225 119.258 17.3208 119.074 17.3208H118.266C118.082 17.3208 117.911 17.225 117.815 17.0678L114.489 11.6221C114.349 11.3943 113.999 11.4929 113.999 11.7599V21.4634C113.999 21.7553 113.762 21.992 113.47 21.992H112.698Z" fill="#252525"/>
<path d="M136.902 20.0585C137.129 20.2352 137.175 20.5635 136.98 20.7748C136.061 21.7688 134.73 22.3812 133.107 22.3812C129.798 22.3812 127.755 19.9483 127.755 17.0873C127.755 14.1094 129.662 11.7933 132.698 11.7933C135.365 11.7933 137.097 13.6618 137.097 16.0752C137.097 16.289 137.079 16.4968 137.054 16.686C137.02 16.933 136.802 17.1067 136.553 17.1067H129.719C129.57 17.1067 129.45 17.2297 129.461 17.3778C129.624 19.5703 131.087 20.8826 133.107 20.8826C134.162 20.8826 135.081 20.4767 135.73 19.8341C135.942 19.6246 136.279 19.5738 136.514 19.7567L136.902 20.0585ZM132.659 13.1947C131.147 13.1947 130.061 14.0304 129.641 15.5262C129.596 15.6875 129.722 15.8416 129.889 15.8416H135.167C135.318 15.8416 135.439 15.7164 135.42 15.5674C135.254 14.2514 134.28 13.1947 132.659 13.1947Z" fill="#202328"/>
<path d="M136.902 20.0585C137.129 20.2352 137.175 20.5635 136.98 20.7748C136.061 21.7688 134.73 22.3812 133.107 22.3812C129.798 22.3812 127.755 19.9483 127.755 17.0873C127.755 14.1094 129.662 11.7933 132.698 11.7933C135.365 11.7933 137.097 13.6618 137.097 16.0752C137.097 16.289 137.079 16.4968 137.054 16.686C137.02 16.933 136.802 17.1067 136.553 17.1067H129.719C129.57 17.1067 129.45 17.2297 129.461 17.3778C129.624 19.5703 131.087 20.8826 133.107 20.8826C134.162 20.8826 135.081 20.4767 135.73 19.8341C135.942 19.6246 136.279 19.5738 136.514 19.7567L136.902 20.0585ZM132.659 13.1947C131.147 13.1947 130.061 14.0304 129.641 15.5262C129.596 15.6875 129.722 15.8416 129.889 15.8416H135.167C135.318 15.8416 135.439 15.7164 135.42 15.5674C135.254 14.2514 134.28 13.1947 132.659 13.1947Z" fill="url(#paint11_linear_5970_3325)" fill-opacity="0.3"/>
<path d="M136.902 20.0585C137.129 20.2352 137.175 20.5635 136.98 20.7748C136.061 21.7688 134.73 22.3812 133.107 22.3812C129.798 22.3812 127.755 19.9483 127.755 17.0873C127.755 14.1094 129.662 11.7933 132.698 11.7933C135.365 11.7933 137.097 13.6618 137.097 16.0752C137.097 16.289 137.079 16.4968 137.054 16.686C137.02 16.933 136.802 17.1067 136.553 17.1067H129.719C129.57 17.1067 129.45 17.2297 129.461 17.3778C129.624 19.5703 131.087 20.8826 133.107 20.8826C134.162 20.8826 135.081 20.4767 135.73 19.8341C135.942 19.6246 136.279 19.5738 136.514 19.7567L136.902 20.0585ZM132.659 13.1947C131.147 13.1947 130.061 14.0304 129.641 15.5262C129.596 15.6875 129.722 15.8416 129.889 15.8416H135.167C135.318 15.8416 135.439 15.7164 135.42 15.5674C135.254 14.2514 134.28 13.1947 132.659 13.1947Z" fill="#252525"/>
<path d="M147.784 20.0585C148.011 20.2352 148.057 20.5635 147.862 20.7748C146.943 21.7688 145.612 22.3812 143.989 22.3812C140.68 22.3812 138.637 19.9483 138.637 17.0873C138.637 14.1094 140.544 11.7933 143.58 11.7933C146.247 11.7933 147.979 13.6618 147.979 16.0752C147.979 16.289 147.961 16.4968 147.936 16.686C147.902 16.933 147.684 17.1067 147.435 17.1067H140.601C140.452 17.1067 140.332 17.2297 140.343 17.3778C140.506 19.5703 141.969 20.8826 143.989 20.8826C145.044 20.8826 145.963 20.4767 146.612 19.8341C146.824 19.6246 147.161 19.5739 147.396 19.7567L147.784 20.0585ZM143.541 13.1947C142.029 13.1947 140.943 14.0304 140.523 15.5262C140.478 15.6875 140.604 15.8416 140.771 15.8416H146.049C146.199 15.8416 146.32 15.7164 146.302 15.5674C146.136 14.2514 145.162 13.1947 143.541 13.1947Z" fill="#202328"/>
<path d="M147.784 20.0585C148.011 20.2352 148.057 20.5635 147.862 20.7748C146.943 21.7688 145.612 22.3812 143.989 22.3812C140.68 22.3812 138.637 19.9483 138.637 17.0873C138.637 14.1094 140.544 11.7933 143.58 11.7933C146.247 11.7933 147.979 13.6618 147.979 16.0752C147.979 16.289 147.961 16.4968 147.936 16.686C147.902 16.933 147.684 17.1067 147.435 17.1067H140.601C140.452 17.1067 140.332 17.2297 140.343 17.3778C140.506 19.5703 141.969 20.8826 143.989 20.8826C145.044 20.8826 145.963 20.4767 146.612 19.8341C146.824 19.6246 147.161 19.5739 147.396 19.7567L147.784 20.0585ZM143.541 13.1947C142.029 13.1947 140.943 14.0304 140.523 15.5262C140.478 15.6875 140.604 15.8416 140.771 15.8416H146.049C146.199 15.8416 146.32 15.7164 146.302 15.5674C146.136 14.2514 145.162 13.1947 143.541 13.1947Z" fill="url(#paint12_linear_5970_3325)" fill-opacity="0.3"/>
<path d="M147.784 20.0585C148.011 20.2352 148.057 20.5635 147.862 20.7748C146.943 21.7688 145.612 22.3812 143.989 22.3812C140.68 22.3812 138.637 19.9483 138.637 17.0873C138.637 14.1094 140.544 11.7933 143.58 11.7933C146.247 11.7933 147.979 13.6618 147.979 16.0752C147.979 16.289 147.961 16.4968 147.936 16.686C147.902 16.933 147.684 17.1067 147.435 17.1067H140.601C140.452 17.1067 140.332 17.2297 140.343 17.3778C140.506 19.5703 141.969 20.8826 143.989 20.8826C145.044 20.8826 145.963 20.4767 146.612 19.8341C146.824 19.6246 147.161 19.5739 147.396 19.7567L147.784 20.0585ZM143.541 13.1947C142.029 13.1947 140.943 14.0304 140.523 15.5262C140.478 15.6875 140.604 15.8416 140.771 15.8416H146.049C146.199 15.8416 146.32 15.7164 146.302 15.5674C146.136 14.2514 145.162 13.1947 143.541 13.1947Z" fill="#252525"/>
<path d="M150.901 18.7027V14.2098C150.901 13.9179 150.664 13.6812 150.372 13.6812H149.58C149.288 13.6812 149.052 13.4446 149.052 13.1527V12.7111C149.052 12.4192 149.288 12.1826 149.58 12.1826H150.372C150.664 12.1826 150.901 11.9459 150.901 11.654V10.2588C150.901 9.96687 151.137 9.73023 151.429 9.73023H152.046C152.338 9.73023 152.575 9.96687 152.575 10.2588V11.654C152.575 11.9459 152.811 12.1826 153.103 12.1826H155.452C155.744 12.1826 155.981 12.4192 155.981 12.7111V13.1527C155.981 13.4446 155.744 13.6812 155.452 13.6812H153.103C152.811 13.6812 152.575 13.9179 152.575 14.2098V18.7027C152.575 20.2403 153.353 20.6685 154.54 20.6685C154.98 20.6685 155.328 20.6321 155.622 20.5592C155.806 20.5139 156 20.6439 156 20.833V21.5617C156 21.7751 155.873 21.9715 155.668 22.0302C155.282 22.1404 154.846 22.1866 154.307 22.1866C152.127 22.1866 150.901 21.0967 150.901 18.7027Z" fill="#202328"/>
<path d="M150.901 18.7027V14.2098C150.901 13.9179 150.664 13.6812 150.372 13.6812H149.58C149.288 13.6812 149.052 13.4446 149.052 13.1527V12.7111C149.052 12.4192 149.288 12.1826 149.58 12.1826H150.372C150.664 12.1826 150.901 11.9459 150.901 11.654V10.2588C150.901 9.96687 151.137 9.73023 151.429 9.73023H152.046C152.338 9.73023 152.575 9.96687 152.575 10.2588V11.654C152.575 11.9459 152.811 12.1826 153.103 12.1826H155.452C155.744 12.1826 155.981 12.4192 155.981 12.7111V13.1527C155.981 13.4446 155.744 13.6812 155.452 13.6812H153.103C152.811 13.6812 152.575 13.9179 152.575 14.2098V18.7027C152.575 20.2403 153.353 20.6685 154.54 20.6685C154.98 20.6685 155.328 20.6321 155.622 20.5592C155.806 20.5139 156 20.6439 156 20.833V21.5617C156 21.7751 155.873 21.9715 155.668 22.0302C155.282 22.1404 154.846 22.1866 154.307 22.1866C152.127 22.1866 150.901 21.0967 150.901 18.7027Z" fill="url(#paint13_linear_5970_3325)" fill-opacity="0.3"/>
<path d="M150.901 18.7027V14.2098C150.901 13.9179 150.664 13.6812 150.372 13.6812H149.58C149.288 13.6812 149.052 13.4446 149.052 13.1527V12.7111C149.052 12.4192 149.288 12.1826 149.58 12.1826H150.372C150.664 12.1826 150.901 11.9459 150.901 11.654V10.2588C150.901 9.96687 151.137 9.73023 151.429 9.73023H152.046C152.338 9.73023 152.575 9.96687 152.575 10.2588V11.654C152.575 11.9459 152.811 12.1826 153.103 12.1826H155.452C155.744 12.1826 155.981 12.4192 155.981 12.7111V13.1527C155.981 13.4446 155.744 13.6812 155.452 13.6812H153.103C152.811 13.6812 152.575 13.9179 152.575 14.2098V18.7027C152.575 20.2403 153.353 20.6685 154.54 20.6685C154.98 20.6685 155.328 20.6321 155.622 20.5592C155.806 20.5139 156 20.6439 156 20.833V21.5617C156 21.7751 155.873 21.9715 155.668 22.0302C155.282 22.1404 154.846 22.1866 154.307 22.1866C152.127 22.1866 150.901 21.0967 150.901 18.7027Z" fill="#252525"/>
<defs>
<linearGradient id="paint0_linear_5970_3325" x1="134.058" y1="8.24938" x2="134.058" y2="21.992" gradientUnits="userSpaceOnUse">
<stop stop-color="#C9191E" stop-opacity="0"/>
<stop offset="1" stop-color="#C9191E"/>
</linearGradient>
<linearGradient id="paint1_linear_5970_3325" x1="134.058" y1="8.24938" x2="134.058" y2="21.992" gradientUnits="userSpaceOnUse">
<stop stop-color="#C9191E" stop-opacity="0"/>
<stop offset="1" stop-color="#C9191E"/>
</linearGradient>
<linearGradient id="paint2_linear_5970_3325" x1="134.058" y1="8.24938" x2="134.058" y2="21.992" gradientUnits="userSpaceOnUse">
<stop stop-color="#C9191E" stop-opacity="0"/>
<stop offset="1" stop-color="#C9191E"/>
</linearGradient>
<linearGradient id="paint3_linear_5970_3325" x1="134.058" y1="8.24938" x2="134.058" y2="21.992" gradientUnits="userSpaceOnUse">
<stop stop-color="#C9191E" stop-opacity="0"/>
<stop offset="1" stop-color="#C9191E"/>
</linearGradient>
<linearGradient id="paint4_linear_5970_3325" x1="134.058" y1="8.24938" x2="134.058" y2="21.992" gradientUnits="userSpaceOnUse">
<stop stop-color="#C9191E" stop-opacity="0"/>
<stop offset="1" stop-color="#C9191E"/>
</linearGradient>
<linearGradient id="paint5_linear_5970_3325" x1="134.058" y1="8.24938" x2="134.058" y2="21.992" gradientUnits="userSpaceOnUse">
<stop stop-color="#C9191E" stop-opacity="0"/>
<stop offset="1" stop-color="#C9191E"/>
</linearGradient>
<linearGradient id="paint6_linear_5970_3325" x1="134.058" y1="8.24938" x2="134.058" y2="21.992" gradientUnits="userSpaceOnUse">
<stop stop-color="#C9191E" stop-opacity="0"/>
<stop offset="1" stop-color="#C9191E"/>
</linearGradient>
<linearGradient id="paint7_linear_5970_3325" x1="134.058" y1="8.24938" x2="134.058" y2="21.992" gradientUnits="userSpaceOnUse">
<stop stop-color="#C9191E" stop-opacity="0"/>
<stop offset="1" stop-color="#C9191E"/>
</linearGradient>
<linearGradient id="paint8_linear_5970_3325" x1="134.058" y1="8.24938" x2="134.058" y2="21.992" gradientUnits="userSpaceOnUse">
<stop stop-color="#C9191E" stop-opacity="0"/>
<stop offset="1" stop-color="#C9191E"/>
</linearGradient>
<linearGradient id="paint9_linear_5970_3325" x1="134.058" y1="8.24938" x2="134.058" y2="21.992" gradientUnits="userSpaceOnUse">
<stop stop-color="#C9191E" stop-opacity="0"/>
<stop offset="1" stop-color="#C9191E"/>
</linearGradient>
<linearGradient id="paint10_linear_5970_3325" x1="134.058" y1="8.24938" x2="134.058" y2="21.992" gradientUnits="userSpaceOnUse">
<stop stop-color="#C9191E" stop-opacity="0"/>
<stop offset="1" stop-color="#C9191E"/>
</linearGradient>
<linearGradient id="paint11_linear_5970_3325" x1="134.058" y1="8.24938" x2="134.058" y2="21.992" gradientUnits="userSpaceOnUse">
<stop stop-color="#C9191E" stop-opacity="0"/>
<stop offset="1" stop-color="#C9191E"/>
</linearGradient>
<linearGradient id="paint12_linear_5970_3325" x1="134.058" y1="8.24938" x2="134.058" y2="21.992" gradientUnits="userSpaceOnUse">
<stop stop-color="#C9191E" stop-opacity="0"/>
<stop offset="1" stop-color="#C9191E"/>
</linearGradient>
<linearGradient id="paint13_linear_5970_3325" x1="134.058" y1="8.24938" x2="134.058" y2="21.992" gradientUnits="userSpaceOnUse">
<stop stop-color="#C9191E" stop-opacity="0"/>
<stop offset="1" stop-color="#C9191E"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

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