Compare commits

...

347 Commits

Author SHA1 Message Date
lebaudantoine 9310bdf36a 🔧(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-16 19:17:45 +02:00
lebaudantoine 21ed460783 🌐(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-16 19:16:01 +02:00
lebaudantoine d562a1fcae 🌐(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-16 19:11:51 +02:00
lebaudantoine 3e93f5924c (backend) add 10-digit PIN codes on rooms for telephony
Enable users to join rooms via SIP telephony by:
- Dialing the SIP trunk number
- Entering the room's PIN followed by '#'

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This service is triggered by the storage hook from Minio.

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

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

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

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

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

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

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

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

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

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

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

Part of a broader effort to enhance data transmission reliability.

Importantly, a participant requesting entry to a room before the owner
arrives should not be considered an exception.
2025-03-25 13:33:47 +01:00
lebaudantoine 33e017464f 📌(livekit) pin livekit dev version
Pin the LiveKit server version to use the same as the staging
and production environment. It's a good practice.
2025-03-25 13:18:44 +01:00
lebaudantoine 9e91bbe417 🔇(backend) reduce noisy logs in Sentry
Ignore logs from DockerflowMiddleware
and minimize unnecessary log submissions.
2025-03-25 09:42:35 +01:00
renovate[bot] e821982353 ⬆️(dependencies) update python dependencies 2025-03-24 20:04:15 +01:00
lebaudantoine 18f4a117ab 🩹(backend) invert operation order in participant handling
Invert operation sequence to first notify people in room before setting
participant in cache. Fixes infinite loop issue caused by 3s cache timeout
for waiting participants when requests take too long. Problem only occurred
when notifications were delayed, as faster notification delivery masked the
race condition.
2025-03-24 19:39:13 +01:00
lebaudantoine b5b99d4c52 🐛(frontend) fix effect menu scroll
Since refactoring, the effect side panel, was not scrollable anymore.
Fixed it.
2025-03-24 17:10:50 +01:00
lebaudantoine 8a4323aa82 🐛(license) fix reuse.software missing support in GitHub
Duplicate the MIT License, which are not yet supported by GitHub and its badges.
2025-03-24 14:54:42 +01:00
lebaudantoine 5cbac23f13 💄(frontend) adjust minor visual item
Adjust few items from the landing and the interface.
2025-03-24 14:38:27 +01:00
lebaudantoine d55810784d 🐛(frontend) fix Marianne import
The font face definition wasn't working properly. Switch to vanilla css.
2025-03-24 14:38:27 +01:00
lebaudantoine c2fdefe7b2 💄(frontend) adjust font size based on Marianne recent changes
Marianne is bigger than OpenSans, adjust admin side panel style.
2025-03-24 13:53:41 +01:00
lebaudantoine bf2474d0e1 🌐(frontend) add missing translation
The admin side panel was translated too quickly.
I forgot a title, fix it.
2025-03-24 13:53:41 +01:00
lebaudantoine 558d844ef3 ✏️(frontend) fix typos in the admin side panel
Based on Users' feedbacks. Also based on Arnaud's one, avoid
having a double negation for the public restriction.
2025-03-24 13:36:03 +01:00
lebaudantoine 94aa828d87 🌐(frontend) add missing dutch content
Oopsi, forgot some dutch content when resolving conflicts
while merging traductions. Fixed.
2025-03-21 16:59:14 +01:00
lebaudantoine 110e062e56 💄(frontend) use official Marianne font
Updated styling system to implement the official French font used across all
La Suite products. This enhances brand consistency and improves the visual
identity alignment with other government digital services.
2025-03-21 16:57:33 +01:00
lebaudantoine f7c2beb30d 🚸(frontend) add screen sharing troubleshooting modal and setup guide
Created modal that appears when users fail to share their entire screen. Also
added documentation helper that explains proper screen sharing setup steps for
different operating systems and browsers.
2025-03-21 15:27:50 +01:00
lebaudantoine 5bf1456200 ⚰️(secrets) remove useless submodule
Secret are not in-use anymore in the project, dead code.
2025-03-21 15:25:37 +01:00
Ikko Eltociear Ashimine 80e5c9bb95 ✏️(frontend) update BackgroundBlurTrackProcessorJsWrapper.ts
accross -> across
2025-03-18 20:07:26 +01:00
Rust Saiargaliev 74164b8498 🗑️(backend) drop obsolete code from the initial boilerplate
Related to 5b1a2b20de
There are no references to the `generate_document.html` template
in the codebase. The same goes for the `INVITATION_VALIDITY_DURATION` setting,
which arrived straigt from https://github.com/suitenumerique/docs

WeasyPrint is (I believe) not used in the project, so it is a ghost dependency.
2025-03-18 20:05:31 +01:00
Juergen Ehrensberger 82cc2e5d17 📝(helm) correct helm chart repository URL for meet
The URL https://numerique-gouv.github.io/meet/ is not accessible.
The correct URL appears to be https://suitenumerique.github.io/meet/
2025-03-18 20:03:45 +01:00
lebaudantoine fa1503db0d 📄(legal) update legal notice following expert review
following legal team's advices, updates our legal terms.
2025-03-16 18:10:40 +01:00
lebaudantoine e106415740 🔒️(frontend) update meet-frontend image to address security vuls
Fixed two HIGH severity vulnerabilities in libxslt:
- CVE-2024-55549: Use-After-Free in libxslt (xsltGetInheritedNsList)
- CVE-2025-24855: Use-After-Free in libxslt numbers.c

The image was manually updated as no more recent unprivileged nginx-based
images were available. This addresses the security scan failures from Trivy.
2025-03-16 16:36:01 +01:00
lebaudantoine 4199328a4a 📄(license) update license following expert review
following @bzg advice, updates our licenses.
2025-03-15 14:14:41 +01:00
aleb_the_flash 57715fea73 📝(readme) integrate feedbacks from open-source specialist
Clarify the license, and avoid any enterprise-like vocabulary or expression.

Thx @bzg
2025-03-15 14:02:16 +01:00
lebaudantoine c969c302bc 📝(docs) add SECURITY.md file
Created SECURITY.md document outlining security policy, vulnerability reporting
process, and responsible disclosure guidelines for the project.
2025-03-13 19:48:31 +01:00
lebaudantoine be29e193c5 📝(docs) add CODE_OF_CONDUCT.md file
Created CODE_OF_CONDUCT.md document defining acceptable behavior standards and
conflict resolution procedures for project participants.
2025-03-13 19:48:31 +01:00
lebaudantoine 53d0e9fe12 📝(docs) add CONTRIBUTING.md file
Created CONTRIBUTING.md document outlining contribution guidelines, development
setup, and code standards for new contributors.
2025-03-13 19:48:31 +01:00
Arnaud Robin e04d9a9dab 📝(doc) update README to engage users
Revamp README to be more engaging and informative.

Goal: Foster a true open-source spirit by making it easier for
contributors to engage, interact, and contribute.

Heavily inspired by PostHog's excellent README.
2025-03-13 19:48:31 +01:00
lebaudantoine fdb6dda65b 🔒️(frontend) update libxml2 to fix CVE-2025-27113 vulnerability
Upgraded libxml2 from version 2.12.7-r1 to 2.12.7-r2 to address
a HIGH severity NULL Pointer Dereference vulnerability. This security update
prevents potential application crashes that could be triggered through
malicious XML input.
2025-03-13 19:21:46 +01:00
lebaudantoine 332662d1e5 🔧(frontend) add make commands for simplified frontend setup
Added intuitive make commands that help new developers quickly set up
frontend dependencies and launch the entire stack. This streamlines
onboarding by providing clear entry points for common development tasks
without requiring deep knowledge of the project structure.
2025-03-13 19:21:46 +01:00
lebaudantoine a8e1bbe085 🧑‍💻(frontend) enable frontend service in docker compose
Added configuration to docker-compose stack allowing users to run the
frontend in production mode. This simplifies the developer onboarding,
for those wanting to run the project locally.
2025-03-13 19:21:46 +01:00
lebaudantoine 9eae98ed16 🐛(devops) fix dockerize platform compatibility for Mac M2 users
Specified the expected platform in dockerize configuration to ensure
compatibility with Mac M2 architecture. This resolves build failures
experienced by developers using Apple Silicon, enabling seamless
development across different hardware.
2025-03-13 19:21:46 +01:00
lebaudantoine 3cae3e66c4 🔖(minor) bump release to 0.1.15
Release private rooms
2025-03-11 13:57:40 +01:00
lebaudantoine 7e463f4554 ♻️(frontend) add opensource annotation on the repo link
Add a clear explanatory text about the project's opensource nature.
This provides better context for users while maintaining transparency
about the software license.
2025-03-11 13:37:01 +01:00
lebaudantoine 1c40003c3c (frontend) introduce dedicated terms of service page
Created a proper terms of service page within the application to replace
external doc page redirects. Implemented based on Sophie's accessibility
requirements to improve user experience for all users regardless
of ability.
2025-03-11 13:37:01 +01:00
lebaudantoine 5f07d4a88b ️(frontend) introduce dedicated accessibility page
Created a proper accessibility page within the application to replace external
doc page redirects. Implemented based on Sophie's accessibility requirements
to improve user experience for all users regardless of ability.
2025-03-11 13:37:01 +01:00
lebaudantoine 6cf8e23ab2 ️(frontend) introduce dedicated legal notice page
Created a proper legal notice page within the application to replace
external doc page redirects. Implemented based on Sophie's
accessibility requirements to improve user experience for all users
regardless of ability.
2025-03-11 13:37:01 +01:00
lebaudantoine b4016ce850 (frontend) support screen titles with colorful page headers
Added support for passing screen titles that display in colorful header
components, improving page navigation context and visual hierarchy.
2025-03-11 13:37:01 +01:00
lebaudantoine 74aba2185a 💄(frontend) add technical link style to legal notice primitive
Added a new style variant to the link primitive component that
visually highlights technical links specifically within legal notices.
This improves clarity and helps users distinguish different link types
in legal documentation.
2025-03-11 13:37:01 +01:00
Eric Wout van der Steen 0bbaae7c5e 🌐(frontend) add Dutch translation
Add Dutch to the language selection system, and add configuration
files with Dutch translated messages.
2025-03-07 19:12:57 +01:00
lebaudantoine fbee41f5dd ♻️(backend) avoid repeating 'service' in python modules
These modules are already stored under the 'service' folder, it was redundant.
Renamed these files based on @lunika feedbacks.
2025-03-07 18:36:30 +01:00
renovate[bot] 2503411311 ⬆️(dependencies) update django to v5.1.7 [SECURITY] 2025-03-07 17:27:37 +01:00
lebaudantoine 13944ceebd 🔧(livekit) create custom LiveKit image with nip.io CA certificate
Override LiveKit Docker image to include nip.io Certificate Authority for
development environment. Addresses issue where LiveKit webhook calls fail in
dev mode due to unknown CA. Custom image places certificate in appropriate
location since LiveKit chart lacks volume mounting options for CA certs or
webhook SSL disabling capabilities.

Discussed with @rouja.
2025-03-07 17:05:06 +01:00
lebaudantoine 50719e8c25 (backend) activate LiveKit webhook event notifications
Enable LiveKit webhook feature to notify backend when events occur in rooms.
Configure LiveKit to call our endpoint whenever events are triggered,
providing real-time updates on room activities. Refer to LiveKit
documentation or LiveKitWebhookEventType enum for complete list of available
events.

This commit is not functionnal, LiveKit fails verifying our backend's
certificate. It will be fixed in the upcoming commits.
2025-03-07 17:05:06 +01:00
lebaudantoine 11c2c2dea8 (backend) expose event-handler matching service via dedicated endpoint
Add new endpoint to access the event-handler matching service. Route is
protected by LiveKit authentication, handle at the service level.

Enables webhook event processing through standardized API.
2025-03-07 17:05:06 +01:00
lebaudantoine d2f79d4524 (backend) introduce LiveKit event-handler matching service
Create new service that matches received events with their appropriate
handlers. Provides centralized system for event routing and processing
across the application.

If an event has no handler, it would be ignored.
2025-03-07 17:05:06 +01:00
lebaudantoine 2168643fd4 (backend) add lobby cache clearing method for meeting conclusion
Implement new lobby service method to clear all participant entries from cache.

Lays foundation for upcoming feature where participant permissions reset when
meetings end. Currently introduces only the cache clearing functionality;
event handling for meeting conclusion will be implemented in future commits
2025-03-07 17:05:06 +01:00
lebaudantoine 356797d326 🐛(frontend) resolve conflicting styles in SidePanel component
Fix regression caused by competing styling methods in Box component. Remove
duplicate position properties and standardize on simple div with css-in-js
approach to prevent style conflicts and unexpected layout behavior.
2025-03-06 00:08:45 +01:00
lebaudantoine 19b88a2078 🐛(frontend) handle overflow for usernames in waiting participant items
Implement text truncation for excessively long usernames in waiting
participant list items to prevent layout overflow and maintain consistent UI
appearance.
2025-03-05 22:27:49 +01:00
lebaudantoine b169e57193 🔥(frontend) delete accept/deny all group actions
Remove group action buttons for accept/deny all participants as they were not
included in the designer's mockups. Functionality may be reintroduced in a
future iteration based on user feedback.

Not necessary for this v1
2025-03-05 22:27:49 +01:00
lebaudantoine 5d81ba1e20 🚸(frontend) enhance waiting notification with temporary quick actions
Add adaptive content to existing notification that displays quick approval
action for the first 10 seconds when new participants request entry. Makes
room access management more efficient without requiring admin to open the
participant panel.

This approach could be apply to the two first participants waiting.
Let's discuss it with the designer.
2025-03-05 22:27:49 +01:00
lebaudantoine b248395cd6 (frontend) introduce usePrevious utility hook
Add new usePrevious utility hook to track previous values in functional
components. Enables comparing current and previous prop/state values across
renders for improved state management.
2025-03-05 22:27:49 +01:00
lebaudantoine 3eef4765df 🐛(backend) adjust throttle rate from hours to minutes for request_entry
Correct throttling configuration for request_entry endpoint from hours to
minutes. Previous setting of 150 requests per hour was insufficient as
participants query approximately once per second while in the lobby.
2025-03-05 22:27:49 +01:00
lebaudantoine ae920c0c9b ️(frontend) use higher contrast red for white text backgrounds
Replace current red with higher contrast variant when used as background with
white text to meet accessibility contrast requirements. Improves readability
for all users.
2025-03-05 15:10:42 +01:00
lebaudantoine 86cb10a3c7 💄(frontend) render side controls in the navigation menu earlier
Update the menu rendering to an earlier breakpoint due to added admin controls
taking up more space. Temporary adjustment until a more comprehensive layout
enhancement is implemented.
2025-03-05 15:10:42 +01:00
lebaudantoine 634b34f2e9 💄(frontend) make "more options" button more explicit
Change the "more options" button layout to horizontal orientation following
patterns used in Jitsi and Whereby. Improves discoverability and makes the
button's purpose more apparent to users.
2025-03-05 15:10:42 +01:00
lebaudantoine 33774a44d4 ️(frontend) improve switch component visual indicators per DSFR rules
Update switch component following accessibility consultant recommendations:
make indicator outlined instead of filled when not selected and add checkmark
when selected. Changes align with DSFR guidelines to improve state visibility.
2025-03-05 15:10:42 +01:00
lebaudantoine ce1c3d26d2 ️(frontend) hide under construction banner icon from screen readers
Add aria-hidden="true" attribute to the "site under construction" banner icon
to prevent it from being announced by screen readers. Improves accessibility
by avoiding unnecessary and potentially confusing vocalization
2025-03-05 15:10:42 +01:00
lebaudantoine b5e7d7eeec ️(frontend) update footer accessibility disclaimer text
Change footer text from "Accessibility: audit in progress" to "Accessibility:
non-compliant" to accurately reflect current status until formal audit is
completed. Provides more transparent information about accessibility
compliance.
2025-03-05 15:10:42 +01:00
lebaudantoine 50b9509c2c ️(frontend) update emoji hover background to meet RGAA contrast rules
Adjust background color of emoji hover state to ensure minimum visual
contrast ratio as recommended by accessibility consultant.
Change ensures compliance with RGAA accessibility standards.
2025-03-05 15:10:42 +01:00
lebaudantoine aaf1163910 🚸(frontend) add login hint for unauthenticated users in lobby
Add informational message suggesting authentication for users waiting in
lobby without being logged in. Highlight that logging in could grant
immediate access without admin approval when rooms have trusted
access level enabled.
2025-03-05 11:26:14 +01:00
lebaudantoine 00cd4fc92a (frontend) add trusted user option in admin panel access settings
Update admin panel interface to include the newly introduced trusted
user access level option alongside existing public and restricted settings.
Allows room administrators to select this intermediate permission level
through the frontend configuration panel.
2025-03-05 11:26:14 +01:00
lebaudantoine 0aa4f6389b (backend) add trusted user access level for rooms
Introduce new intermediate access level between public and restricted that
allows authenticated users to join rooms without admin approval. Not making
this the default level yet as current 12hr sessions would create painful
user experience for accessing rooms. Will reconsider default settings after
improving session management.

This access level definition may evolve to become stricter in the future,
potentially limiting access to authenticated users who share the same
organization as the room admin.
2025-03-05 11:26:14 +01:00
lebaudantoine e2f60775a9 ♻️(frontend) reduce over-mocking in lobby service unit tests
Replace excessive mocking with more realistic test scenarios to better
reflect actual code execution. Improves debuggability while maintaining
thorough test coverage.
2025-03-05 11:26:14 +01:00
lebaudantoine e20acfa5a9 🔒️(backend) limit user listing endpoint with security flag
Deactivate inherited user listing capability that allows authenticated users
to retrieve all application users in JSON format. This potentially unsecure
endpoint exposes user database to scraping and isn't currently used in the
application.

Implement security flag to disable access until properly refactored for
upcoming invitation feature. Will revisit and adapt endpoint behavior when
developing user invitation functionality.
2025-03-05 10:45:50 +01:00
lebaudantoine fac9435bc7 💄(frontend) adapt entry body text to match Robin's design
Update the styling of entry body text to align with Robin's design
specifications. Ensures consistent visual language throughout the
application.
2025-03-05 10:44:41 +01:00
lebaudantoine 3e9992bae3 💄(frontend) adapt loading spinner to match Robin's design
Update loading spinner component to follow Robin's design specifications,
ensuring visual consistency with the established design system.
2025-03-05 10:44:41 +01:00
lebaudantoine 25d4ede2dd 💬(frontend) rephrase entry text as question for politeness
Change entry text to interrogative form to make it sound more polite and
welcoming. Improves tone and friendliness of the user interface through
more considerate language.
2025-03-05 10:44:41 +01:00
lebaudantoine 6545ecf11a 🔒️(frontend) implement strict validation for user-provided metadata
Add comprehensive validation for metadata that can be input by users with
LiveKit access tokens. Handle all user-controlled metadata with extra care,
implementing strict checks to prevent injection attacks or other security
issues from malicious input.
2025-03-04 10:12:06 +01:00
lebaudantoine b73f18419b 🔒️(frontend) add HSL color format validation in metadata
Implement regex validation for HSL color format in notification metadata.
Ensures only properly formatted color values are accepted, preventing
potential injection or rendering issues from malformed color strings.
2025-03-04 10:12:06 +01:00
lebaudantoine 49163eba67 🔒️(frontend) enhance notification data decoding with improved validation
Strengthen decodeNotificationDataReceived function with additional validation
to properly handle malicious input. Ensures application security when
processing potentially dangerous notification data from untrusted sources.
2025-03-04 10:12:06 +01:00
lebaudantoine 38c3776556 ⬆️(dependencies) update js dependencies 2025-03-03 23:39:58 +01:00
lebaudantoine 75e4092dad 💚(ci) add Redis requirement for backend tests
Redis was made a required dependency for running project tests. Update CI
environment to include Redis instance as tests now depend on it for proper
execution. Affects all backend test suites.

This dependency was intorduced by the lobby service.
2025-03-03 21:48:22 +01:00
lebaudantoine 2774d76176 (frontend) add room access configuration options
Implement interface allowing room creators to configure access settings,
with options to set rooms as either public or restricted. Provides users
with control over who can join their rooms.
2025-03-03 21:48:22 +01:00
lebaudantoine da05438f45 (frontend) add description support to radio field component
Enhance radio field component with description capability to provide
additional context and improve form accessibility and usability.
2025-03-03 21:48:22 +01:00
lebaudantoine 5d89efec78 (frontend) add admin side panel for room management
Introduce new dedicated side panel for room administration, providing
centralized interface for admins to manage room settings and participants.
Content will be added in the upcoming commits.
2025-03-03 21:48:22 +01:00
lebaudantoine 7c46029f87 (frontend) add margin option to Text primitive component
Extend Text primitive component with new margin option to provide more
flexibility in layout spacing without requiring wrapper components.
2025-03-03 21:48:22 +01:00
lebaudantoine e535040ac6 (frontend) implement persistent notifications for waiting participants
Add special non-closing notifications for waiting participants that remain
visible until all have been reviewed. Implement complementary system alongside
existing toaster notifications.

Designed with accessibility in mind but would benefit from expert review.
Current UX provides good foundation with quick actions planned for v2.
2025-03-03 21:48:22 +01:00
lebaudantoine 92851b10cc ♻️(frontend) extract notification decoding logic for reusability
Extract notification decoding functionality into a reusable module to
improve enable consistent notification handling across
the frontend application.
2025-03-03 21:48:22 +01:00
lebaudantoine ea37a3154e 🚧(frontend) add basic loading spinner from react-aria
Add raw loading spinner component from react-aria library to handle
loading states. Will refine styling and appearance after receiving design
review feedback.
2025-03-03 21:48:22 +01:00
lebaudantoine 65ddf2e2a1 (frontend) add waiting participants list for room admins
Implement list showing waiting participants for admins already in the room.
Initial fetch on render, then stops polling if empty until LiveKit emits event
for new arrivals. Uses long polling with configurable timeouts to prevent UI
flicker. Focus on UX implementation with responsive layout issues remaining
for long participant names.
2025-03-03 21:48:22 +01:00
lebaudantoine a48501bc02 (frontend) update Join component to support lobby system
Update Join component to integrate with the newly introduced lobby system.
Current implementation has functional UX but UI elements and copywriting
still need refinement and will be polished in upcoming commits.
2025-03-03 21:48:22 +01:00
lebaudantoine 4d961ed162 🚧(backend) introduce a lobby system
Implement lobby service using cache as LiveKit doesn't natively support
secure lobby functionality. Their teams recommended to create our own
system in our app's backend.

The lobby system is totally independant of the DRF session IDs,
making the request_entry endpoint authentication agnostic.

This decoupling prevents future DRF changes from breaking lobby functionality
and makes participant tracking more explicit.

Security audit is needed as current LiveKit tokens have excessive privileges
for unprivileged users. I'll offer more option ASAP for the admin to control
participant privileges.

Race condition handling also requires improvements, but should not be critical
at this point.

A great enhancement, would be to add a webhook, notifying the backend when the
room is closed, to reset cache.

This commit makes redis a prerequesite to run the suite of tests. The readme
and CI will be updated in dedicated commits.
2025-03-03 21:48:22 +01:00
lebaudantoine 710d7964ee ♻️(backend) extract LiveKit connection info generation function
Extract serialization logic for LiveKit server connection data to make it
reusable across endpoints. Function naming will be improved in future
refactoring when utility functions are moved to a proper service.
2025-03-03 21:48:22 +01:00
lebaudantoine 01f4d05d6b ♻️(backend) replace is_public with access_level field
Replace unused is_public boolean field with access_level to allow for more
granular control. Initially maintains public/restricted functionality while
enabling future addition of "trusted" access level.
2025-03-03 21:48:22 +01:00
lebaudantoine 7fad60d9a9 📝(backend) use certifi certificate for livekit-api dependency
LiveKit uses aiohttp which relies on the ssl module under the hood.
Set certificate file using an env variable, similar to @rouja's fix
for the request module.

This tweak applies only in the dev environment.
2025-03-03 21:48:22 +01:00
dependabot[bot] e9ebac46ac ⬆️ (mail) bump braces from 3.0.2 to 3.0.3 in /src/mail
Update indirect dependency braces from 3.0.2 to 3.0.3.
See changelog: https://github.com/micromatch/braces/blob/master/CHANGELOG.md
2025-03-03 17:37:04 +01:00
lebaudantoine d64e5d1923 🐛(CI) sync CI Python version with Docker image Python version
Update CI environment to use the same Python version as our Docker image.
Issue surfaced when upgrading IPython to v9, which requires Python 11.
Ensures consistent runtime behavior between CI tests and production.
2025-03-03 17:31:01 +01:00
renovate[bot] c9512004ac ⬆️(dependencies) update python dependencies 2025-03-03 17:31:01 +01:00
lebaudantoine 879cf20891 📈(frontend) add data attributes to key UI buttons
Add data attributes to important buttons in the frontend to enable tracking
through PostHog actions. Allows measurement of user interaction with key
features to inform future product decisions.
2025-02-28 17:17:00 +01:00
lebaudantoine 433a3a7cdf (frontend) add email collection step for anonymous survey responses
Add optional email collection step for anonymous users submitting survey
responses. Store email in new "unsafe_email" property on PostHog user
profile to enable follow-up troubleshooting while maintaining anonymity.
Addresses inability to contact users reporting issues without accounts.
2025-02-28 17:17:00 +01:00
lebaudantoine c0bcced3c0 🐛(frontend) fix logout issues affecting support and analytics sessions
Fix logout functionality that was failing in 2 out of 3 scenarios where
support and analytics sessions weren't properly closed. Prevents bugs and
behavioral issues when enabling feature flags or advanced PostHog features.
2025-02-28 17:17:00 +01:00
lebaudantoine 4045662433 💄(frontend) adjust message notification size for better readability
Increase message notification size to improve text readability when
notifications appear, enhancing user experience by making content more
visible at a glance.
2025-02-28 17:17:00 +01:00
lebaudantoine e7f11194ce 📝(frontend) add technical network requirements documentation
Add link to technical notice for our videoconference product describing
network engineering details, required ports, and IP addresses that need to be
allowed in client systems. Helps users properly configure their environments.
2025-02-28 09:41:54 +01:00
lebaudantoine 4955f3eea7 🔒️(frontend) validate emoji in notifications to prevent forbidden emoji
Add validation for emoji received through notifications to ensure
participants cannot send forbidden emoji characters. Improves security
by filtering potentially problematic content before display.
2025-02-28 09:23:15 +01:00
lebaudantoine c576a75660 🔥(frontend) delete dead code in emoji reaction component
Remove unused code in emoji reaction feature that was mistakenly introduced
in previous commits. Clean up codebase to improve clarity and maintenance.
2025-02-28 09:23:15 +01:00
renovate[bot] 51fa4e84e4 ⬆️(dependencies) update python dependencies 2025-02-27 11:27:22 +01:00
lebaudantoine eec6a46883 🚸(frontend) make explicit why a page is not found
Enhance the error message based on @spaccoud's feedback,
explain to the user why a page is not found.
2025-02-26 19:02:04 +01:00
lebaudantoine 19a240c63f 🩹(frontend) make "not found screen" accessible again
With the recent introduction of SDK, the route "not found screen" was broken.
Fixed it.
2025-02-26 19:02:04 +01:00
lebaudantoine 2236674849 🐛(frontend) correct Keycloak logout endpoint in tilt stack
Replace invalid session/end endpoint with correct logout endpoint in Keycloak
configuration. Fixes broken logout functionality that prevented developers
from properly signing out of the application during development.
2025-02-26 18:51:26 +01:00
lebaudantoine e5fe81de4d 🐛(frontend) preserve line breaks and whitespace in chat messages
Add CSS styling to preserve return lines and whitespace in chat messages,
fixing display issues that were causing broken line formatting.
2025-02-26 18:18:09 +01:00
lebaudantoine 38ab001bcf ️(frontend) allow the reaction toolbar to be accessible by keyboard
Fix an accessibility issue. Reaction wasn't accessible using keyboard.
This fix is imperfect, the animation doesn't perflectly replicate the
menu one.
2025-02-26 18:06:08 +01:00
lebaudantoine 14885e0ffa 🔖(minor) bump release to 0.1.14
Release emoji reactions.
2025-02-24 12:33:52 +01:00
lebaudantoine 054e7ba945 ️(tilt) clean automatically old images
Tilt live updates generate a new image for each change, ending up storing
a lot of images when you are really developing with Tilt.

I have not found a built-in way of cleaning old images from Tilt documentation,
I create a utility doing the dirty work.
2025-02-24 11:05:52 +01:00
lebaudantoine 25506b6a2a 🩹(frontend) fix missing key in reactions toolbar
Emojis are a constant, so the reaction index remains stable,
but using the index as a key isn't ideal.
2025-02-22 17:43:25 +01:00
lebaudantoine 0498a9e7f9 🩹(frontend) preload notifications translations to prevent flickering
The notifications namespace was being lazy-loaded when the first notification
appeared, causing a screen flicker during translation loading. Now preloading
the namespace during i18n initialization to ensure smooth rendering.
2025-02-21 19:19:13 +01:00
lebaudantoine 0a63270154 🚸(frontend) offer a "clear effect" option
Based on user feedbacks, allow them stopping an effect
explicitly by pressing a "no effect" option.
2025-02-21 18:14:17 +01:00
Arnaud Robin b962dddbf2 (frontend) add emoji reactions feature
Implement a new reactions system allowing users
to send quick emoji responses during video calls.
This feature is present in most visio softwares.

Particulary this commit:
- Add ReactionsButton to control bar with emoji selection
- Support floating emoji animations
2025-02-21 13:49:38 +01:00
lebaudantoine 16929bcc83 ♻️(frontend) allow passing placement to a menu
Props supported by react aria. Introduce it.
2025-02-21 13:49:38 +01:00
lebaudantoine 354bdd8bfe 🩹(frontend) fix screen sharing warnings browser compatibility
This feature is far from perfect. Still not working on Safari.
Also, we should display this warning when user share the current
opened window. Minor enhancement, I don't have time currently
to prioritize these enhancements.
2025-02-21 12:14:06 +01:00
Jacques ROUSSEL ccca2b9472 🔧(ci) fix argocd notification
Argocd deployment use numerique-gouv/lasuite-deploiement as source so
the webhook need to tell argocd to refresh apps that use this repos
2025-02-21 11:21:01 +01:00
lebaudantoine 9fc5681137 🩹(frontend) fix visual regression on Ratings buttons
Visual regression was introduced probably by a change on button style.
As this button is quite different than the usual one, remove its inheritance
from the primitive ones.
2025-02-20 19:00:46 +01:00
renovate[bot] edf29076ba ⬆️(dependencies) update vite to v5.4.12 [SECURITY] 2025-02-20 10:31:55 +01:00
lebaudantoine 337181add8 🩹(frontend) fix text overflow in message notification
Minor issue, layout overflow occurred when sharing url or
long text.
2025-02-19 22:47:49 +01:00
lebaudantoine 5af64e539d ♻️(frontend) refactor toast selection to use a switch
More extensible design. Still a poor piece of code.
Technical debt here.
2025-02-19 22:47:03 +01:00
lebaudantoine 291544bd52 (frontend) lower hand autonomously for active speakers
Users requested that their raised hands be lowered automatically if they become
the active speaker. This change ensures a more natural experience during
discussions, preventing user from forgetting to lower their hand while speaking.
2025-02-19 22:47:03 +01:00
lebaudantoine 13bb6acdf6 ♻️(frontend) extract notification duration in a proper file
As for notification's type, extract notification's duration in a
dedicated file.
2025-02-19 22:47:03 +01:00
lebaudantoine aa2783ae2e 🚸(frontend) auto-hide participant controls after 3s of inactivity
Auto-hides participant tile controls after 3 seconds of cursor inactivity
to reduce visual clutter. Controls reappear on cursor movement.
2025-02-19 22:37:48 +01:00
Nathan Vasse c3e4ea0fd1 🔧(ci) add sdk to workflow
Add basic jobs to handle the new sdk.
2025-02-18 16:16:07 +01:00
Nathan Vasse 183e8f6a72 (sdk) add sdk projects
It includes a consumer project which is simply a demo app. The sdk
project includes the first version of the SDK, with a light React
implementation. The first version lays down the foundations of
the iframe sdk framework. The npm package logic is also already
ready to be published.
2025-02-18 16:16:07 +01:00
Nathan Vasse 452dbe8bba (front) add sdk related routes and logic
The create room button is a dedicated route. There is also a bit of
logic implied in this commit, including the BroadcastChannel.
The router has been updated with a /sdk negation in order to avoid
including support and react query debug tool in the iframe.
2025-02-18 16:16:07 +01:00
Nathan Vasse 8818d12ee9 ♻️(front) add option to disable silent login
When displayed in an iframe, not being logged-in causes a redirection
to the home page. Which is not what we want with the SDK integration.
We just want to stay on the same page.
2025-02-18 16:16:07 +01:00
Nathan Vasse fb0c9b766d (front) add quaternary variant to Button
This variant is needed to implement the sdk create room button.
2025-02-18 16:16:07 +01:00
Nathan Vasse ba873196c7 (front) add loader and icon props to Button
These shortcuts improve DX and will be used in the next commits.
2025-02-18 16:16:07 +01:00
Jacques ROUSSEL 48937bb7a3 ♻️(helm) fix helm chart for keycloak stack
Ingress stop working, so this commit fix it
2025-02-14 11:51:31 +01:00
lebaudantoine 6f09eb3602 🚸(frontend) add warning for full screen capture
Following user feedback about infinite loops risks, add warning overlay
when users attempt to share their entire screen. The warning explains
the risk and suggests sharing a specific tab instead, providing options
to stop sharing or dismiss the warning while respecting user preferences
for future sessions.
2025-02-13 16:30:22 +01:00
lebaudantoine b7fb6b1b69 🔖(patch) bump release to 0.1.13 2025-02-12 12:35:55 +01:00
Jacques ROUSSEL 2cd4a6efa8 (helm) add pdbs to deployments
In order to avoid a service interruption during a Kubernetes (k8s)
upgrade, we add a Pod Disruption Budget (PDB) to deployments.
2025-02-12 11:54:08 +01:00
lebaudantoine b5037db685 🔒️(docker) patch libssl3 and libcrypto3 to address CVE-2024-12797
Added temporary root privileges to update OpenSSL libraries. Upgrades libssl3
and libcrypto3 to 3.3.3-r0 to fix HIGH severity vulnerability. Properly
switches back to nginx user after updates. Maintains unprivileged execution
while addressing security concern affecting RFC7250 Raw Public Keys
authentication.

Security: CVE-2024-12797
2025-02-12 11:52:40 +01:00
lebaudantoine 19804d2e3f (frontend) add sound notification for unread messages
Add togglable audio alerts when new chat messages arrive,
allowing users to customize their notification preferences.
2025-02-12 11:52:40 +01:00
lebaudantoine a979f05549 🐛(frontend) preserve notification preferences while merging new types
When loading notification settings from localStorage,
keep user preferences for existing notification types while adding
new notification types with default values.

If a notification type is removed, make sure to get rid of it.
My initial implementation wasn't future proof.
2025-02-12 11:52:40 +01:00
lebaudantoine 09a0496d25 ♻️(frontend) standardize toast notification durations
Introduce ToastDuration enum to replace magic numbers for notification
timeouts. Added semantic duration constants for different notification types
(alerts, messages, join/leave events). Improves maintainability and
consistency across toast implementations.
2025-02-12 11:52:40 +01:00
lebaudantoine da95e804a0 (frontend) add toast notifications for unread chat messages
Users frequently miss chat messages due to discrete visual indicators.
Implemented toast notifications showing sender name and message preview to
improve visibility. Added message tracking and auto-dismiss when chat panel
opens.

Remove the warning in handleDataReceived function, it was triggered by
chat message events.
2025-02-12 11:52:40 +01:00
lebaudantoine 591a3a5d8b (frontend) notify user when her was muted
Add notification system to handle user mute events. Implement extensible
design to support future notification types. Set notification duration to 3s.
2025-02-12 11:52:40 +01:00
lebaudantoine 30ed0da416 (frontend) introduce a new notification type
This notification alert the user when her was muted by another one.
Simple copywritting.
2025-02-12 11:52:40 +01:00
lebaudantoine bd4fcc2a5e (frontend) notify participant when her was muted
Make sure a participant is notified when another one muted her.
It will help display a notification to alert the participant.
2025-02-12 11:52:40 +01:00
lebaudantoine fc36ae8b49 🐛(frontend) fix useRoomData when a participant's name has changed
The participant's name in the query key prevented proper cache invalidation
when renamed. Since name changes don't affect query data, removed this
dependency.
2025-02-12 11:52:40 +01:00
lebaudantoine 87baca247b 🚨(frontend) fix warning component from uncontrolled to controlled
When the hand has never been raised yet, the value is undefined and
not false, which leads to a react aria warning.

It's a bad practice. Fix it.
2025-02-12 11:52:40 +01:00
lebaudantoine 42d3846660 ✏️(frontend) fix minor typo
Typo identified by Celine. No "e" in french.
2025-02-11 12:04:17 +01:00
lebaudantoine 2d393f9f70 (frontend) add controls for full screen screenshare
Requested by several users.
Inspired by Jitsi.

Yet imperfect implementation. Controls on video element of
a screenshare should be disabled.
2025-02-11 12:01:23 +01:00
lebaudantoine b4b4ff79d9 ♻️(frontend) refactor full screen to allow video args
useFullScreen hook is now generic, it allows passing a specific
video element to zoom in.

Needed to zoom a specific video track.
2025-02-11 12:01:23 +01:00
lebaudantoine 861244ce01 💄(frontend) update cursor on disabled button
When a button is disabled, it's misleading to show a pointer.
Update it to the default pointer.
2025-02-11 12:01:23 +01:00
lebaudantoine 6373593de3 🚸(frontend) offer additionnal controls on participant tile
On hover, based on participant's type (remove/local) offer an
appropriate action. Either applying effects on the local participant
video or muting the remote participant.

This is a huge enhancement in term of UX, nobody was finding these two
controls in the current menus, and though the features were not
implemented.
2025-02-11 12:01:23 +01:00
lebaudantoine 03f6e6519b 🩹(frontend) remove warning on modal without heading
The MuteAlertDialog was triggering accessibility warnings, lacking
a proper heading. Fix it.
2025-02-11 12:01:23 +01:00
lebaudantoine 716d40dd4e 🎨(frontend) avoid duplicating prefix in i18n's key
Simple enhancement. Avoid duplicating prefix.
2025-02-11 12:01:23 +01:00
lebaudantoine 17f8ec6319 🚸(frontend) refactor participant tile while being hovered
Inspired by GMeet. Make central actions available on a participant
tile when a user hover it.

This new interactive zone will be extended with more actions and
controls.
2025-02-11 12:01:23 +01:00
lebaudantoine 0b74cf96f2 ♻️(frontend) extract MuteAlertDialog in a dedicated component
Will be reused in actions displayed when a participant tile
is focused.
2025-02-11 12:01:23 +01:00
Jacques ROUSSEL 723b8718f9 🔐(helm) bump chart version
Bump chart version to publish a new one with evolution
2025-02-05 22:20:49 +01:00
Nathan Vasse 07a44dab36 (front) turn on camera when using effects
We want to automatically turn on the camera if its off when
enabling a new effect.
2025-02-05 11:57:00 +01:00
Nathan Vasse e47b027bbc (front) add loading state on effects component
useSyncAfterDelay allows to enable loading indicators only if the
loading takes more than a specific time. It prevent blinking
effect when the loading time is nearly instant.
2025-02-05 11:57:00 +01:00
Nathan Vasse d7a4f3946c (front) add Loader component
This component will be used for various purpose.
2025-02-05 11:57:00 +01:00
Jacques ROUSSEL 1b7523bbf1 💚(github) fix argocd notification
Use the right variable for webhook url
2025-02-05 11:53:56 +01:00
Jacques ROUSSEL 4326df4b6a 💚(github) fix argocd notification
Fix double simple quote issue on argocd notification job
2025-02-05 11:48:38 +01:00
Jacques ROUSSEL 564d31ab49 💚(github) remove secret fetch
The secrets are not managed in the folder anymore.
2025-02-05 11:41:37 +01:00
lebaudantoine 6e0948c696 🩹(frontend) prevent support toggle when Crisp chat is blocked
Some users have ad-blockers or privacy extensions that prevent the Crisp chat
widget script from loading properly. This was causing the support toggle to
still display in rooms, but clicking it had no effect since Crisp was blocked.

This ensures users don't see an inactive support button when their browser
is blocking Crisp functionality.
2025-01-31 17:01:57 +01:00
Nathan Vasse eff0cb4afb (front) implement new ui of EffectsConfiguration
This new ui implement the new sketches and also enables the usage of
virtual background in a nice way.
2025-01-31 16:52:44 +01:00
Nathan Vasse 08c5245b48 (front) handle virtual background in custom processor
BackgroundBlurCustomProcessor is renamed to BackgroundCustomProcessor in
order to reflect the fact that is now handles virtual backgrounds too.
BackgroundBlurFactory is also renamed to BackgroundProcessorFactory.
The processor serialization handling has also been updated in order
to support various options, also if persisted in local storage.
2025-01-31 16:52:44 +01:00
Nathan Vasse 465bf293f0 (front) add BackgroundVirtualTrackProcessorJsWrapper
This is a wrapper around track-processor-js virtual background
processor. This is needed in order to be used in a generic way
in our code between firefox processors.
2025-01-31 16:52:44 +01:00
lebaudantoine db51ca1aa5 🩹(frontend) fix border radius issue on safari
I introduced a bug while moving the border radius css style to the
parent element of the video.

On safari, the video element wasn't rounded anymore.

Fix this! Please note our approach should be refactored, nit-picking,
but there are few pixels leaking from the black background on the
video corner.
2025-01-31 14:54:26 +01:00
lebaudantoine 6935001aab 🚸(frontend) disable few native html video option
Disable Picture-In-Picture option for browsers that support it,
to avoid having the option appearing on the video element.

It's not appropriate.
Actually, I am not sure we should disable remote playback ones,
feel free to challenge it.
2025-01-31 14:54:26 +01:00
lebaudantoine bbc2b1d9f6 🚸(frontend) avoid displaying the effects option when not optimal
On unsupported browser showing this option whitout offering the
blur effects to the user would be quite frustrating.

At the moment, safari user cannot blur their background.

Also, avoid offering the option on mobile which are really
cpu-constrained devices.
2025-01-31 14:54:26 +01:00
lebaudantoine 12e2149b9e ♻️(frontend) extract effects-related logic in a component
Wraps all logics related to the effects on prejoin screen in a
dedicated component.
2025-01-31 14:54:26 +01:00
lebaudantoine 3bd9363879 🔍️(frontend) add link to the source code in the footer
Great idea suggested by @fflorent — totally worth it!
2025-01-31 14:30:52 +01:00
lebaudantoine b48135c3b6 🚸(frontend) replace effects icons in menu option
Align effects icon with the one from the newly refactored pre-join screen.
This new icon is stylish.
2025-01-31 14:21:48 +01:00
Nathan Vasse 206e74c645 (front) add blurring feature on join
This adds a button that opens a modal that allow user to enable
video effects on join screen.
2025-01-31 11:42:28 +01:00
Nathan Vasse 9ebfc8ea29 ♻️(front) refactor Effects
We need to have an agnostic component to apply effects on any video
track, enters EffectsConfiguration. It takes in input the video
track and outputs the processor applied. This is gonna be used in
order to use the same component on the pre join screen and the in
room customization side panel.
2025-01-31 11:42:28 +01:00
Nathan Vasse 03796fcbb2 (front) add button white variant
This variant is gonna be used on overlay over a video element.
2025-01-31 11:42:28 +01:00
Nathan Vasse 4b6bd3d1c8 ♻️(front) enable custom size on Dialog
We want to have a Dialog that is constrained in width. Thus,
introducing this variant enables this possibility and many more
in the future.
2025-01-31 11:42:28 +01:00
Nathan Vasse d45880ab5c ♻️(front) add clone method to processors
We need to make new processor instance between pre join and room
in order to apply effects.
2025-01-31 11:42:28 +01:00
lebaudantoine 4ae3d965f9 🚸(frontend) show support toggle only if support is enabled
Avoid misleading the user, if support is not enabled, as it's optional,
avoid displaying a useless control button.

Requested by Dutch counterparts.
2025-01-29 16:12:47 +01:00
lebaudantoine 8eab45b6d5 🩹(frontend) prevent runtime error when Crisp is not initialized
Prevents runtime error when Crisp chat hasn't initialized before component mount
Previously caused crash since we assumed $crisp global was always available.
2025-01-29 16:12:47 +01:00
405 changed files with 55779 additions and 5316 deletions
+4 -21
View File
@@ -11,34 +11,17 @@ jobs:
notify-argocd:
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: "meet,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: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_PRODUCTION_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_PRODUCTION_WEBHOOK_URL
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_WEBHOOK_URL }}
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_URL }}
start-test-on-preprod:
needs:
+9 -82
View File
@@ -19,26 +19,9 @@ jobs:
build-and-push-backend:
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: "meet,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: Docker meta
id: meta
@@ -48,7 +31,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
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
@@ -69,26 +52,9 @@ jobs:
build-and-push-frontend:
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: "meet,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: Docker meta
id: meta
@@ -98,7 +64,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
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
@@ -120,26 +86,9 @@ jobs:
build-and-push-summary:
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: "meet,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: Docker meta
id: meta
@@ -149,7 +98,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Build and push
uses: docker/build-push-action@v6
@@ -162,39 +111,17 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
notify-argocd:
needs:
- build-and-push-frontend
- build-and-push-backend
- build-and-push-summary
runs-on: ubuntu-latest
if: |
github.event_name != 'pull_request'
if: github.event_name != 'pull_request'
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
- uses: numerique-gouv/action-argocd-webhook-notification@main
id: notify
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "meet,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: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
argocd_webhook_secret: "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}"
argocd_url: "${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}"
+81 -9
View File
@@ -81,7 +81,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
python-version: "3.12"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
@@ -110,6 +110,16 @@ jobs:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
redis:
image: redis:5
ports:
- 6379:6379
# Set health checks to wait until redis has started
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DJANGO_CONFIGURATION: Test
@@ -121,9 +131,13 @@ jobs:
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
REDIS_URL: redis://localhost:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
LIVEKIT_API_SECRET: secret
LIVEKIT_API_KEY: devkey
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
steps:
- name: Checkout repository
@@ -141,10 +155,37 @@ jobs:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Start MinIO
run: |
docker pull minio/minio
docker run -d --name minio \
-p 9000:9000 \
-e "MINIO_ACCESS_KEY=meet" \
-e "MINIO_SECRET_KEY=password" \
-v /data/media:/data \
minio/minio server --console-address :9001 /data
# Tool to wait for a service to be ready
- name: Install Dockerize
run: |
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
- name: Wait for MinIO to be ready
run: |
dockerize -wait tcp://localhost:9000 -timeout 10s
- name: Configure MinIO
run: |
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
docker exec ${MINIO} sh -c \
"mc alias set meet http://localhost:9000 meet password && \
mc alias ls && \
mc mb meet/meet-media-storage"
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
python-version: "3.12"
- name: Install development dependencies
run: pip install --user .[dev]
@@ -175,25 +216,56 @@ jobs:
- name: Check format
run: cd src/frontend/ && npm run check
lint-sdk:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/sdk/library
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Check linting
run: npm run lint
- name: Check format
run: npm run check
build-sdk:
runs-on: ubuntu-latest
needs: lint-sdk
defaults:
run:
working-directory: src/sdk/library
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Build SDK
run: npm run build
i18n-crowdin:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
- 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
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
- name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
@@ -207,7 +279,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
python-version: "3.12"
- name: Install development dependencies
working-directory: src/backend
+1 -3
View File
@@ -3,10 +3,8 @@ run-name: Release Chart
on:
push:
branches:
- 'main'
paths:
- ./src/helm/meet/**
- src/helm/meet/**
jobs:
release:
+3
View File
@@ -79,3 +79,6 @@ db.sqlite3
# Egress output
docker/livekit/out
# LiveKit CA configuration
docker/livekit/rootCA.pem
-3
View File
@@ -1,3 +0,0 @@
[submodule "secrets"]
path = secrets
url = ../secrets
+1
View File
@@ -1,3 +1,4 @@
# Changelog
All notable changes to this project will be documented in this file.
+76
View File
@@ -0,0 +1,76 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at visio@numerique.gouv.fr. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [PostHog Code of Conduct](https://github.com/PostHog/posthog/blob/master/CODE_OF_CONDUCT.md), inspired from Contributor Covenant version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq
+77
View File
@@ -0,0 +1,77 @@
# Contributing to the Project
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/meet/blob/main/README.md) for detailed instructions.
Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices.
## Creating an Issue
When creating an issue, please provide the following details:
1. **Title**: A concise and descriptive title for the issue.
2. **Description**: A detailed explanation of the issue, including relevant context or screenshots if applicable.
3. **Steps to Reproduce**: If the issue is a bug, include the steps needed to reproduce the problem.
4. **Expected vs. Actual Behavior**: Describe what you expected to happen and what actually happened.
5. **Labels**: Add appropriate labels to categorize the issue (e.g., bug, feature request, documentation).
## Commit Message Format
All commit messages must adhere to the following format:
`<gitmoji>(type) title description`
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
* **title**: A short, descriptive title for the change, starting with a lowercase character.
* **description**: Include additional details about what was changed and why.
### Example Commit Message
```
✨(frontend) add user authentication logic
Implemented login and signup features, and integrated OAuth2 for social login.
```
## Changelog Update
Please add a line to the changelog describing your development. The changelog entry should include a brief summary of the changes, this helps in tracking changes effectively and keeping everyone informed. We usually include the title of the pull request, followed by the pull request ID to finish the log entry. The changelog line should be less than 80 characters in total.
### Example Changelog Message
```
## [Unreleased]
## Added
- ✨(frontend) add AI to the project #321
```
## Pull Requests
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes.
### Don't forget to:
- check your commits
- check the linting: `make lint && make frontend-lint`
- check the tests: `make test`
- add a changelog entry
Once all the required tests have passed, you can request a review from the project maintainers.
## Code Style
Please maintain consistency in code style. Run any linting tools available to make sure the code is clean and follows the project's conventions.
## Tests
Make sure that all new features or fixes have corresponding tests. Run the test suite before pushing your changes to ensure that nothing is broken.
## Asking for Help
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
Thank you for your contributions! 👍
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2023 Direction Interministérielle du Numérique - Gouvernement Français
Copyright (c) 2024-2025 DINUM/Etalab
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+79
View File
@@ -0,0 +1,79 @@
# LICENCE OUVERTE 2.0/OPEN LICENCE 2.0
## Réutilisation de l’« Information » sous cette licence
Le « Concédant » concède au « Réutilisateur » un droit non exclusif et gratuit de libre « Réutilisation » de l’« Information » objet de la présente licence, à des fins commerciales ou non, dans le monde entier et pour une durée illimitée, dans les conditions exprimées ci-dessous.
**Le « Réutilisateur » est libre de réutiliser l’« Information » :**
- de la communiquer, la reproduire, la copier ;
- de ladapter, la modifier, lextraire et la transformer, notamment pour créer des « Informations dérivées » ;
- de la diffuser, la redistribuer, la publier et la transmettre, de lexploiter à titre commercial, par exemple en la combinant avec dautres informations, ou en lincluant dans votre propre produit ou application.
**Sous réserve de :**
- mentionner la paternité de l’«Information» : sa source (a minima le nom du « Concédant ») et la date de la dernière mise à jour de l’« Information » réutilisée.
Le « Réutilisateur » peut notamment s acquitter de cette condition en indiquant ladresse (URL) renvoyant vers « lInformation » et assurant une mention effective de sa paternité.
**Par exemple :**
Dans le cas dune réutilisation de la base SIRENE de lINSEE, mentionner lURL du « Concédant » : www.insee.fr + la date de dernière mise à jour de lInformation réutilisée.
Cette mention de paternité ne doit ni conférer un caractère officiel à la « Réutilisation » de l’« Information », ni suggérer une quelconque reconnaissance ou caution par le « Concédant », ou par toute autre entité publique, du « Réutilisateur » ou de sa « Réutilisation ».
## Données à caractère personnel
L’« Information » mise à disposition peut contenir des « Données à caractère personnel » pouvant faire lobjet dune « Réutilisation ». Alors, le « Concédant » informe le « Réutilisateur » (par tous moyens) de leur présence, l « Information » peut être librement réutilisée, sans faire obstacle aux libertés accordées par la présente licence, à condition de respecter le cadre légal relatif à la protection des données à caractère personnel.
## Droits de propriété intellectuelle
Il est garanti au « Réutilisateur » que l « Information » ne contient pas de « Droits de propriété intellectuelle » appartenant à des tiers qui pourraient faire obstacle aux libertés qui lui sont accordées par la présente licence.
Les éventuels « Droits de propriété intellectuelle » détenus par le « Concédant » sur l « Information » ne font pas obstacle aux libertés qui sont accordées par la présente licence. Lorsque le « Concédant » détient des « Droits de propriété intellectuelle » » sur l « Information », il les cède au « Réutilisateur » de façon non exclusive, à titre gracieux, pour le monde entier, pour toute la durée des « Droits de propriété intellectuelle », et le « Réutilisateur » peut en faire tout usage conformément aux libertés et aux conditions définies par la présente licence.
## Responsabilité
L «Information» est mise à disposition telle que produite ou reçue, sans autre garantie expresse ou tacite qui nest pas prévue par la présente licence. Labsence de défauts ou derreurs éventuellement contenues dans l «Information», comme la fourniture continue de l « Information » nest pas garantie par le «Concédant». Il ne peut être tenu pour responsable de toute perte, préjudice ou dommage de quelque sorte causé à des tiers du fait de la « Réutilisation ».
Le « Réutilisateur » est seul responsable de la « Réutilisation » de l’« Information ».
La « Réutilisation » ne doit pas induire en erreur des tiers quant au contenu de l’« Information », sa source et sa date de mise à jour.
## Droit applicable
La présente licence est régie par le droit français.
### Compatibilité de la présente licence
Elle a été conçue pour être compatible avec toute licence libre qui exige _a minima_ la mention de paternité. Elle est notamment compatible avec la version antérieure de la présente licence ainsi quavec les licences « Open Government Licence » (OGL) du Royaume-Uni, « Creative Commons Attribution » (CC-BY) de Creative Commons et « Open Data Commons Attribution » (ODC-BY) de lOpen Knowledge Foundation.
## Définitions
Sont considérés, au sens de la présente licence comme :
- Le « **Concédant** » : toute personne concédant un droit de « Réutilisation » sur l’« Information » dans les libertés et les conditions prévues par la présente licence.
- L’« **Information** » :
- toute information publique figurant dans des documents communiqués ou publiés par une administration mentionnée au premier alinéa de larticle L.300-2 du CRPA ;
- toute information mise à disposition par toute personne selon les termes et conditions de la présente licence.
- La « **Réutilisation** » : lutilisation de l’« Information » à dautres fins que celles pour lesquelles elle a été produite ou reçue.
- Le « **Réutilisateur** » : toute personne qui réutilise les « Informations » conformément aux conditions de la présente licence.
- Des « **Données à caractère personnel** » : toute information se rapportant à une personne physique identifiée ou identifiable, pouvant être identifiée directement ou indirectement. Leur « Réutilisation » est subordonnée au respect du cadre juridique en vigueur.
- Une « **Information dérivée** » : toute nouvelle donnée ou information créées directement à partir de l’« Information » ou à partir dune combinaison de l « Information » et dautres données ou informations non soumises à cette licence.
- Les « **Droits de propriété intellectuelle** » : tous droits identifiés comme tels par le Code de la propriété intellectuelle (droit dauteur, droits voisins au droit dauteur, droit sui generis des producteurs de bases de données).
## À propos de cette licence
La présente licence a vocation à être utilisée par les administrations pour la réutilisation de leurs informations publiques. Elle peut également être utilisée par toute personne souhaitant mettre à disposition de l’« Information » dans les conditions définies par la présente licence
La France est dotée dun cadre juridique global visant à une diffusion spontanée par les administrations de leurs informations publiques afin den permettre la plus large réutilisation.
Le droit de la « Réutilisation » de l’« Information » des administrations est régi par le code des relations entre le public et ladministration (CRPA) et, le cas échéant, le code du patrimoine (livre II relatif aux archives).
Cette licence facilite la réutilisation libre et gratuite des informations publiques et figure parmi les licences qui peuvent être utilisées par ladministration en vertu du décret pris en application de larticle L.323-2 du CRPA.
Etalab est la mission chargée, sous lautorité du Premier ministre, douvrir le plus grand nombre de données publiques des administrations de l’État et de ses établissements publics. Elle a réalisé la Licence Ouverte pour faciliter la réutilisation libre et gratuite de ces informations publiques, telles que définies par larticle L321-1 du CRPA.
Cette licence est une version 2.0 de la Licence Ouverte.
Etalab se réserve la faculté de proposer de nouvelles versions de la Licence Ouverte. Cependant, les « Réutilisateurs » pourront continuer à réutiliser les informations disponibles sous cette licence sils le souhaitent.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024-2025 DINUM/Etalab
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+38 -3
View File
@@ -88,10 +88,20 @@ bootstrap: \
.PHONY: bootstrap
# -- Docker/compose
build: ## build the app-dev container
@$(COMPOSE) build app-dev --no-cache
build: ## build the project containers
@$(MAKE) build-backend
@$(MAKE) build-frontend
.PHONY: build
build-backend: ## build the app-dev container
@$(COMPOSE) build app-dev
.PHONY: build-backend
build-frontend: ## build the frontend container
@$(COMPOSE) build frontend
.PHONY: build-frontend
down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE) down
.PHONY: down
@@ -100,10 +110,16 @@ logs: ## display app-dev logs (follow mode)
@$(COMPOSE) logs -f app-dev
.PHONY: logs
run: ## start the wsgi (production) and development server
run-backend: ## start only the backend application and all needed services
@$(COMPOSE) up --force-recreate -d celery-dev
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
.PHONY: run-backend
run:
run: ## start the wsgi (production) and development server
@$(MAKE) run-backend
@$(COMPOSE) up --force-recreate -d frontend
.PHONY: run
status: ## an alias for "docker compose ps"
@@ -114,6 +130,25 @@ stop: ## stop the development server using Docker
@$(COMPOSE) stop
.PHONY: stop
# -- Front
frontend-development-install: ## install the frontend locally
cd $(PATH_FRONT) && npm i
.PHONY: frontend-development-install
frontend-lint: ## run the frontend linter
cd $(PATH_FRONT) && npm run lint
.PHONY: frontend-lint
frontend-format: ## run the frontend format
cd $(PATH_FRONT) && npm run format
.PHONY: frontend-format
run-frontend-development: ## run the frontend in development mode
@$(COMPOSE) stop frontend
cd $(PATH_FRONT) && npm run dev
.PHONY: run-frontend-development
# -- Backend
demo: ## flush db then create a demo for load testing purpose
+97 -135
View File
@@ -1,157 +1,119 @@
# Meet
Meet is a simple video and phone conferencing tool, powered by [LiveKit](https://livekit.io/).
Meet is built on top of [Django Rest
Framework](https://www.django-rest-framework.org/) and [Vite.js](https://vitejs.dev/).
## Getting started
### Prerequisite
#### Docker
Make sure you have a recent version of Docker and [Docker
Compose](https://docs.docker.com/compose/install) installed on your laptop:
```bash
$ docker -v
Docker version 20.10.2, build 2291f61
$ docker compose -v
docker compose version 1.27.4, build 40524192
```
> ⚠️ You may need to run the following commands with `sudo` but this can be
> avoided by assigning your user to the `docker` group.
#### LiveKit CLI
Install LiveKit CLI, which provides utilities for interacting with the LiveKit ecosystem (including the server, egress, and more), please follow the instructions available in the [official repository](https://github.com/livekit/livekit-cli).
### Project bootstrap
The easiest way to start working on the project is to use GNU Make:
```bash
$ make bootstrap FLUSH_ARGS='--no-input'
```
Then you can access to the project in development mode by going to http://localhost:3000.
You will be prompted to log in, the default credentials are:
```bash
username: meet
password: meet
```
---
This command builds the `app` container, installs dependencies, performs
database migrations and compile translations. It's a good idea to use this
command each time you are pulling code from the project repository to avoid
dependency-related or migration-related issues.
Your Docker services should now be up and running 🎉
[FIXME] Explain how to run the frontend project.
### Configure LiveKit CLI
For the optimal DX, create a default project named `meet` to use with `livekit-cli` commands:
```bash
$ livekit-cli project add
URL: http://localhost:7880
API Key: devkey
API Secret: secret
Give it a name for later reference: meet
? Make this project default?? [y/N] y
```
Thus, you won't need to pass the project API Key and API Secret for each command.
<p align="center">
<img alt="meet logo" src="./docs/assets/banner-meet-fr.png" maxWidth="100%">
</p>
### Adding content
<p align="center">
<a href="https://github.com/suitenumerique/meet/stargazers/">
<img src="https://img.shields.io/github/stars/suitenumerique/meet" alt="">
</a>
<a href='http://makeapullrequest.com'><img alt='PRs Welcome' src='https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=shields'/></a>
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/meet"/>
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
</a>
</p>
You can create a basic demo site by running:
<p align="center">
<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>
```bash
$ make demo
```
<p align="center">
<a href="https://visio.numerique.gouv.fr/">
<img src="https://github.com/user-attachments/assets/09c1faa1-de88-4848-af3a-6fbe793999bf" alt="La Suite Meet Demonstration">
</a>
</p>
Finally, you can check all available Make rules using:
## La Suite Meet: Simple Video Conferencing
```bash
$ make help
```
### Django admin
You can access the Django admin site at
[http://localhost:8071/admin](http://localhost:8071/admin).
You first need to create a superuser account:
```bash
$ make superuser
```
### Run application on local Kubernetes
The application is deployed across staging, preprod, and production environments using Kubernetes (K8s).
Reproducing environment conditions locally is crucial for developing new features or debugging issues.
This is facilitated by [Tilt](https://tilt.dev/) ("Kubernetes for Prod, Tilt for Dev"). Tilt enables smart rebuilds and live updates for services running locally in Kubernetes. We defined our services in a Tiltfile located at `bin/Tiltfile`.
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
### Features
- Optimized for stability in large meetings (+100 p.)
- Support for multiple screen sharing streams
- Non-persistent, secure chat
- End-to-end encryption (coming soon)
- Meeting recording (coming soon)
- Meeting transcription (currently in beta)
- Telephony integration (in development)
- Secure participation with robust authentication and access control
- LiveKit Advances features including :
- speaker detection
- simulcast
- end-to-end optimizations
- selective subscription
- SVC codecs (VP9, AV1)
#### Getting Started
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
Make sure you have installed:
- kubectl
- helm
- helmfile
- tilt
Were continuously adding new features to enhance your experience, with the latest updates coming soon!
To build and start the Kubernetes cluster using Kind:
```shell
$ make build-k8s-cluster
```
Once the Kubernetes cluster is ready, start the application stack locally:
```shell
$ make start-tilt
or
$ make start-tilt-keycloak # start stack without Pro Connect, use keycloak
```
These commands set up and run your application environment using Tilt for local Kubernetes development.
## Table of Contents
You can monitor Tilt's at `http://localhost:10350/`. After Tilt actions finish, you can access the app at `https://meet.127.0.0.1.nip.io/`.
- [Get started](#get-started)
- [Docs](#docs)
- [Contributing](#contributing)
- [Philosophy](#philosophy)
- [Open source](#open-source)
#### Debugging frontend
Tilt deploys the `meet-dev` for the frontend by default, to benefit from Vite.js hot reloading while developing.
To troubleshoot production issues, please modify the Tiltfile, switch frontend's target to `frontend-production`:
## Get started
```yaml
...
### La Suite Meet Cloud (Recommended)
Sign up for La Suite Meet Cloud, designed for french public servants. Hosted on SecNumCloud-compliant providers and accessible via government SSO, [ProConnect](https://www.proconnect.gouv.fr/). The easiest way to try our product. Reach out if your entity isn't connected yet to our sso.
docker_build(
'localhost:5001/meet-frontend:latest',
context='..',
dockerfile='../src/frontend/Dockerfile',
only=['./src/frontend', './docker', './.dockerignore'],
target='frontend-production', # Update this line when needed
live_update=[
sync('../src/frontend', '/home/frontend'),
]
)
...
```
### Open-source deployment (Advanced)
Deploy La Suite Meet on your own infrastructure using [our self-hosting guide](https://github.com/suitenumerique/meet/blob/main/docs/installation.md). Our open-source deployment is optimized for Kubernetes, and we're working on supporting additional deployment options. Keycloak integration and any SSO are supported. We offer customer support for open-source setups—just reach out for assistance.
## Docs
We're currently working on both technical and user documentation for La Suite Meet. In the meantime, many of the essential aspects are already well covered by the [LiveKit documentation](https://docs.livekit.io/home/) and their [self-hosting guide](https://docs.livekit.io/home/self-hosting/deployment/). Stay tuned for more updates!
## Contributing
This project is intended to be community-driven, so please, do not hesitate to
get in touch if you have any question related to our implementation or design
decisions.
We <3 contributions of any kind, big and small:
- Vote on features or get early access to beta functionality in our [roadmap](https://github.com/orgs/suitenumerique/projects/3/views/2)
- Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
## Philosophy
Were relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics.
Our users come first. Were committed to making La Suite Meet as accessible and easy to use as proprietary solutions, ensuring it meets the highest standards.
Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority.
## Open-source
Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE).
All features we develop will always remain open-source, and we are committed to contributing back to the LiveKit community whenever feasible.
To learn more, don't hesitate to [reach out](mailto:visio@numerique.gouv.fr).
### Help us!
Come help us make La Suite Meet even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr).
## Contributors 🧞
<a href="https://github.com/suitenumerique/meet/graphs/contributors">
<img src="https://contrib.rocks/image?repo=suitenumerique/meet" />
</a>
## Credits
We're using the awesome [LiveKit](https://livekit.io/) implementation. We're also thankful to the teams behind [Django Rest Framework](https://www.django-rest-framework.org/), [Vite.js](https://vite.dev/), and [React Aria](https://github.com/adobe/react-spectrum) — Thanks for your amazing work!
## License
This work is released under the MIT License (see [LICENSE](./LICENSE)).
Code in this repository is published under the MIT license by DINUM (Direction interministériel du numérique).
Documentation (in the docs/) directory is released under the [Etalab-2.0 license](https://spdx.org/licenses/etalab-2.0.html).
+23
View File
@@ -0,0 +1,23 @@
# Security Policy
## Reporting a Vulnerability
Security is very important to us.
If you have any issue regarding security, please disclose the information responsibly submiting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at visio@numerique.gouv.fr
We appreciate your effort to make Visio more secure.
## Vulnerability disclosure policy
Working with security issues in an open source project can be challenging, as we are required to disclose potential problems that could be exploited by attackers. With this in mind, our security fix policy is as follows:
1. The Maintainers team will handle the fix as usual (Pull Request,
release).
2. In the release notes, we will include the identification numbers from the
GitHub Advisory Database (GHSA) and, if applicable, the Common Vulnerabilities
and Exposures (CVE) identifier for the vulnerability.
3. Once this grace period has passed, we will publish the vulnerability.
By adhering to this security policy, we aim to address security concerns
effectively and responsibly in our open source software project.
+24
View File
@@ -1,6 +1,10 @@
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
load('ext://namespace', 'namespace_create', 'namespace_inject')
namespace_create('meet')
def clean_old_images(image_name):
local('docker images -q %s | tail -n +2 | xargs -r docker rmi' % image_name)
docker_build(
'localhost:5001/meet-backend:latest',
context='..',
@@ -15,6 +19,7 @@ docker_build(
)
]
)
clean_old_images('localhost:5001/meet-backend')
docker_build(
'localhost:5001/meet-frontend:latest',
@@ -26,6 +31,7 @@ docker_build(
sync('../src/frontend', '/home/frontend'),
]
)
clean_old_images('localhost:5001/meet-frontend')
docker_build(
'localhost:5001/meet-summary:latest',
@@ -37,6 +43,24 @@ docker_build(
sync('../src/summary', '/home/summary'),
]
)
clean_old_images('localhost:5001/meet-summary')
# Copy the mkcert root CA certificate to our Docker build context
# This is necessary because we need to inject the certificate into our LiveKit container
local_resource(
'copy-root-ca',
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
# This allows LiveKit to trust our local development certificates
docker_build(
'localhost:5001/meet-livekit:latest',
context='../docker/livekit',
dockerfile='./../docker/livekit/Dockerfile',
only=['.'],
)
clean_old_images('localhost:5001/meet-livekit')
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
+48
View File
@@ -15,6 +15,37 @@ services:
ports:
- "1081:1080"
minio:
user: ${DOCKER_USER:-1000}
image: minio/minio
environment:
- MINIO_ROOT_USER=meet
- MINIO_ROOT_PASSWORD=password
ports:
- '9000:9000'
- '9001:9001'
healthcheck:
test: [ "CMD", "mc", "ready", "local" ]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server --console-address :9001 /data
volumes:
- ./data/media:/data
createbuckets:
image: minio/mc
depends_on:
minio:
condition: service_healthy
restart: true
entrypoint: >
sh -c "
/usr/bin/mc alias set meet http://minio:9000 meet password && \
/usr/bin/mc mb meet/meet-media-storage && \
exit 0;"
app-dev:
build:
context: .
@@ -40,6 +71,9 @@ services:
- redis
- nginx
- livekit
- createbuckets
extra_hosts:
- "127.0.0.1.nip.io:host-gateway"
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -73,6 +107,7 @@ services:
- postgresql
- redis
- livekit
- minio
celery:
user: ${DOCKER_USER:-1000}
@@ -95,8 +130,21 @@ services:
depends_on:
- keycloak
frontend:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: frontend-production
args:
VITE_API_BASE_URL: "http://localhost:8071"
image: meet:frontend-development
ports:
- "3000:8080"
dockerize:
image: jwilder/dockerize
platform: linux/x86_64
crowdin:
image: crowdin/cli:4.0.0
+6
View File
@@ -0,0 +1,6 @@
FROM livekit/livekit-server:v1.8.0
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
COPY rootCA.pem /etc/ssl/certs/
ENTRYPOINT ["/livekit-server"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

+145
View File
@@ -0,0 +1,145 @@
# Getting Started
Before setting up, let's review Visio's architecture.
Visio consists of four main components that run simultaneously:
- React frontend, built with Vite.js
- Django server
- LiveKit server
- FastAPI server (optional, required for AI beta features)
These components rely on a few key services:
- PostgreSQL for storing data (users, rooms, recordings)
- Redis for caching and inter-service communication
- MinIO for storing files (room recordings)
- Celery workers for meeting transcript (optional, required for AI beta features)
We provide two stack options for getting Visio up and running for development:
- Docker Compose stack (recommended for most users)
- Kubernetes stack powered by Tilt (Advanced)
We recommend starting with the **Docker Compose** option for simplicity. However, if you're comfortable with running Kubernetes locally, the advanced option mirrors the production environment and provides most of the tools required for development (e.g., hot reloading).
These instructions are for macOS or Ubuntu. For other distros, adjust as needed.
If any steps are outdated, please let us know!
---
We also provide **GNU make utilities**. To view all available Make rules, run:
```shellscript
$ make help
```
---
## Need Help?
If you need any assistance or have questions while getting started, feel free to reach out to @lebaudantoine anytime! Antoine is available to help you onboard and guide you through the process. Chat with him @antoine.lebaud:matrix.org, or from the [support hotline](https://go.crisp.chat/chat/embed/?website_id=58ea6697-8eba-4492-bc59-ad6562585041).
---
## Option 1: Developing with Docker
### Prerequisites
1. Ensure you have a recent version of **Docker** and **Docker Compose** installed:
```shellscript
$ docker -v
Docker version 20.10.2, build 2291f61
$ docker compose version
Docker Compose version v2.32.4
```
2. Install **LiveKit CLI** by following the instructions available in the [official repository](https://github.com/livekit/livekit-cli). After installation, verify that it's working:
```shellscript
$ lk --version
lk version 2.3.1
```
---
### Project Bootstrap
1. Bootstrap the project using the **Make** command. This will build the `app` container, install dependencies, run database migrations, and compile translations:
```shellscript
$ make bootstrap FLUSH_ARGS='--no-input'
```
2. Access the project:
- The frontend is available at [http://localhost:3000](http://localhost:3000) with the default credentials:
- username: meet
- password: meet
- The Django backend is available at [http://localhost:8071](http://localhost:8071)
---
## Developing
- To **stop** the application:
```shellscript
$ make stop
```
- To **restart** the application:
```shellscript
$ make run
```
- For **frontend development**, start all backend services without the frontend container:
```shellscript
$ make run-backend
```
Then:
```shellscript
$ make frontend-development-install
$ make run-frontend-development
```
Which is equivalent to these direct npm commands:
```shellscript
$ cd src/frontend
$ npm i
$ npm run dev
```
---
## Adding Content
You can bootstrap demo data with a single command:
```shellscript
$ make demo
```
---
## Option 2: Developing with Kubernetes
Visio is deployed across staging, preprod, and production environments using **Kubernetes (K8s)**. Reproducing the environment locally is crucial for developing new features or debugging.
This is facilitated by [Tilt](https://tilt.dev/), which provides Kubernetes-like development for local environments, enabling smart rebuilds and live updates.
### Getting Started
Make sure you have the following installed:
- kubectl
- helm
- helmfile
- tilt
To build and start the Kubernetes cluster using **Kind**:
```shellscript
$ make build-k8s-cluster
```
Once the Kubernetes cluster is ready, start the application stack locally:
```shellscript
$ make start-tilt-keycloak
```
Monitor Tilts progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://visio.127.0.0.1.nip.io/](https://visio.127.0.0.1.nip.io/).
+125 -16
View File
@@ -2,8 +2,7 @@
This document is a step-by-step guide that describes how to install Visio on a k8s cluster without AI features.
## Prerequisites
## Prerequisites for a kubernetes setup
- k8s cluster with an nginx-ingress controller
- an OIDC provider (if you don't have one, we will provide an example)
@@ -13,14 +12,25 @@ This document is a step-by-step guide that describes how to install Visio on a k
### Test cluster
If you do not have a test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
If you do not have a kubernetes test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script located in this repo under **bin/start-kind.sh**.
To be able to use the script, you will need to install:
IMPORTANT: The kind method will only deploy meet as a local instance(127.0.0.1) that can only be accessed from the device where it has been deployed.
To be able to use the script, you will need to install the following components:
- Docker (https://docs.docker.com/desktop/)
- Kind (https://kind.sigs.k8s.io/docs/user/quick-start/#installation)
- Mkcert (https://github.com/FiloSottile/mkcert#installation)
- Helm (https://helm.sh/docs/intro/quickstart/#install-helm)
- kubectl (https://kubernetes.io/docs/tasks/tools/)
In order to initiate the local kind installation via **start-kind.sh** do the following:
1) Make sure administrator/root user context is able to execute mkcert, docker, kind etc. commands or the script might fail
2) Download the script to the device where the above components are installed
3) Make the script executable
4) Run the script with proper permissions (administrator/sudo etc.)
The output of the script will resemble the below example:
```
$ ./bin/start-kind.sh
@@ -43,11 +53,11 @@ It will expire on 23 March 2027 🗓
2. Create kind cluster with containerd registry config dir enabled
Creating cluster "visio" ...
✓ Ensuring node image (kindest/node:v1.27.3) 🖼
✓ Preparing nodes 📦
✓ Writing configuration 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
✓ Preparing nodes 📦
✓ Writing configuration 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
Set kubectl context to "kind-visio"
You can now use your cluster with:
@@ -95,13 +105,14 @@ ingress-nginx-admission-create-jgnc9 0/1 Completed 0 2m44s
ingress-nginx-admission-patch-wrt47 0/1 Completed 0 2m44s
ingress-nginx-controller-57c548c4cd-9xwt6 1/1 Running 0 2m44s
```
When your k8s cluster is ready, you can start the deployment. This cluster is special because it uses the *.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
Please remember that *.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
When your k8s cluster is ready, you can start the deployment. This cluster is special because it uses the \*.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
## Preparation
Please remember that \*.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
### What will you use to authenticate your users ?
## Preparation of components
### What will you use to authenticate your users ?
Visio uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Visio) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
@@ -111,7 +122,7 @@ If you haven't run the script **bin/start-kind.sh**, you'll need to manually cre
$ kubectl create namespace meet
```
If you have already run the script, you can skip this step and proceed to the next instruction.
If you have already run the script, you can skip this step and proceed to the next instruction. NOTE: Before you proceed, and is using the kind method, make sure you download this repo examples/ directory and its contents to the location where you will be executing the helm command. Helm will look for "examples/<name>values.yaml" from based on the path it is being executed.
```
$ kubectl config set-context --current --namespace=meet
@@ -153,6 +164,7 @@ keycloak-0 1/1 Running 0 26m
keycloak-postgresql-0 1/1 Running 0 26m
redis-master-0 1/1 Running 0 35s
```
When the redis is ready we can deploy livekit-server.
```
@@ -194,6 +206,7 @@ livekit-livekit-server-5c5fb87f7f-ct6x5 1/1 Running 0 15m
postgresql-0 1/1 Running 0 50s
redis-master-0 1/1 Running 0 19
```
From here important information you will need are :
```
@@ -212,7 +225,7 @@ POSTGRES_PASSWORD: pass
Now you are ready to deploy Visio without AI. AI required more dependencies (Openai-compliant API, LiveKit Egress, Cold storage and a docs deployment to push resumes). To deploy meet you need to provide all previous information to the helm chart.
```
$ helm repo add meet https://numerique-gouv.github.io/meet/
$ helm repo add meet https://suitenumerique.github.io/meet/
$ helm repo update
$ helm install meet meet/meet -f examples/meet.values.yaml
```
@@ -230,4 +243,100 @@ 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_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 |
| 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 |
| OIDC_USERINFO_ESSENTIAL_CLAIMS | required claims from OIDC token | [] |
| 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_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 |
+16 -1
View File
@@ -12,12 +12,20 @@ PYTHONPATH=/app
# Mail
DJANGO_EMAIL_HOST="mailcatcher"
DJANGO_EMAIL_PORT=1025
DJANGO_EMAIL_BRAND_NAME=La Suite Numérique
DJANGO_EMAIL_SUPPORT_EMAIL=test@yopmail.com
DJANGO_EMAIL_LOGO_IMG=http://localhost:3000/assets/logo-suite-numerique.png
DJANGO_EMAIL_DOMAIN=localhost:3000
DJANGO_EMAIL_APP_BASE_URL=http://localhost:3000
# Backend url
MEET_BASE_URL="http://localhost:8072"
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
@@ -40,5 +48,12 @@ 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
Submodule secrets deleted from 2ba12db71d
+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)
+1 -1
View File
@@ -94,7 +94,7 @@ class HasAbilityPermission(IsAuthenticated):
class HasPrivilegesOnRoom(IsAuthenticated):
"""Check if user has privileges on a given room."""
message = "You must have privileges to start a recording."
message = "You must have privileges on room to perform this action."
def has_object_permission(self, request, view, obj):
"""Determine if user has privileges on room."""
+89 -16
View File
@@ -1,10 +1,10 @@
"""Client serializers for the Meet core app."""
from django.conf import settings
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
@@ -12,9 +12,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"]
@@ -92,7 +94,7 @@ class ListRoomSerializer(serializers.ModelSerializer):
class Meta:
model = models.Room
fields = ["id", "name", "slug", "is_public"]
fields = ["id", "name", "slug", "access_level"]
read_only_fields = ["id", "slug"]
@@ -101,7 +103,7 @@ class RoomSerializer(serializers.ModelSerializer):
class Meta:
model = models.Room
fields = ["id", "name", "slug", "configuration", "is_public"]
fields = ["id", "name", "slug", "configuration", "access_level"]
read_only_fields = ["id", "slug"]
def to_representation(self, instance):
@@ -118,7 +120,7 @@ class RoomSerializer(serializers.ModelSerializer):
role = instance.get_role(request.user)
is_admin = models.RoleChoices.check_administrator_role(role)
if role is not None:
if is_admin:
access_serializer = NestedResourceAccessSerializer(
instance.accesses.select_related("resource", "user").all(),
context=self.context,
@@ -129,17 +131,21 @@ class RoomSerializer(serializers.ModelSerializer):
if not is_admin:
del output["configuration"]
if role is not None or instance.is_public:
slug = f"{instance.id!s}"
username = request.query_params.get("username", None)
should_access_room = (
(
instance.access_level == models.RoomAccessLevel.TRUSTED
and request.user.is_authenticated
)
or role is not None
or instance.is_public
)
output["livekit"] = {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": slug,
"token": utils.generate_token(
room=slug, user=request.user, username=username
),
}
if should_access_room:
room_id = f"{instance.id!s}"
username = request.query_params.get("username", None)
output["livekit"] = utils.generate_livekit_config(
room_id=room_id, user=request.user, username=username
)
output["is_administrable"] = is_admin
@@ -153,7 +159,17 @@ class RecordingSerializer(serializers.ModelSerializer):
class Meta:
model = models.Recording
fields = ["id", "room", "created_at", "updated_at", "status"]
fields = [
"id",
"room",
"created_at",
"updated_at",
"status",
"mode",
"key",
"is_expired",
"expired_at",
]
read_only_fields = fields
@@ -177,3 +193,60 @@ class StartRecordingSerializer(serializers.Serializer):
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")
class RequestEntrySerializer(serializers.Serializer):
"""Validate request entry data."""
username = serializers.CharField(required=True)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RequestEntrySerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RequestEntrySerializer is validation-only")
class ParticipantEntrySerializer(serializers.Serializer):
"""Validate participant entry decision data."""
participant_id = serializers.CharField(required=True)
allow_entry = serializers.BooleanField(required=True)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("ParticipantEntrySerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("ParticipantEntrySerializer is validation-only")
class CreationCallbackSerializer(serializers.Serializer):
"""Validate room creation callback data."""
callback_id = serializers.CharField(required=True)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("CreationCallbackSerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("CreationCallbackSerializer is validation-only")
class RoomInviteSerializer(serializers.Serializer):
"""Validate room invite creation data."""
emails = serializers.ListField(child=serializers.EmailField(), allow_empty=False)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RoomInviteSerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RoomInviteSerializer is validation-only")
+295 -34
View File
@@ -2,6 +2,7 @@
import uuid
from logging import getLogger
from urllib.parse import urlparse
from django.conf import settings
from django.db.models import Q
@@ -9,12 +10,7 @@ from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.text import slugify
from rest_framework import (
decorators,
mixins,
pagination,
viewsets,
)
from rest_framework import decorators, mixins, pagination, throttling, viewsets
from rest_framework import (
exceptions as drf_exceptions,
)
@@ -25,7 +21,8 @@ from rest_framework import (
status as drf_status,
)
from core import models, utils
from core import enums, models, utils
from core.recording.enums import FileExtension
from core.recording.event.authentication import StorageEventAuthentication
from core.recording.event.exceptions import (
InvalidBucketError,
@@ -44,6 +41,16 @@ from core.recording.worker.factories import (
from core.recording.worker.mediator import (
WorkerServiceMediator,
)
from core.services.invitation import InvitationService
from core.services.livekit_events import (
LiveKitEventsService,
LiveKitWebhookError,
)
from core.services.lobby import (
LobbyParticipantNotFound,
LobbyService,
)
from core.services.room_creation import RoomCreation
from . import permissions, serializers
@@ -151,9 +158,8 @@ class UserViewSet(
queryset = self.queryset
if self.action == "list":
# Exclude all users already in the given document
if document_id := self.request.GET.get("document_id", ""):
queryset = queryset.exclude(documentaccess__document_id=document_id)
if not settings.ALLOW_UNSECURE_USER_LISTING:
return models.User.objects.none()
# Filter users by email similarity
if query := self.request.GET.get("q", ""):
@@ -178,6 +184,18 @@ class UserViewSet(
)
class RequestEntryAnonRateThrottle(throttling.AnonRateThrottle):
"""Throttle Anonymous user requesting room entry"""
scope = "request_entry"
class CreationCallbackAnonRateThrottle(throttling.AnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback"""
scope = "creation_callback"
class RoomViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
@@ -260,6 +278,9 @@ class RoomViewSet(
role=models.RoleChoices.OWNER,
)
if callback_id := self.request.data.get("callback_id"):
RoomCreation().persist_callback_state(callback_id, room)
@decorators.action(
detail=True,
methods=["post"],
@@ -343,41 +364,179 @@ class RoomViewSet(
{"message": f"Recording stopped for room {room.slug}."}
)
@decorators.action(
detail=True,
methods=["post"],
url_path="request-entry",
permission_classes=[],
throttle_classes=[RequestEntryAnonRateThrottle],
)
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
"""Request entry to a room"""
class ResourceAccessListModelMixin:
"""List mixin for resource access API."""
serializer = serializers.RequestEntrySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
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()
room = self.get_object()
lobby_service = LobbyService()
return [permission() for permission in permission_classes]
participant, livekit = lobby_service.request_entry(
room=room,
request=request,
**serializer.validated_data,
)
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
lobby_service.prepare_response(response, participant.id)
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
if self.action == "list":
user = self.request.user
queryset = queryset.filter(
Q(resource__accesses__user=user),
resource__accesses__role__in=[
models.RoleChoices.ADMIN,
models.RoleChoices.OWNER,
],
).distinct()
return queryset
return response
@decorators.action(
detail=True,
methods=["post"],
url_path="enter",
permission_classes=[
permissions.HasPrivilegesOnRoom,
],
)
def allow_participant_to_enter(self, request, pk=None): # pylint: disable=unused-argument
"""Accept or deny a participant's entry request."""
serializer = serializers.ParticipantEntrySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
room = self.get_object()
if room.is_public:
return drf_response.Response(
{"message": "Room has no lobby system."},
status=drf_status.HTTP_404_NOT_FOUND,
)
lobby_service = LobbyService()
try:
lobby_service.handle_participant_entry(
room_id=room.id,
**serializer.validated_data,
)
return drf_response.Response({"message": "Participant was updated."})
except LobbyParticipantNotFound:
return drf_response.Response(
{"message": "Participant not found."},
status=drf_status.HTTP_404_NOT_FOUND,
)
@decorators.action(
detail=True,
methods=["GET"],
url_path="waiting-participants",
permission_classes=[
permissions.HasPrivilegesOnRoom,
],
)
def list_waiting_participants(self, request, pk=None): # pylint: disable=unused-argument
"""List waiting participants."""
room = self.get_object()
if room.is_public:
return drf_response.Response({"participants": []})
lobby_service = LobbyService()
participants = lobby_service.list_waiting_participants(room.id)
return drf_response.Response({"participants": participants})
@decorators.action(
detail=False,
methods=["post"],
url_path="webhooks-livekit",
permission_classes=[],
)
def webhooks_livekit(self, request):
"""Process webhooks from LiveKit."""
livekit_events_service = LiveKitEventsService()
try:
livekit_events_service.receive(request)
return drf_response.Response(
{"status": "success"}, status=drf_status.HTTP_200_OK
)
except LiveKitWebhookError as e:
status_code = getattr(e, "status_code", drf_status.HTTP_400_BAD_REQUEST)
if status_code == drf_status.HTTP_500_INTERNAL_SERVER_ERROR:
raise e
return drf_response.Response(
{"status": "error", "message": str(e)}, status=status_code
)
@decorators.action(
detail=False,
methods=["post"],
url_path="creation-callback",
permission_classes=[],
throttle_classes=[CreationCallbackAnonRateThrottle],
)
def creation_callback(self, request):
"""Retrieve cached room data via an unauthenticated request with a unique ID.
Designed for interoperability across iframes, popups, and other contexts,
even on the same domain, bypassing browser security restrictions on direct communication.
"""
serializer = serializers.CreationCallbackSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
room = RoomCreation().get_callback_state(
callback_id=serializer.validated_data.get("callback_id")
)
return drf_response.Response(
{"status": "success", "room": room}, status=drf_status.HTTP_200_OK
)
@decorators.action(
detail=True,
methods=["post"],
url_path="invite",
permission_classes=[
permissions.HasPrivilegesOnRoom,
],
)
def invite(self, request, pk=None): # pylint: disable=unused-argument
"""Send email invitations to join a room.
This API endpoint allows a user with appropriate privileges to send email invitations
to one or more recipients, inviting them to join the specified room.
"""
room = self.get_object()
serializer = serializers.RoomInviteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
emails = serializer.validated_data.get("emails")
emails = list(set(emails))
InvitationService().invite_to_room(
room=room, sender=request.user, emails=emails
)
return drf_response.Response(
{"status": "success", "message": "invitations sent"},
status=drf_status.HTTP_200_OK,
)
class ResourceAccessViewSet(
ResourceAccessListModelMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""
@@ -388,10 +547,30 @@ class ResourceAccessViewSet(
queryset = models.ResourceAccess.objects.all()
serializer_class = serializers.ResourceAccessSerializer
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
# Restrict access to resources the user either has explicit
# permissions for or administrative privileges over.
if self.action == "list":
user = self.request.user
queryset = queryset.filter(
Q(resource__accesses__user=user),
resource__accesses__role__in=[
models.RoleChoices.ADMIN,
models.RoleChoices.OWNER,
],
).distinct()
return queryset
class RecordingViewSet(
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
"""
@@ -465,3 +644,85 @@ class RecordingViewSet(
return drf_response.Response(
{"message": "Event processed."},
)
def _auth_get_original_url(self, request):
"""
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
Raises PermissionDenied if the header is missing.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
Based on the original url and the logged-in user, we must decide if we authorize Nginx
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
"""
# Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
if not original_url:
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
raise drf_exceptions.PermissionDenied()
logger.debug("Original url: '%s'", original_url)
return urlparse(original_url)
def _auth_get_url_params(self, pattern, fragment):
"""
Extracts URL parameters from the given fragment using the specified regex pattern.
Raises PermissionDenied if parameters cannot be extracted.
"""
match = pattern.search(fragment)
try:
return match.groupdict()
except (ValueError, AttributeError) as exc:
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
raise drf_exceptions.PermissionDenied() from exc
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
def media_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to a recording's
media file.
When we let the request go through, we compute authorization headers that will be added to
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
annotation. The request will then be proxied to the object storage backend who will
respond with the file after checking the signature included in headers.
"""
parsed_url = self._auth_get_original_url(request)
url_params = self._auth_get_url_params(
enums.RECORDING_STORAGE_URL_PATTERN, parsed_url.path
)
user = request.user
recording_id = url_params["recording_id"]
extension = url_params["extension"]
if extension not in [item.value for item in FileExtension]:
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
try:
recording = models.Recording.objects.get(id=recording_id)
except models.Recording.DoesNotExist as e:
raise drf_exceptions.NotFound("No recording found for this event.") from e
if extension != recording.extension:
raise drf_exceptions.NotFound("No recording found with this extension.")
abilities = recording.get_abilities(user)
if not abilities["retrieve"]:
logger.debug("User '%s' lacks permission for attachment", user.id)
raise drf_exceptions.PermissionDenied()
if not recording.is_saved:
logger.debug("Recording '%s' has not been saved", recording)
raise drf_exceptions.PermissionDenied()
request = utils.generate_s3_authorization_headers(recording.key)
return drf_response.Response("authorized", headers=request.headers, status=200)
+35 -108
View File
@@ -1,109 +1,63 @@
"""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
from core.services.marketing_service import (
from core.services.marketing import (
ContactCreationError,
ContactData,
get_marketing_service,
)
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
This class overrides the default OIDC Authentication Backend to accommodate differences
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
"""
def get_userinfo(self, access_token, id_token, payload):
"""Return user details dictionary.
def get_extra_claims(self, user_info):
"""
Return extra claims from user_info.
Parameters:
- access_token (str): The access token.
- id_token (str): The id token (unused).
- payload (dict): The token payload (unused).
Note: The id_token and payload parameters are unused in this implementation,
but were kept to preserve base method signature.
Note: It handles signed and/or encrypted UserInfo Response. It is required by
Agent Connect, which follows the OIDC standard. It forces us to override the
base method, which deal with 'application/json' response.
Args:
user_info (dict): The user information dictionary.
Returns:
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
dict: A dictionary of extra claims.
"""
user_response = requests.get(
self.OIDC_OP_USER_ENDPOINT,
headers={"Authorization": f"Bearer {access_token}"},
verify=self.get_settings("OIDC_VERIFY_SSL", True),
timeout=self.get_settings("OIDC_TIMEOUT", None),
proxies=self.get_settings("OIDC_PROXY", None),
)
user_response.raise_for_status()
userinfo = self.verify_token(user_response.text)
return userinfo
def get_or_create_user(self, access_token, id_token, payload):
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
Parameters:
- access_token (str): The access token.
- id_token (str): The ID token.
- payload (dict): The user payload.
Returns:
- User: An existing or newly created User instance.
Raises:
- Exception: Raised when user creation is not allowed and no existing user is found.
"""
user_info = self.get_userinfo(access_token, id_token, payload)
sub = user_info.get("sub")
if not sub:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
email = user_info.get("email")
user = self.get_existing_user(sub, email)
claims = {
"email": email,
return {
# Get user's full name from OIDC fields defined in settings
"full_name": self.compute_full_name(user_info),
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
}
if not user and self.get_settings("OIDC_CREATE_USER", True):
user = User.objects.create(
sub=sub,
password="!", # noqa: S106
**claims,
)
if settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
self.signup_to_marketing_email(email)
def post_get_or_create_user(self, user, claims, is_new_user):
"""
Post-processing after user creation or retrieval.
elif not user:
return None
Args:
user (User): The user instance.
claims (dict): The claims dictionary.
is_new_user (bool): Indicates if the user was newly created.
if not user.is_active:
raise SuspiciousOperation(_("User account is disabled"))
Returns:
- None
self.update_user_if_needed(user, claims)
return user
"""
email = claims["email"]
if is_new_user and email and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
self.signup_to_marketing_email(email)
@staticmethod
def signup_to_marketing_email(email):
@@ -116,14 +70,16 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
"""
try:
with contextlib.suppress(
ContactCreationError, ImproperlyConfigured, ImportError
):
marketing_service = get_marketing_service()
contact_data = ContactData(
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
)
marketing_service.create_contact(contact_data, timeout=1)
except (ContactCreationError, ImproperlyConfigured, ImportError):
pass
marketing_service.create_contact(
contact_data, timeout=settings.BREVO_API_TIMEOUT
)
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
@@ -137,35 +93,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
pass
except User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
_("Multiple user accounts share a common email.")
"Multiple user accounts share a common email."
) from e
return None
@staticmethod
def compute_full_name(user_info):
"""Compute user's full name based on OIDC fields in settings."""
full_name = " ".join(
filter(
None,
(
user_info.get(field)
for field in settings.OIDC_USERINFO_FULLNAME_FIELDS
),
)
)
return full_name or None
@staticmethod
def update_user_if_needed(user, claims):
"""Update user claims if they have changed."""
user_fields = vars(user.__class__) # Get available model fields
updated_claims = {
key: value
for key, value in claims.items()
if value and key in user_fields and value != getattr(user, key)
}
if not updated_claims:
return
User.objects.filter(sub=user.sub).update(**updated_claims)
-18
View File
@@ -1,18 +0,0 @@
"""Authentication URLs for the People core app."""
from django.urls import path
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
from .views import OIDCLogoutCallbackView, OIDCLogoutView
urlpatterns = [
# Override the default 'logout/' path from Mozilla Django OIDC with our custom view.
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
path(
"logout-callback/",
OIDCLogoutCallbackView.as_view(),
name="oidc_logout_callback",
),
*mozzila_oidc_urls,
]
-181
View File
@@ -1,181 +0,0 @@
"""Authentication Views for the People core app."""
import copy
from urllib.parse import urlencode
from django.contrib import auth
from django.core.exceptions import SuspiciousOperation
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils import crypto
from mozilla_django_oidc.utils import (
absolutify,
)
from mozilla_django_oidc.views import (
OIDCAuthenticationCallbackView as MozillaOIDCAuthenticationCallbackView,
)
from mozilla_django_oidc.views import (
OIDCAuthenticationRequestView as MozillaOIDCAuthenticationRequestView,
)
from mozilla_django_oidc.views import (
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
)
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
Adds support for handling logout callbacks from the identity provider (OP)
by initiating the logout flow if the user has an active session.
The Django session is retained during the logout process to persist the 'state' OIDC parameter.
This parameter is crucial for maintaining the integrity of the logout flow between this call
and the subsequent callback.
"""
@staticmethod
def persist_state(request, state):
"""Persist the given 'state' parameter in the session's 'oidc_states' dictionary
This method is used to store the OIDC state parameter in the session, according to the
structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session'
utility function.
"""
if "oidc_states" not in request.session or not isinstance(
request.session["oidc_states"], dict
):
request.session["oidc_states"] = {}
request.session["oidc_states"][state] = {}
request.session.save()
def construct_oidc_logout_url(self, request):
"""Create the redirect URL for interfacing with the OIDC provider.
Retrieves the necessary parameters from the session and constructs the URL
required to initiate logout with the OpenID Connect provider.
If no ID token is found in the session, the logout flow will not be initiated,
and the method will return the default redirect URL.
The 'state' parameter is generated randomly and persisted in the session to ensure
its integrity during the subsequent callback.
"""
oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT")
if not oidc_logout_endpoint:
return self.redirect_url
reverse_url = reverse("oidc_logout_callback")
id_token = request.session.get("oidc_id_token", None)
if not id_token:
return self.redirect_url
query = {
"id_token_hint": id_token,
"state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)),
"post_logout_redirect_uri": absolutify(request, reverse_url),
}
self.persist_state(request, query["state"])
return f"{oidc_logout_endpoint}?{urlencode(query)}"
def post(self, request):
"""Handle user logout.
If the user is not authenticated, redirects to the default logout URL.
Otherwise, constructs the OIDC logout URL and redirects the user to start
the logout process.
If the user is redirected to the default logout URL, ensure her Django session
is terminated.
"""
logout_url = self.redirect_url
if request.user.is_authenticated:
logout_url = self.construct_oidc_logout_url(request)
# If the user is not redirected to the OIDC provider, ensure logout
if logout_url == self.redirect_url:
auth.logout(request)
return HttpResponseRedirect(logout_url)
class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
"""Custom view for handling the logout callback from the OpenID Connect (OIDC) provider.
Handles the callback after logout from the identity provider (OP).
Verifies the state parameter and performs necessary logout actions.
The Django session is maintained during the logout process to ensure the integrity
of the logout flow initiated in the previous step.
"""
http_method_names = ["get"]
def get(self, request):
"""Handle the logout callback.
If the user is not authenticated, redirects to the default logout URL.
Otherwise, verifies the state parameter and performs necessary logout actions.
"""
if not request.user.is_authenticated:
return HttpResponseRedirect(self.redirect_url)
state = request.GET.get("state")
if state not in request.session.get("oidc_states", {}):
msg = "OIDC callback state not found in session `oidc_states`!"
raise SuspiciousOperation(msg)
del request.session["oidc_states"][state]
request.session.save()
auth.logout(request)
return HttpResponseRedirect(self.redirect_url)
class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
"""Custom callback view for handling the silent login flow."""
@property
def failure_url(self):
"""Override the failure URL property to handle silent login flow
A silent login failure (e.g., no active user session) should not be
considered as an authentication failure.
"""
if self.request.session.get("silent", None):
del self.request.session["silent"]
self.request.session.save()
return self.success_url
return super().failure_url
class OIDCAuthenticationRequestView(MozillaOIDCAuthenticationRequestView):
"""Custom authentication view for handling the silent login flow."""
def get_extra_params(self, request):
"""Handle 'prompt' extra parameter for the silent login flow
This extra parameter is necessary to distinguish between a standard
authentication flow and the silent login flow.
"""
extra_params = self.get_settings("OIDC_AUTH_REQUEST_EXTRA_PARAMS", None)
if extra_params is None:
extra_params = {}
if request.GET.get("silent") == "true":
extra_params = copy.deepcopy(extra_params)
extra_params.update({"prompt": "none"})
request.session["silent"] = True
request.session.save()
return extra_params
+12
View File
@@ -2,9 +2,21 @@
Core application enums declaration
"""
import re
from django.conf import global_settings, settings
from django.utils.translation import gettext_lazy as _
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
# pylint: disable=line-too-long
RECORDING_STORAGE_URL_PATTERN = re.compile(
f"/media/{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s}).(?P<extension>{FILE_EXT_REGEX:s})"
)
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
# the choice of languages which should not be limited to the few languages active in
# the app.
+1 -2
View File
@@ -36,8 +36,6 @@ class ResourceFactory(factory.django.DjangoModelFactory):
model = models.Resource
skip_postgeneration_save = True
is_public = factory.Faker("boolean", chance_of_getting_true=50)
@factory.post_generation
def users(self, create, extracted, **kwargs):
"""Add users to resource from a given list of users."""
@@ -70,6 +68,7 @@ class RoomFactory(ResourceFactory):
name = factory.Faker("catch_phrase")
slug = factory.LazyAttribute(lambda o: slugify(o.name))
access_level = factory.fuzzy.FuzzyChoice(models.RoomAccessLevel)
class RecordingFactory(factory.django.DjangoModelFactory):
@@ -0,0 +1,22 @@
# Generated by Django 5.1.5 on 2025-02-16 11:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0010_alter_resourceaccess_options_alter_user_options'),
]
operations = [
migrations.RemoveField(
model_name='resource',
name='is_public',
),
migrations.AddField(
model_name='room',
name='access_level',
field=models.CharField(choices=[('public', 'Public Access'), ('restricted', 'Restricted Access')], default='public', max_length=50),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-03-04 09:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0011_remove_resource_is_public_room_access_level'),
]
operations = [
migrations.AlterField(
model_name='room',
name='access_level',
field=models.CharField(choices=[('public', 'Public Access'), ('trusted', 'Trusted Access'), ('restricted', 'Restricted Access')], default='public', max_length=50),
)
]
@@ -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 not pin_code 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
),
]
+118 -5
View File
@@ -2,9 +2,11 @@
Declare and configure the models for the Meet core application
"""
import secrets
import uuid
from datetime import datetime, timedelta
from logging import getLogger
from typing import List
from typing import List, Optional
from django.conf import settings
from django.contrib.auth import models as auth_models
@@ -12,12 +14,14 @@ from django.contrib.auth.base_user import AbstractBaseUser
from django.core import mail, validators
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import models
from django.utils.functional import lazy
from django.utils import timezone
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
from timezone_field import TimeZoneField
from .recording.enums import FileExtension
logger = getLogger(__name__)
@@ -80,6 +84,14 @@ class RecordingModeChoices(models.TextChoices):
TRANSCRIPT = "transcript", _("TRANSCRIPT")
class RoomAccessLevel(models.TextChoices):
"""Room access level choices."""
PUBLIC = "public", _("Public Access")
TRUSTED = "trusted", _("Trusted Access")
RESTRICTED = "restricted", _("Restricted Access")
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
@@ -152,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."),
@@ -241,7 +253,6 @@ def get_resource_roles(resource: models.Model, user: User) -> List[str]:
class Resource(BaseModel):
"""Model to define access control"""
is_public = models.BooleanField(default=settings.RESOURCE_DEFAULT_IS_PUBLIC)
users = models.ManyToManyField(
User,
through="ResourceAccess",
@@ -361,13 +372,25 @@ class Room(Resource):
primary_key=True,
)
slug = models.SlugField(max_length=100, blank=True, null=True, unique=True)
access_level = models.CharField(
max_length=50,
choices=RoomAccessLevel.choices,
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
)
configuration = models.JSONField(
blank=True,
default=dict,
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"
@@ -378,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.
@@ -392,8 +423,39 @@ class Room(Resource):
pass
else:
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
super().clean_fields(exclude=exclude)
@property
def is_public(self):
"""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."""
@@ -560,6 +622,57 @@ class Recording(BaseModel):
RecordingStatusChoices.STOPPED,
}
@property
def is_saved(self) -> bool:
"""Check if the recording is in a saved state."""
return self.status in {
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
RecordingStatusChoices.SAVED,
}
@property
def extension(self):
"""Get recording extension based on its mode."""
extensions = {
RecordingModeChoices.TRANSCRIPT: FileExtension.OGG.value,
RecordingModeChoices.SCREEN_RECORDING: FileExtension.MP4.value,
}
return extensions.get(self.mode, FileExtension.MP4.value)
@property
def key(self):
"""Generate the file key based on recording mode."""
return f"{settings.RECORDING_OUTPUT_FOLDER}/{self.id}.{self.extension}"
@property
def expired_at(self) -> Optional[datetime]:
"""
Calculate the expiration date based on created_at and RECORDING_EXPIRATION_DAYS.
Returns None if no expiration is configured.
Note: This is a naive and imperfect implementation since recordings are actually
saved to the bucket after created_at timestamp is set. The actual expiration
will be determined by the bucket lifecycle policy which operates on the object's
timestamp in the storage system, not this value.
"""
if not settings.RECORDING_EXPIRATION_DAYS:
return None
return self.created_at + timedelta(days=settings.RECORDING_EXPIRATION_DAYS)
@property
def is_expired(self) -> bool:
"""
Determine if the recording has expired by comparing expired_at with current UTC time.
Returns False if no expiration is configured or if expiration date is in the future.
"""
if not self.expired_at:
return False
return self.expired_at < timezone.now()
class RecordingAccess(BaseAccess):
"""Relation model to give access to a recording for a user or a team with a role."""
+10
View File
@@ -0,0 +1,10 @@
"""Enums related to recordings."""
from enum import Enum
class FileExtension(Enum):
"""Enum for file extensions used in recordings."""
OGG = "ogg"
MP4 = "mp4"
@@ -55,7 +55,7 @@ class StorageEventAuthentication(BaseAuthentication):
if not required_token:
if settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
raise AuthenticationFailed(
_("Authentication is enabled but token is not configured.")
"Authentication is enabled but token is not configured."
)
return MachineUser(), None
@@ -67,7 +67,7 @@ class StorageEventAuthentication(BaseAuthentication):
"Authentication failed: Missing Authorization header (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed(_("Authorization header is required"))
raise AuthenticationFailed("Authorization header is required")
auth_parts = auth_header.split(" ")
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
@@ -75,7 +75,7 @@ class StorageEventAuthentication(BaseAuthentication):
"Authentication failed: Invalid authorization header (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed(_("Invalid authorization header."))
raise AuthenticationFailed("Invalid authorization header.")
token = auth_parts[1]
@@ -85,7 +85,7 @@ class StorageEventAuthentication(BaseAuthentication):
"Authentication failed: Invalid token (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed(_("Invalid token"))
raise AuthenticationFailed("Invalid token")
return MachineUser(), token
@@ -1,8 +1,13 @@
"""Service to notify external services when a new recording is ready."""
import logging
import smtplib
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
import requests
@@ -21,10 +26,7 @@ class NotificationService:
return self._notify_summary_service(recording)
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
logger.warning(
"Screen recording mode not implemented for recording %s", recording.id
)
return False
return self._notify_user_by_email(recording)
logger.error(
"Unknown recording mode %s for recording %s",
@@ -33,6 +35,79 @@ class NotificationService:
)
return False
@staticmethod
def _notify_user_by_email(recording) -> bool:
"""
Send an email notification to recording owners when their recording is ready.
The email includes a direct link that redirects owners to a dedicated download
page in the frontend where they can access their specific recording.
"""
owner_accesses = (
models.RecordingAccess.objects.select_related("user")
.filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
)
.order_by("created_at")
)
if not owner_accesses:
logger.error("No owner found for recording %s", recording.id)
return False
context = {
"brandname": settings.EMAIL_BRAND_NAME,
"support_email": settings.EMAIL_SUPPORT_EMAIL,
"logo_img": settings.EMAIL_LOGO_IMG,
"domain": settings.EMAIL_DOMAIN,
"room_name": recording.room.name,
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"link": f"{settings.SCREEN_RECORDING_BASE_URL}/{recording.id}",
}
has_failures = False
# We process emails individually rather than in batch because:
# 1. Each email requires personalization (timezone, language)
# 2. The number of recipients per recording is typically small (not thousands)
for access in owner_accesses:
user = access.user
language = user.language or get_language()
with override(language):
personalized_context = {
"recording_date": recording.created_at.astimezone(
user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
user.timezone
).strftime("%H:%M"),
**context,
}
msg_html = render_to_string(
"mail/html/screen_recording.html", personalized_context
)
msg_plain = render_to_string(
"mail/text/screen_recording.txt", personalized_context
)
subject = str(_("Your recording is ready")) # Force translation
try:
send_mail(
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
[user.email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("notification could not be sent: %s", exception)
has_failures = True
return not has_failures
@staticmethod
def _notify_summary_service(recording):
"""Notify summary service about a new recording."""
@@ -57,10 +132,8 @@ class NotificationService:
logger.error("No owner found for recording %s", recording.id)
return False
key = f"{settings.RECORDING_OUTPUT_FOLDER}/{recording.id}.ogg"
payload = {
"filename": key,
"filename": recording.key,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
}
@@ -17,7 +17,6 @@ class WorkerServiceConfig:
output_folder: str
server_configurations: Dict[str, Any]
verify_ssl: Optional[bool]
bucket_args: Optional[dict]
@classmethod
@@ -29,7 +28,6 @@ class WorkerServiceConfig:
return cls(
output_folder=settings.RECORDING_OUTPUT_FOLDER,
server_configurations=settings.LIVEKIT_CONFIGURATION,
verify_ssl=settings.RECORDING_VERIFY_SSL,
bucket_args={
"endpoint": settings.AWS_S3_ENDPOINT_URL,
"access_key": settings.AWS_S3_ACCESS_KEY_ID,
+26 -18
View File
@@ -2,11 +2,11 @@
# pylint: disable=no-member
import aiohttp
from asgiref.sync import async_to_sync
from livekit import api as livekit_api
from livekit.api.egress_service import EgressService
from ... import utils
from ..enums import FileExtension
from .exceptions import WorkerConnectionError, WorkerResponseError
from .factories import WorkerServiceConfig
@@ -28,21 +28,26 @@ class BaseEgressService:
async def _handle_request(self, request, method_name: str):
"""Handle making a request to the LiveKit API and returns the response."""
# Use HTTP connector for local development with Tilt,
# where cluster communications are unsecure
connector = aiohttp.TCPConnector(ssl=self._config.verify_ssl)
lkapi = utils.create_livekit_client(self._config.server_configurations)
async with aiohttp.ClientSession(connector=connector) as session:
client = EgressService(session, **self._config.server_configurations)
method = getattr(client, method_name)
try:
response = await method(request)
except livekit_api.TwirpError as e:
raise WorkerConnectionError(
f"LiveKit client connection error, {e.message}."
) from e
# ruff: noqa: SLF001
# pylint: disable=protected-access
method = getattr(lkapi._egress, method_name)
try:
response = await method(request)
return response
except livekit_api.TwirpError as e:
raise WorkerConnectionError(
f"LiveKit client connection error, {e.message}."
) from e
except Exception as e:
raise WorkerConnectionError(
f"Unexpected error during LiveKit client connection: {str(e)}"
) from e
finally:
await lkapi.aclose()
def stop(self, worker_id: str) -> str:
"""Stop an ongoing egress worker.
@@ -89,7 +94,9 @@ class VideoCompositeEgressService(BaseEgressService):
# Save room's recording as a mp4 video file.
file_type = livekit_api.EncodedFileType.MP4
filepath = self._get_filepath(filename=recording_id, extension="mp4")
filepath = self._get_filepath(
filename=recording_id, extension=FileExtension.MP4.value
)
file_output = livekit_api.EncodedFileOutput(
file_type=file_type,
@@ -98,8 +105,7 @@ class VideoCompositeEgressService(BaseEgressService):
)
request = livekit_api.RoomCompositeEgressRequest(
room_name=room_name,
file_outputs=[file_output],
room_name=room_name, file_outputs=[file_output], layout="speaker-light"
)
response = self._handle_request(request, "start_room_composite_egress")
@@ -120,7 +126,9 @@ class AudioCompositeEgressService(BaseEgressService):
# Save room's recording as an ogg audio file.
file_type = livekit_api.EncodedFileType.OGG
filepath = self._get_filepath(filename=recording_id, extension="ogg")
filepath = self._get_filepath(
filename=recording_id, extension=FileExtension.OGG.value
)
file_output = livekit_api.EncodedFileOutput(
file_type=file_type,
+59
View File
@@ -0,0 +1,59 @@
"""Invitation Service."""
import smtplib
from logging import getLogger
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
logger = getLogger(__name__)
class InvitationError(Exception):
"""Exception raised when invitation emails cannot be sent."""
status_code = 500
class InvitationService:
"""Service for invitations to users."""
@staticmethod
def invite_to_room(room, sender, emails):
"""Send invitation emails to join a room."""
language = get_language()
context = {
"brandname": settings.EMAIL_BRAND_NAME,
"logo_img": settings.EMAIL_LOGO_IMG,
"domain": settings.EMAIL_DOMAIN,
"room_url": f"{settings.EMAIL_APP_BASE_URL}/{room.slug}",
"room_link": f"{settings.EMAIL_DOMAIN}/{room.slug}",
"sender_email": sender.email,
}
with override(language):
msg_html = render_to_string("mail/html/invitation.html", context)
msg_plain = render_to_string("mail/text/invitation.txt", context)
subject = str(
_(
f"Video call in progress: {sender.email} is waiting for you to connect"
)
) # Force translation
try:
send_mail(
subject,
msg_plain,
settings.EMAIL_FROM,
emails,
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as e:
logger.error("invitation to %s was not sent: %s", emails, e)
raise InvitationError("Could not send invitation") from e
+117
View File
@@ -0,0 +1,117 @@
"""LiveKit Events Service"""
import uuid
from enum import Enum
from django.conf import settings
from livekit import api
from .lobby import LobbyService
class LiveKitWebhookError(Exception):
"""Base exception for LiveKit webhook processing errors."""
status_code = 500
class AuthenticationError(LiveKitWebhookError):
"""Authentication failed."""
status_code = 401
class InvalidPayloadError(LiveKitWebhookError):
"""Invalid webhook payload."""
status_code = 400
class UnsupportedEventTypeError(LiveKitWebhookError):
"""Unsupported event type."""
status_code = 422
class ActionFailedError(LiveKitWebhookError):
"""Webhook action fails to process or complete."""
status_code = 500
class LiveKitWebhookEventType(Enum):
"""LiveKit webhook event types."""
# Room events
ROOM_STARTED = "room_started"
ROOM_FINISHED = "room_finished"
# Participant events
PARTICIPANT_JOINED = "participant_joined"
PARTICIPANT_LEFT = "participant_left"
# Track events
TRACK_PUBLISHED = "track_published"
TRACK_UNPUBLISHED = "track_unpublished"
# Egress events
EGRESS_STARTED = "egress_started"
EGRESS_UPDATED = "egress_updated"
EGRESS_ENDED = "egress_ended"
# Ingress events
INGRESS_STARTED = "ingress_started"
INGRESS_ENDED = "ingress_ended"
class LiveKitEventsService:
"""Service for processing and handling LiveKit webhook events and notifications."""
def __init__(self):
"""Initialize with required services."""
token_verifier = api.TokenVerifier(
settings.LIVEKIT_CONFIGURATION["api_key"],
settings.LIVEKIT_CONFIGURATION["api_secret"],
)
self.webhook_receiver = api.WebhookReceiver(token_verifier)
self.lobby_service = LobbyService()
def receive(self, request):
"""Process webhook and route to appropriate handler."""
auth_token = request.headers.get("Authorization")
if not auth_token:
raise AuthenticationError("Authorization header missing")
try:
data = self.webhook_receiver.receive(
request.body.decode("utf-8"), auth_token
)
except Exception as e:
raise InvalidPayloadError("Invalid webhook payload") from e
try:
webhook_type = LiveKitWebhookEventType(data.event)
except ValueError as e:
raise UnsupportedEventTypeError(
f"Unknown webhook type: {data.event}"
) from e
handler_name = f"_handle_{webhook_type.value}"
handler = getattr(self, handler_name, None)
if not handler or not callable(handler):
return
# pylint: disable=not-callable
handler(data)
def _handle_room_finished(self, data):
"""Handle 'room_finished' event."""
try:
room_id = uuid.UUID(data.room.name)
self.lobby_service.clear_room_cache(room_id)
except Exception as e:
raise ActionFailedError("Failed to process room finished event") from e
+384
View File
@@ -0,0 +1,384 @@
"""Lobby Service"""
import json
import logging
import uuid
from dataclasses import dataclass
from enum import Enum
from typing import Dict, List, Optional, Tuple
from uuid import UUID
from django.conf import settings
from django.core.cache import cache
from asgiref.sync import async_to_sync
from livekit.api import ( # pylint: disable=E0611
ListRoomsRequest,
SendDataRequest,
TwirpError,
)
from core import models, utils
logger = logging.getLogger(__name__)
class LobbyParticipantStatus(Enum):
"""Possible states of a participant in the lobby system.
Values are lowercase strings for consistent serialization and API responses.
"""
UNKNOWN = "unknown"
WAITING = "waiting"
ACCEPTED = "accepted"
DENIED = "denied"
class LobbyError(Exception):
"""Base exception for lobby-related errors."""
class LobbyParticipantParsingError(LobbyError):
"""Raised when participant data parsing fails."""
class LobbyParticipantNotFound(LobbyError):
"""Raised when participant is not found."""
class LobbyNotificationError(LobbyError):
"""Raised when LiveKit notification fails."""
@dataclass
class LobbyParticipant:
"""Participant in a lobby system."""
status: LobbyParticipantStatus
username: str
color: str
id: str
def to_dict(self) -> Dict[str, str]:
"""Serialize the participant object to a dict representation."""
return {
"status": self.status.value,
"username": self.username,
"id": self.id,
"color": self.color,
}
@classmethod
def from_dict(cls, data: dict) -> "LobbyParticipant":
"""Create a LobbyParticipant instance from a dictionary."""
try:
status = LobbyParticipantStatus(
data.get("status", LobbyParticipantStatus.UNKNOWN.value)
)
return cls(
status=status,
username=data["username"],
id=data["id"],
color=data["color"],
)
except (KeyError, ValueError) as e:
logger.exception("Error creating Participant from dict:")
raise LobbyParticipantParsingError("Invalid participant data") from e
class LobbyService:
"""Service for managing participant access through a lobby system.
Handles participant entry requests, status management, and notifications
using cache for state management and LiveKit for real-time updates.
"""
@staticmethod
def _get_cache_key(room_id: UUID, participant_id: str) -> str:
"""Generate cache key for participant(s) data."""
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
@staticmethod
def _get_or_create_participant_id(request) -> str:
"""Extract unique participant identifier from the request."""
return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, uuid.uuid4().hex)
@staticmethod
def prepare_response(response, participant_id):
"""Set participant cookie if needed."""
if not response.cookies.get(settings.LOBBY_COOKIE_NAME):
response.set_cookie(
key=settings.LOBBY_COOKIE_NAME,
value=participant_id,
httponly=True,
secure=True,
samesite="Lax",
)
@staticmethod
def can_bypass_lobby(room, user) -> bool:
"""Determines if a user can bypass the waiting lobby and join a room directly.
A user can bypass the lobby if:
1. The room is public (open to everyone)
2. The room has TRUSTED access level and the user is authenticated
Note: Room access levels can change while participants are waiting in the lobby.
This function only checks the current state and should be called each time
a participant requests entry to ensure consistent access control, even for
participants who have already begun waiting.
"""
return room.is_public or (
room.access_level == models.RoomAccessLevel.TRUSTED
and user.is_authenticated
)
def request_entry(
self,
room,
request,
username: str,
) -> Tuple[LobbyParticipant, Optional[Dict]]:
"""Request entry to a room for a participant.
This usual status transitions is:
UNKNOWN -> WAITING -> (ACCEPTED | DENIED)
Flow:
1. Check current status
2. If waiting, refresh timeout to maintain position
3. If unknown, add to waiting list
4. If accepted, generate LiveKit config
5. If denied, do nothing.
"""
participant_id = self._get_or_create_participant_id(request)
participant = self._get_participant(room.id, participant_id)
if self.can_bypass_lobby(room=room, user=request.user):
if participant is None:
participant = LobbyParticipant(
status=LobbyParticipantStatus.ACCEPTED,
username=username,
id=participant_id,
color=utils.generate_color(participant_id),
)
else:
participant.status = LobbyParticipantStatus.ACCEPTED
livekit_config = utils.generate_livekit_config(
room_id=str(room.id),
user=request.user,
username=username,
color=participant.color,
)
return participant, livekit_config
livekit_config = None
if participant is None:
participant = self.enter(room.id, participant_id, username)
elif participant.status == LobbyParticipantStatus.WAITING:
self.refresh_waiting_status(room.id, participant_id)
elif participant.status == LobbyParticipantStatus.ACCEPTED:
# wrongly named, contains access token to join a room
livekit_config = utils.generate_livekit_config(
room_id=str(room.id),
user=request.user,
username=username,
color=participant.color,
)
return participant, livekit_config
def refresh_waiting_status(self, room_id: UUID, participant_id: str):
"""Refresh timeout for waiting participant.
Extends the waiting period for a participant to maintain their position
in the lobby queue. Automatic removal if the participant is not
actively checking their status.
"""
cache.touch(
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
)
def enter(
self, room_id: UUID, participant_id: str, username: str
) -> LobbyParticipant:
"""Add participant to waiting lobby.
Create a new participant entry in waiting status and notify room
participants of the new entry request.
Raises:
LobbyNotificationError: If room notification fails
"""
color = utils.generate_color(participant_id)
participant = LobbyParticipant(
status=LobbyParticipantStatus.WAITING,
username=username,
id=participant_id,
color=color,
)
try:
self.notify_participants(room_id=room_id)
except LobbyNotificationError:
# If room not created yet, there is no participants to notify
pass
cache_key = self._get_cache_key(room_id, participant_id)
cache.set(
cache_key,
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
return participant
def _get_participant(
self, room_id: UUID, participant_id: str
) -> Optional[LobbyParticipant]:
"""Check participant's current status in the lobby."""
cache_key = self._get_cache_key(room_id, participant_id)
data = cache.get(cache_key)
if not data:
return None
try:
return LobbyParticipant.from_dict(data)
except LobbyParticipantParsingError:
logger.error("Corrupted participant data found and removed: %s", cache_key)
cache.delete(cache_key)
return None
def list_waiting_participants(self, room_id: UUID) -> List[dict]:
"""List all waiting participants for a room."""
pattern = self._get_cache_key(room_id, "*")
keys = cache.keys(pattern)
if not keys:
return []
data = cache.get_many(keys)
waiting_participants = []
for cache_key, raw_participant in data.items():
try:
participant = LobbyParticipant.from_dict(raw_participant)
except LobbyParticipantParsingError:
cache.delete(cache_key)
continue
if participant.status == LobbyParticipantStatus.WAITING:
waiting_participants.append(participant.to_dict())
return waiting_participants
def handle_participant_entry(
self,
room_id: UUID,
participant_id: str,
allow_entry: bool,
) -> None:
"""Handle decision on participant entry.
Updates participant status based on allow_entry:
- If accepted: ACCEPTED status with extended timeout matching LiveKit token
- If denied: DENIED status with short timeout allowing status check and retry
"""
if allow_entry:
decision = {
"status": LobbyParticipantStatus.ACCEPTED,
"timeout": settings.LOBBY_ACCEPTED_TIMEOUT,
}
else:
decision = {
"status": LobbyParticipantStatus.DENIED,
"timeout": settings.LOBBY_DENIED_TIMEOUT,
}
self._update_participant_status(room_id, participant_id, **decision)
def _update_participant_status(
self,
room_id: UUID,
participant_id: str,
status: LobbyParticipantStatus,
timeout: int,
) -> None:
"""Update participant status with appropriate timeout."""
cache_key = self._get_cache_key(room_id, participant_id)
data = cache.get(cache_key)
if not data:
logger.error("Participant %s not found", participant_id)
raise LobbyParticipantNotFound("Participant not found")
try:
participant = LobbyParticipant.from_dict(data)
except LobbyParticipantParsingError:
logger.exception(
"Removed corrupted data for participant %s:", participant_id
)
cache.delete(cache_key)
raise
participant.status = status
cache.set(cache_key, participant.to_dict(), timeout=timeout)
@async_to_sync
async def notify_participants(self, room_id: UUID):
"""Notify room participants about a new waiting participant using LiveKit.
Raises:
LobbyNotificationError: If notification fails to send
"""
notification_data = {
"type": settings.LOBBY_NOTIFICATION_TYPE,
}
lkapi = utils.create_livekit_client()
try:
room_response = await lkapi.room.list_rooms(
ListRoomsRequest(
names=[str(room_id)],
)
)
# Check if the room exists
if not room_response.rooms:
return
await lkapi.room.send_data(
SendDataRequest(
room=str(room_id),
data=json.dumps(notification_data).encode("utf-8"),
kind="RELIABLE",
)
)
except TwirpError as e:
logger.exception("Failed to notify room participants")
raise LobbyNotificationError("Failed to notify room participants") from e
finally:
await lkapi.aclose()
def clear_room_cache(self, room_id: UUID) -> None:
"""Clear all participant entries from the cache for a specific room."""
pattern = self._get_cache_key(room_id, "*")
keys = cache.keys(pattern)
if not keys:
return
cache.delete_many(keys)
@@ -0,0 +1,37 @@
"""Room creation service."""
from django.conf import settings
from django.core.cache import cache
class RoomCreation:
"""Room creation related methods"""
@staticmethod
def _get_cache_key(callback_id):
"""Generate a standardized cache key for room creation callbacks."""
return f"room-creation-callback_{callback_id}"
def persist_callback_state(self, callback_id: str, room) -> None:
"""Store room data in cache using the callback ID as an identifier."""
data = {
"slug": room.slug,
}
cache.set(
self._get_cache_key(callback_id),
data,
timeout=settings.ROOM_CREATION_CALLBACK_CACHE_TIMEOUT,
)
def get_callback_state(self, callback_id: str) -> dict:
"""Retrieve and clear cached room data for the given callback ID."""
cache_key = self._get_cache_key(callback_id)
data = cache.get(cache_key)
if not data:
return {}
cache.delete(cache_key)
return data
@@ -1,14 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Generate Document</title>
</head>
<body>
<h2>Generate Document</h2>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Generate PDF</button>
</form>
</body>
</html>
@@ -9,7 +9,7 @@ import pytest
from core import models
from core.authentication.backends import OIDCAuthenticationBackend
from core.factories import UserFactory
from core.services import marketing_service
from core.services import marketing
pytestmark = pytest.mark.django_db
@@ -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)
@@ -535,7 +535,7 @@ def test_marketing_signup_handles_service_initialization_errors(
@pytest.mark.parametrize(
"error",
[
marketing_service.ContactCreationError,
marketing.ContactCreationError,
ImproperlyConfigured,
ImportError,
],
@@ -1,10 +0,0 @@
"""Unit tests for the Authentication URLs."""
from core.authentication.urls import urlpatterns
def test_urls_override_default_mozilla_django_oidc():
"""Custom URL patterns should override default ones from Mozilla Django OIDC."""
url_names = [u.name for u in urlpatterns]
assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout")
@@ -1,359 +0,0 @@
"""Unit tests for the Authentication Views."""
from unittest import mock
from urllib.parse import parse_qs, urlparse
from django.contrib.auth.models import AnonymousUser
from django.contrib.sessions.middleware import SessionMiddleware
from django.core.exceptions import SuspiciousOperation
from django.test import RequestFactory
from django.test.utils import override_settings
from django.urls import reverse
from django.utils import crypto
import pytest
from rest_framework.test import APIClient
from core import factories
from core.authentication.views import (
MozillaOIDCAuthenticationCallbackView,
OIDCAuthenticationCallbackView,
OIDCAuthenticationRequestView,
OIDCLogoutCallbackView,
OIDCLogoutView,
)
pytestmark = pytest.mark.django_db
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_anonymous():
"""Anonymous users calling the logout url,
should be redirected to the specified LOGOUT_REDIRECT_URL."""
url = reverse("oidc_logout_custom")
response = APIClient().get(url)
assert response.status_code == 302
assert response.url == "/example-logout"
@mock.patch.object(
OIDCLogoutView, "construct_oidc_logout_url", return_value="/example-logout"
)
def test_view_logout(mocked_oidc_logout_url):
"""Authenticated users should be redirected to OIDC provider for logout."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
url = reverse("oidc_logout_custom")
response = client.get(url)
mocked_oidc_logout_url.assert_called_once()
assert response.status_code == 302
assert response.url == "/example-logout"
@override_settings(LOGOUT_REDIRECT_URL="/default-redirect-logout")
@mock.patch.object(
OIDCLogoutView, "construct_oidc_logout_url", return_value="/default-redirect-logout"
)
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
"""Authenticated users should be logged out when no OIDC provider is available."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
url = reverse("oidc_logout_custom")
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
response = client.get(url)
mocked_oidc_logout_url.assert_called_once()
mock_logout.assert_called_once()
assert response.status_code == 302
assert response.url == "/default-redirect-logout"
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_callback_anonymous():
"""Anonymous users calling the logout callback url,
should be redirected to the specified LOGOUT_REDIRECT_URL."""
url = reverse("oidc_logout_callback")
response = APIClient().get(url)
assert response.status_code == 302
assert response.url == "/example-logout"
@pytest.mark.parametrize(
"initial_oidc_states",
[{}, {"other_state": "foo"}],
)
def test_view_logout_persist_state(initial_oidc_states):
"""State value should be persisted in session's data."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
if initial_oidc_states:
request.session["oidc_states"] = initial_oidc_states
request.session.save()
mocked_state = "mock_state"
OIDCLogoutView().persist_state(request, mocked_state)
assert "oidc_states" in request.session
assert request.session["oidc_states"] == {
"mock_state": {},
**initial_oidc_states,
}
@override_settings(OIDC_OP_LOGOUT_ENDPOINT="/example-logout")
@mock.patch.object(OIDCLogoutView, "persist_state")
@mock.patch.object(crypto, "get_random_string", return_value="mocked_state")
def test_view_logout_construct_oidc_logout_url(
mocked_get_random_string, mocked_persist_state
):
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
request.session["oidc_id_token"] = "mocked_oidc_id_token"
request.session.save()
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
mocked_persist_state.assert_called_once()
mocked_get_random_string.assert_called_once()
params = parse_qs(urlparse(redirect_url).query)
assert params["id_token_hint"][0] == "mocked_oidc_id_token"
assert params["state"][0] == "mocked_state"
url = reverse("oidc_logout_callback")
assert url in params["post_logout_redirect_uri"][0]
@override_settings(LOGOUT_REDIRECT_URL="/")
def test_view_logout_construct_oidc_logout_url_none_id_token():
"""If no ID token is available in the session,
the user should be redirected to the final URL."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
assert redirect_url == "/"
@pytest.mark.parametrize(
"initial_state",
[None, {"other_state": "foo"}],
)
def test_view_logout_callback_wrong_state(initial_state):
"""Should raise an error if OIDC state doesn't match session data."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
if initial_state:
request.session["oidc_states"] = initial_state
request.session.save()
callback_view = OIDCLogoutCallbackView.as_view()
with pytest.raises(SuspiciousOperation) as excinfo:
callback_view(request)
assert (
str(excinfo.value) == "OIDC callback state not found in session `oidc_states`!"
)
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_callback():
"""If state matches, callback should clear OIDC state and redirects."""
user = factories.UserFactory()
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
mocked_state = "mocked_state"
request.session["oidc_states"] = {mocked_state: {}}
request.session.save()
callback_view = OIDCLogoutCallbackView.as_view()
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
def clear_user(request):
# Assert state is cleared prior to logout
assert request.session["oidc_states"] == {}
request.user = AnonymousUser()
mock_logout.side_effect = clear_user
response = callback_view(request)
mock_logout.assert_called_once()
assert response.status_code == 302
assert response.url == "/example-logout"
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
def test_view_authentication_default(settings, mocked_extra_params_setting):
"""By default, authentication request should not trigger silent login."""
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
request.GET = {}
view = OIDCAuthenticationRequestView()
extra_params = view.get_extra_params(request)
assert extra_params == (mocked_extra_params_setting or {})
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
def test_view_authentication_silent_false(settings, mocked_extra_params_setting):
"""Ensure setting 'silent' parameter to a random value doesn't trigger the silent login flow."""
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
request.GET = {"silent": "foo"}
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
view = OIDCAuthenticationRequestView()
extra_params = view.get_extra_params(request)
assert extra_params == (mocked_extra_params_setting or {})
assert not request.session.get("silent")
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
def test_view_authentication_silent_true(settings, mocked_extra_params_setting):
"""If 'silent' parameter is set to True, the silent login should be triggered."""
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
request.GET = {"silent": "true"}
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
view = OIDCAuthenticationRequestView()
extra_params = view.get_extra_params(request)
expected_params = {"prompt": "none"}
assert (
extra_params == {**mocked_extra_params_setting, **expected_params}
if mocked_extra_params_setting
else expected_params
)
assert request.session.get("silent") is True
@mock.patch.object(
MozillaOIDCAuthenticationCallbackView,
"failure_url",
new_callable=mock.PropertyMock,
return_value="foo",
)
def test_view_callback_failure_url(mocked_failure_url):
"""Test default behavior of the 'failure_url' property"""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
view = OIDCAuthenticationCallbackView()
view.request = request
returned_url = view.failure_url
mocked_failure_url.assert_called_once()
assert returned_url == "foo"
@mock.patch.object(
OIDCAuthenticationCallbackView,
"success_url",
new_callable=mock.PropertyMock,
return_value="foo",
)
def test_view_callback_failure_url_silent_login(mocked_success_url):
"""If a silent login was initiated and failed, it should not be treated as a failure."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
request.session["silent"] = True
request.session.save()
view = OIDCAuthenticationCallbackView()
view.request = request
returned_url = view.failure_url
mocked_success_url.assert_called_once()
assert returned_url == "foo"
assert not request.session.get("silent")
@@ -0,0 +1,224 @@
"""
Test event notification.
"""
# pylint: disable=E1128,W0621,W0613,W0212
import datetime
import smtplib
from unittest import mock
from django.contrib.sites.models import Site
import pytest
from core import factories, models
from core.recording.event.notification import NotificationService, notification_service
pytestmark = pytest.mark.django_db
@pytest.fixture
def mocked_current_site():
"""Mocks the Site.objects.get_current()to return a controlled predefined domain."""
site_mock = mock.Mock()
site_mock.domain = "test-domain.com"
with mock.patch.object(
Site.objects, "get_current", return_value=site_mock
) as patched:
yield patched
@mock.patch.object(NotificationService, "_notify_summary_service", return_value=True)
def test_notify_external_services_transcript_mode(mock_notify_summary):
"""Test notification routing for transcript mode recordings."""
service = NotificationService()
recording = factories.RecordingFactory(mode=models.RecordingModeChoices.TRANSCRIPT)
result = service.notify_external_services(recording)
assert result is True
mock_notify_summary.assert_called_once_with(recording)
@mock.patch.object(NotificationService, "_notify_user_by_email", return_value=True)
def test_notify_external_services_screen_recording_mode(mock_notify_email):
"""Test notification routing for screen recording mode."""
service = NotificationService()
recording = factories.RecordingFactory(
mode=models.RecordingModeChoices.SCREEN_RECORDING
)
result = service.notify_external_services(recording)
assert result is True
mock_notify_email.assert_called_once_with(recording)
def test_notify_external_services_unknown_mode(caplog):
"""Test notification for unknown recording mode."""
recording = factories.RecordingFactory()
# Bypass validation
recording.mode = "unknown"
service = NotificationService()
result = service.notify_external_services(recording)
assert result is False
assert f"Unknown recording mode unknown for recording {recording.id}" in caplog.text
# pylint: disable=too-many-locals
def test_notify_user_by_email_success(mocked_current_site, settings):
"""Test successful email notification to recording owners."""
settings.EMAIL_BRAND_NAME = "ACME"
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.SCREEN_RECORDING_BASE_URL = "https://acme.com/recordings"
settings.EMAIL_FROM = "notifications@acme.com"
recording = factories.RecordingFactory(room__name="Conference Room A")
# Fix recording test to avoid flaky tests
recording.created_at = datetime.datetime(2023, 5, 15, 14, 30, 0)
french_user = factories.UserFactory(
email="franc@test.com", language="fr-fr", timezone="Europe/Paris"
)
dutch_user = factories.UserFactory(
email="berry@test.com", language="nl-nl", timezone="Europe/Amsterdam"
)
english_user = factories.UserFactory(
email="john@test.com", language="en-us", timezone="America/Phoenix"
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=french_user
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=dutch_user
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=english_user
)
# Create non-owner users to verify they don't receive emails
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.MEMBER
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.ADMIN
)
notification_service = NotificationService()
with mock.patch("core.recording.event.notification.send_mail") as mock_send_mail:
result = notification_service._notify_user_by_email(recording)
assert result is True
assert mock_send_mail.call_count == 3
call_args_list = mock_send_mail.call_args_list
base_content = [
"ACME", # Brand name
"support@acme.com", # Support email
"https://acme.com/logo", # Logo URL
f"https://acme.com/recordings/{recording.id}", # Recording link
"Conference Room A", # Room name
]
# First call verification
subject1, body1, sender1, recipients1 = call_args_list[0][0]
assert subject1 == "Votre enregistrement est prêt"
# Verify email contains expected content
personalized_content1 = [
*base_content,
"2023-05-15", # Formatted date
"16:30", # Formatted time
"Votre enregistrement est prêt !", # Intro
]
for content in personalized_content1:
assert content in body1
assert recipients1 == ["franc@test.com"]
assert sender1 == "notifications@acme.com"
# Second call verification
subject2, body2, sender2, recipients2 = call_args_list[1][0]
assert subject2 == "Je opname is klaar"
# Verify second email content (if needed)
personalized_content2 = [
*base_content,
"2023-05-15", # Formatted date
"16:30", # Formatted time
"Je opname is klaar!", # Intro
]
for content in personalized_content2:
assert content in body2
assert recipients2 == ["berry@test.com"]
assert sender2 == "notifications@acme.com"
# Third call verification
subject3, body3, sender3, recipients3 = call_args_list[2][0]
assert subject3 == "Your recording is ready"
# Verify second email content (if needed)
personalized_content3 = [
*base_content,
"Conference Room A", # Room name
"2023-05-15", # Formatted date
"07:30", # Formatted time
"Your recording is ready!", # Intro
]
for content in personalized_content3:
assert content in body3
assert recipients3 == ["john@test.com"]
assert sender3 == "notifications@acme.com"
def test_notify_user_by_email_no_owners(mocked_current_site, caplog):
"""Test email notification when no owners are found."""
# Recording with no access
recording = factories.RecordingFactory()
result = notification_service._notify_user_by_email(recording)
assert result is False
assert f"No owner found for recording {recording.id}" in caplog.text
def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
"""Test email notification when an exception occurs."""
recording = factories.RecordingFactory(room__name="Conference Room A")
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
notification_service = NotificationService()
with mock.patch(
"core.recording.event.notification.send_mail",
side_effect=smtplib.SMTPException("SMTP Error"),
) as mock_send_mail:
result = notification_service._notify_user_by_email(recording)
assert result is False
assert mock_send_mail.call_count == 2
assert "notification could not be sent:" in caplog.text
@@ -22,15 +22,37 @@ def test_api_recordings_list_anonymous():
assert response.status_code == 401
def test_api_recordings_list_authenticated_no_recording():
"""
Authenticated users listing recordings should only
see recordings to which they have access.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
factories.UserRecordingAccessFactory(user=other_user)
response = client.get(
"/api/v1.0/recordings/",
)
assert response.status_code == 200
results = response.json()["results"]
assert results == []
@pytest.mark.parametrize(
"role",
["administrator", "member", "owner"],
)
def test_api_recordings_list_authenticated_direct(role):
def test_api_recordings_list_authenticated_direct(role, settings):
"""
Authenticated users listing recordings, should only see the recordings
to which they are related.
"""
settings.RECORDING_EXPIRATIONS_DAYS = None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -57,15 +79,19 @@ def test_api_recordings_list_authenticated_direct(role):
assert expected_ids == result_ids
assert results[0] == {
"id": str(recording.id),
"key": recording.key,
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"mode": recording.mode,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"is_public": room.is_public,
"name": room.name,
"slug": room.slug,
},
"status": "initiated",
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"expired_at": None,
"is_expired": False,
}
@@ -0,0 +1,284 @@
"""
Test media-auth authorization API endpoint in docs core app.
"""
from io import BytesIO
from urllib.parse import urlparse
from uuid import uuid4
from django.conf import settings
from django.core.files.storage import default_storage
from django.utils import timezone
import pytest
import requests
from rest_framework.test import APIClient
from core import models
from core.factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
pytestmark = pytest.mark.django_db
def test_api_recordings_media_auth_unauthenticated():
"""
Test that unauthenticated requests to download media are rejected
"""
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
response = APIClient().get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 401
def test_api_recordings_media_auth_wrong_path():
"""
Test that media URLs with incorrect path structures are rejected
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/wrong-path/{uuid4()!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
def test_api_recordings_media_auth_unknown_recording():
"""
Test that requests for non-existent recordings are properly handled
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 404
def test_api_recordings_media_auth_no_abilities():
"""
Test that users without any access permissions cannot download recordings
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
def test_api_recordings_media_auth_wrong_abilities():
"""
Test that users with insufficient role permissions cannot download recordings
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
UserRecordingAccessFactory(user=user, recording=recording, role="member")
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
@pytest.mark.parametrize("wrong_status", ["initiated", "active", "failed_to_stop"])
def test_api_recordings_media_auth_unsaved(wrong_status):
"""
Test that recordings that aren't in 'saved' status cannot be downloaded
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=wrong_status)
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
def test_api_recordings_media_auth_mismatched_extension():
"""
Test that requests with mismatched file extensions are rejected
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(
status=models.RecordingStatusChoices.SAVED,
mode=models.RecordingModeChoices.TRANSCRIPT,
)
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 404
assert response.json() == {"detail": "No recording found with this extension."}
@pytest.mark.parametrize(
"wrong_extension", ["jpg", "txt", "mp3"], ids=["image", "text", "audio"]
)
def test_api_recordings_media_auth_wrong_extension(wrong_extension):
"""
Trying to download a recording with an unsupported extension should return
a validation error (400) with details about allowed extensions.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
original_url = (
f"http://localhost/media/recordings/{recording.id!s}.{wrong_extension}"
)
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 400
assert response.json() == {"detail": "Unsupported extension."}
@pytest.mark.parametrize("mode", ["screen_recording", "transcript"])
def test_api_recordings_media_auth_success_owner(mode):
"""
Test downloading a recording when logged in and authorized.
Verifies S3 authentication headers and successful file retrieval.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED, mode=mode)
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=recording.key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
original_url = f"http://localhost/media/{recording.key:s}"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
@pytest.mark.parametrize("mode", ["screen_recording", "transcript"])
def test_api_recordings_media_auth_success_administrator(mode):
"""
Test downloading a recording when logged in and authorized.
Verifies S3 authentication headers and successful file retrieval.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED, mode=mode)
UserRecordingAccessFactory(user=user, recording=recording, role="administrator")
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=recording.key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
original_url = f"http://localhost/media/{recording.key:s}"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
@@ -0,0 +1,243 @@
"""
Test recordings API endpoints in the Meet core app: retrieve.
"""
import random
import pytest
from freezegun import freeze_time
from rest_framework.test import APIClient
from ...factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
from ...models import RecordingStatusChoices
pytestmark = pytest.mark.django_db
def test_api_recording_retrieve_anonymous():
"""Anonymous users should not be able to retrieve recordings."""
recording = RecordingFactory()
client = APIClient()
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_recording_retrieve_authenticated():
"""Authenticated users without access receive 404 when requesting recordings.
The API returns 404 instead of 403 to avoid revealing the existence of
resources the user doesn't have permission to access.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
other_user = UserFactory()
recording = UserRecordingAccessFactory(user=other_user).recording
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "No Recording matches the given query."}
def test_api_recording_retrieve_members():
"""
A user who is a member of a recording should not be able to retrieve it.
"""
user = UserFactory()
recording = RecordingFactory()
UserRecordingAccessFactory(recording=recording, user=user, role="member")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_recording_retrieve_administrators(settings):
"""A user who is an administrator of a recording should be able to retrieve it."""
settings.RECORDING_EXPIRATION_DAYS = None
user = UserFactory()
recording = RecordingFactory()
UserRecordingAccessFactory(recording=recording, user=user, role="administrator")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
room = recording.room
assert content == {
"id": str(recording.id),
"key": recording.key,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": None,
"is_expired": False,
}
def test_api_recording_retrieve_owners(settings):
"""A user who is an owner of a recording should be able to retrieve it."""
settings.RECORDING_EXPIRATION_DAYS = None
user = UserFactory()
recording = RecordingFactory()
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
room = recording.room
assert content == {
"id": str(recording.id),
"key": recording.key,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": None,
"is_expired": False,
}
@freeze_time("2023-01-15 12:00:00")
def test_api_recording_retrieve_compute_expiration_date_correctly(settings):
"""Test that the API returns the correct expiration date for a non-expired recording."""
settings.RECORDING_EXPIRATION_DAYS = 1
user = UserFactory()
recording = RecordingFactory()
UserRecordingAccessFactory(
recording=recording, user=user, role=random.choice(["administrator", "owner"])
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
room = recording.room
assert content == {
"id": str(recording.id),
"key": recording.key,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"created_at": "2023-01-15T12:00:00Z",
"updated_at": "2023-01-15T12:00:00Z",
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": "2023-01-16T12:00:00Z",
"is_expired": False, # Ensure the recording is still valid and hasn't expired
}
def test_api_recording_retrieve_expired(settings):
"""Test that the API returns the correct expiration date and flag for an expired recording."""
settings.RECORDING_EXPIRATION_DAYS = 2
user = UserFactory()
with freeze_time("2023-01-15 12:00:00"):
recording = RecordingFactory()
UserRecordingAccessFactory(
recording=recording, user=user, role=random.choice(["administrator", "owner"])
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
room = recording.room
assert content == {
"id": str(recording.id),
"key": recording.key,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"created_at": "2023-01-15T12:00:00Z",
"updated_at": "2023-01-15T12:00:00Z",
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": "2023-01-17T12:00:00Z",
"is_expired": True, # Ensure the recording has expired
}
@pytest.mark.parametrize(
"status",
[
RecordingStatusChoices.INITIATED,
RecordingStatusChoices.ACTIVE,
RecordingStatusChoices.SAVED,
RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.ABORTED,
],
)
def test_api_recording_retrieve_any_status(status):
"""Test that recordings with any status can be retrieved."""
user = UserFactory()
recording = RecordingFactory(status=status)
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
assert content["id"] == str(recording.id)
assert content["status"] == status
@@ -32,7 +32,6 @@ def test_settings():
mocked_settings = {
"RECORDING_OUTPUT_FOLDER": "/test/output",
"LIVEKIT_CONFIGURATION": {"server": "test.example.com"},
"RECORDING_VERIFY_SSL": True,
"AWS_S3_ENDPOINT_URL": "https://s3.test.com",
"AWS_S3_ACCESS_KEY_ID": "test_key",
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
@@ -56,7 +55,6 @@ def test_config_initialization(default_config):
"""Test that WorkerServiceConfig is properly initialized from settings"""
assert default_config.output_folder == "/test/output"
assert default_config.server_configurations == {"server": "test.example.com"}
assert default_config.verify_ssl is True
assert default_config.bucket_args == {
"endpoint": "https://s3.test.com",
"access_key": "test_key",
@@ -76,7 +74,6 @@ def test_config_immutability(default_config):
@override_settings(
RECORDING_OUTPUT_FOLDER="/test/output",
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
RECORDING_VERIFY_SSL=True,
AWS_S3_ENDPOINT_URL="https://s3.test.com",
AWS_S3_ACCESS_KEY_ID="test_key",
AWS_S3_SECRET_ACCESS_KEY="test_secret",
@@ -6,10 +6,9 @@ Test worker service classes.
from unittest.mock import AsyncMock, Mock, patch
import aiohttp
import pytest
from core.recording.worker.exceptions import WorkerConnectionError, WorkerResponseError
from core.recording.worker.exceptions import WorkerResponseError
from core.recording.worker.factories import WorkerServiceConfig
from core.recording.worker.services import (
AudioCompositeEgressService,
@@ -25,11 +24,10 @@ def config():
return WorkerServiceConfig(
output_folder="/test/output",
server_configurations={
"host": "test.livekit.io",
"url": "test.livekit.io",
"api_key": "test_key",
"api_secret": "test_secret",
},
verify_ssl=True,
bucket_args={
"endpoint": "https://s3.test.com",
"access_key": "test_key",
@@ -127,58 +125,6 @@ def test_base_egress_filepath_construction(service, filename, extension, expecte
assert result.endswith(f"{filename}.{extension}")
def test_base_egress_handle_request_success(
config, service, mock_client_session, mock_egress_service, mock_tcp_connector
):
"""Test successful request handling"""
# Setup mock response
mock_response = Mock()
mock_method = AsyncMock(return_value=mock_response)
mock_egress_instance = Mock()
mock_egress_instance.test_method = mock_method
mock_egress_service.return_value = mock_egress_instance
# Create test request
test_request = Mock()
response = service._handle_request(test_request, "test_method")
mock_client_session.assert_called_once_with(
connector=mock_tcp_connector.return_value
)
# Verify EgressService initialization
mock_egress_service.assert_called_once_with(
mock_client_session.return_value.__aenter__.return_value,
**service._config.server_configurations,
)
# Verify method call and response
mock_method.assert_called_once_with(test_request)
assert response == mock_response
def test_base_egress_handle_request_connection_error(service, mock_egress_service):
"""Test handling of connection errors"""
# Setup mock error
mock_method = AsyncMock(
side_effect=livekit_api.TwirpError(msg="Connection failed", code=500)
)
mock_egress_instance = Mock()
mock_egress_instance.test_method = mock_method
mock_egress_service.return_value = mock_egress_instance
# Create test request
test_request = Mock()
# Verify error handling
with pytest.raises(WorkerConnectionError) as exc:
service._handle_request(test_request, "test_method")
assert "LiveKit client connection error" in str(exc.value)
assert "Connection failed" in str(exc.value)
@pytest.mark.parametrize(
"response_status,expected_result",
[
@@ -224,43 +170,6 @@ def test_base_egress_start_not_implemented(service):
assert "Subclass must implement this method" in str(exc.value)
@pytest.mark.parametrize("verify_ssl", [True, False])
def test_base_egress_ssl_verification_config(verify_ssl):
"""Test SSL verification configuration"""
config = WorkerServiceConfig(
output_folder="/test/output",
server_configurations={
"host": "test.livekit.io",
"api_key": "test_key",
"api_secret": "test_secret",
},
verify_ssl=verify_ssl,
bucket_args={
"endpoint": "https://s3.test.com",
"access_key": "test_key",
"secret": "test_secret",
"region": "test-region",
"bucket": "test-bucket",
"force_path_style": True,
},
)
service = BaseEgressService(config)
# Mock ClientSession to capture connector configuration
with patch("aiohttp.ClientSession") as mock_session:
mock_session.return_value.__aenter__ = AsyncMock()
mock_session.return_value.__aexit__ = AsyncMock()
# Trigger request to verify connector configuration
service._handle_request(Mock(), "test_method")
# Verify SSL configuration
connector = mock_session.call_args[1]["connector"]
assert isinstance(connector, aiohttp.TCPConnector)
assert connector._ssl == verify_ssl
def test_video_composite_egress_hrid(video_service):
"""Test HRID is correct"""
assert video_service.hrid == "video-recording-composite-livekit-egress"
@@ -2,6 +2,9 @@
Test rooms API endpoints in the Meet core app: create.
"""
# pylint: disable=W0621,W0613
from django.core.cache import cache
import pytest
from rest_framework.test import APIClient
@@ -11,6 +14,15 @@ from ...models import Room
pytestmark = pytest.mark.django_db
@pytest.fixture
def reset_cache():
"""Provide cache cleanup after each test to maintain test isolation."""
yield
keys = cache.keys("room-creation-callback_*")
if keys:
cache.delete(*keys)
def test_api_rooms_create_anonymous():
"""Anonymous users should not be allowed to create rooms."""
client = APIClient()
@@ -26,7 +38,7 @@ def test_api_rooms_create_anonymous():
assert Room.objects.exists() is False
def test_api_rooms_create_authenticated():
def test_api_rooms_create_authenticated(reset_cache):
"""
Authenticated users should be able to create rooms and should automatically be declared
as owner of the newly created room.
@@ -49,6 +61,33 @@ def test_api_rooms_create_authenticated():
assert room.slug == "my-room"
assert room.accesses.filter(role="owner", user=user).exists() is True
rooms_data = cache.keys("room-creation-callback_*")
assert not rooms_data
def test_api_rooms_create_generation_cache(reset_cache):
"""
Authenticated users creating a room with a callback ID should have room data stored in cache.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{"name": "my room", "callback_id": "1234"},
)
assert response.status_code == 201
room = Room.objects.get()
assert room.name == "my room"
assert room.slug == "my-room"
assert room.accesses.filter(role="owner", user=user).exists() is True
room_data = cache.get("room-creation-callback_1234")
assert room_data.get("slug") == "my-room"
def test_api_rooms_create_authenticated_existing_slug():
"""
@@ -0,0 +1,98 @@
"""
Test rooms API endpoints in the Meet core app: creation callback functionality.
"""
# pylint: disable=W0621,W0613
from django.core.cache import cache
import pytest
from rest_framework.test import APIClient
from ...factories import UserFactory
pytestmark = pytest.mark.django_db
# Tests for creation_callback endpoint
@pytest.fixture
def reset_cache():
"""Provide cache cleanup after each test to maintain test isolation."""
yield
keys = cache.keys("room-creation-callback_*")
if keys:
cache.delete(*keys)
def test_api_rooms_create_anonymous(reset_cache):
"""Anonymous user can retrieve room data once using a valid callback ID."""
client = APIClient()
cache.set("room-creation-callback_123", {"slug": "my room"})
response = client.post(
"/api/v1.0/rooms/creation-callback/",
{
"callback_id": "123",
},
)
assert response.status_code == 200
assert response.json() == {"status": "success", "room": {"slug": "my room"}}
# Data should be cleared after retrieval
response = client.post(
"/api/v1.0/rooms/creation-callback/",
{
"callback_id": "123",
},
)
assert response.status_code == 200
assert response.json() == {"status": "success", "room": {}}
def test_api_rooms_create_empty_cache():
"""Valid callback ID return empty room data when nothing is stored in the cache."""
client = APIClient()
response = client.post(
"/api/v1.0/rooms/creation-callback/",
{
"callback_id": "123",
},
)
assert response.status_code == 200
assert response.json() == {"status": "success", "room": {}}
def test_api_rooms_create_missing_callback_id():
"""Requests without a callback ID properly fail with a 400 status code."""
client = APIClient()
response = client.post(
"/api/v1.0/rooms/creation-callback/",
{},
)
assert response.status_code == 400
def test_api_rooms_create_authenticated(reset_cache):
"""Authenticated users can also successfully retrieve room data using a valid callback ID"""
user = UserFactory()
client = APIClient()
client.force_login(user)
cache.set("room-creation-callback_123", {"slug": "my room"})
response = client.post(
"/api/v1.0/rooms/creation-callback/",
{
"callback_id": "123",
},
)
assert response.status_code == 200
assert response.json() == {"status": "success", "room": {"slug": "my room"}}
@@ -0,0 +1,295 @@
"""
Test rooms API endpoints in the Meet core app: invite.
"""
# pylint: disable=W0621,W0613
import json
import random
from unittest import mock
import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...services.invitation import InvitationError, InvitationService
pytestmark = pytest.mark.django_db
def test_api_rooms_invite_anonymous():
"""Test anonymous users should not be allowed to invite people to rooms."""
client = APIClient()
room = RoomFactory()
data = {"emails": ["toto@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 401
def test_api_rooms_invite_no_access():
"""Test non-privileged users should not be allowed to invite people to rooms."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
client.force_login(user)
data = {"emails": ["toto@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You must have privileges on room to perform this action.",
}
def test_api_rooms_invite_member():
"""Test member users should not be allowed to invite people to rooms."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
client.force_login(user)
room.accesses.create(user=user, role="member")
data = {"emails": ["toto@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You must have privileges on room to perform this action.",
}
def test_api_rooms_invite_missing_emails():
"""Test missing email list should return validation error."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"foo": []}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 400
assert response.json() == {
"emails": [
"This field is required.",
]
}
def test_api_rooms_invite_empty_emails():
"""Test empty email list should return validation error."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": []}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 400
assert response.json() == {
"emails": [
"This list may not be empty.",
]
}
def test_api_rooms_invite_invalid_emails():
"""Test invalid email addresses should return validation errors."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["abdc", "efg"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 400
assert response.json() == {
"emails": {
"0": ["Enter a valid email address."],
"1": ["Enter a valid email address."],
}
}
def test_api_rooms_invite_partially_invalid_emails():
"""Test partially invalid email addresses should return validation errors."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["fabrice@yopmail.com", "efg"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 400
assert response.json() == {
"emails": {
"1": ["Enter a valid email address."],
}
}
@mock.patch.object(InvitationService, "invite_to_room")
def test_api_rooms_invite_duplicates(mock_invite_to_room):
"""Test duplicate emails should be deduplicated before processing."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["toto@yopmail.com", "toto@yopmail.com", "Toto@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 200
mock_invite_to_room.assert_called_once()
_, kwargs = mock_invite_to_room.call_args
assert kwargs["room"] == room
assert kwargs["sender"] == user
assert sorted(kwargs["emails"]) == sorted(["Toto@yopmail.com", "toto@yopmail.com"])
@mock.patch.object(InvitationService, "invite_to_room", side_effect=InvitationError())
def test_api_rooms_invite_error(mock_invite_to_room):
"""Test invitation service error should return appropriate error response."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["toto@yopmail.com", "toto@yopmail.com"]}
with pytest.raises(InvitationError) as excinfo:
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.patch("core.services.invitation.send_mail")
def test_api_rooms_invite_success(mock_send_mail, settings):
"""Test privileged users should successfully send invitation emails."""
settings.EMAIL_BRAND_NAME = "ACME"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.EMAIL_APP_BASE_URL = "https://acme.com"
settings.EMAIL_FROM = "notifications@acme.com"
settings.EMAIL_DOMAIN = "acme.com"
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["fabien@yopmail.com", "gerald@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 200
assert response.json() == {"status": "success", "message": "invitations sent"}
mock_send_mail.assert_called_once()
subject, body, sender, recipients = mock_send_mail.call_args[0]
assert (
subject == f"Video call in progress: {user.email} is waiting for you to connect"
)
# Verify email contains expected content
required_content = [
"ACME", # Brand name
"https://acme.com/logo", # Logo URL
f"https://acme.com/{room.slug}", # Room url
f"acme.com/{room.slug}", # Room link
]
for content in required_content:
assert content in body
assert sender == "notifications@acme.com"
# Verify all owners received the email (order-independent comparison)
assert sorted(recipients) == sorted(["fabien@yopmail.com", "gerald@yopmail.com"])
@@ -9,14 +9,16 @@ from rest_framework.pagination import PageNumberPagination
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...models import RoomAccessLevel
pytestmark = pytest.mark.django_db
def test_api_rooms_list_anonymous():
"""Anonymous users should not be able to list rooms."""
RoomFactory(is_public=False)
RoomFactory(is_public=True)
RoomFactory(access_level=RoomAccessLevel.PUBLIC)
RoomFactory(access_level=RoomAccessLevel.TRUSTED)
RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
@@ -38,10 +40,13 @@ def test_api_rooms_list_authenticated():
other_user = UserFactory()
RoomFactory(is_public=False)
RoomFactory(is_public=True)
room_user_accesses = RoomFactory(is_public=False, users=[user])
RoomFactory(is_public=False, users=[other_user])
RoomFactory(access_level=RoomAccessLevel.PUBLIC)
RoomFactory(access_level=RoomAccessLevel.TRUSTED)
RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
room_user_accesses = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED, users=[user]
)
RoomFactory(access_level=RoomAccessLevel.RESTRICTED, users=[other_user])
response = client.get(
"/api/v1.0/rooms/",
@@ -105,7 +110,7 @@ def test_api_rooms_list_authenticated_distinct():
client = APIClient()
client.force_login(user)
room = RoomFactory(is_public=True, users=[user, other_user])
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC, users=[user, other_user])
response = client.get(
"/api/v1.0/rooms/",
@@ -0,0 +1,624 @@
"""
Test rooms API endpoints in the Meet core app: lobby functionality.
"""
# pylint: disable=W0621,W0613,W0212
import uuid
from unittest import mock
from django.core.cache import cache
import pytest
from rest_framework.test import APIClient
from ... import utils
from ...factories import RoomFactory, UserFactory
from ...models import RoomAccessLevel
from ...services.lobby import (
LobbyService,
)
pytestmark = pytest.mark.django_db
# Tests for request_entry endpoint
def test_request_entry_anonymous(settings):
"""Anonymous users should be allowed to request entry to a room."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys
with (
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
# Verify the lobby cookie was properly set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
participant_id = cookie.value
# Verify response content matches expected structure and values
assert response.json() == {
"id": participant_id,
"username": "test_user",
"status": "waiting",
"color": "mocked-color",
"livekit": None,
}
# Verify a participant was stored in cache
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert len(lobby_keys) == 1
# Verify participant data was correctly stored in cache
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
assert participant_data.get("username") == "test_user"
def test_request_entry_authenticated_user(settings):
"""Authenticated users should be allowed to request entry."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory()
client = APIClient()
client.force_login(user)
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys
with (
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
# Verify the lobby cookie was properly set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
participant_id = cookie.value
# Verify response content matches expected structure and values
assert response.json() == {
"id": participant_id,
"username": "test_user",
"status": "waiting",
"color": "mocked-color",
"livekit": None,
}
# Verify a participant was stored in cache
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert len(lobby_keys) == 1
# Verify participant data was correctly stored in cache
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
assert participant_data.get("username") == "test_user"
def test_request_entry_with_existing_participants(settings):
"""Anonymous users should be allowed to request entry to a room with existing participants."""
# Create a restricted access room
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
# Configure test settings for cookies and cache
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Add two participants already waiting in the lobby
cache.set(
f"mocked-cache-prefix_{room.id}_participant1",
{
"id": "participant1",
"username": "user1",
"status": "waiting",
"color": "#123456",
},
)
cache.set(
f"mocked-cache-prefix_{room.id}_participant2",
{
"id": "participant2",
"username": "user2",
"status": "accepted",
"color": "#654321",
},
)
# Verify two participants are in the lobby before the request
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert len(lobby_keys) == 2
# Mock external service calls to isolate the test
with (
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
# Make request as a new anonymous user
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
# Verify successful response
assert response.status_code == 200
# Verify the lobby cookie was properly set for the new participant
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
participant_id = cookie.value
# Verify response content matches expected structure and values
assert response.json() == {
"id": participant_id,
"username": "test_user",
"status": "waiting",
"color": "mocked-color",
"livekit": None,
}
# Verify now three participants are in the lobby cache
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert len(lobby_keys) == 3
# Verify the new participant data was correctly stored in cache
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
assert participant_data.get("username") == "test_user"
def test_request_entry_public_room(settings):
"""Entry requests to public rooms should return ACCEPTED status with LiveKit config."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys
with (
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(
LobbyService, "_get_or_create_participant_id", return_value="123"
),
mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "123"
# Verify response content matches expected structure and values
assert response.json() == {
"id": "123",
"username": "test_user",
"status": "accepted",
"color": "mocked-color",
"livekit": {"token": "test-token"},
}
# Verify lobby cache is still empty after the request
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys
def test_request_entry_authenticated_user_public_room(settings):
"""While authenticated, entry request to public rooms should get accepted."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
user = UserFactory()
client = APIClient()
client.force_login(user)
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys
with (
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(
LobbyService, "_get_or_create_participant_id", return_value="123"
),
mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "123"
# Verify response content matches expected structure and values
assert response.json() == {
"id": "123",
"username": "test_user",
"status": "accepted",
"color": "mocked-color",
"livekit": {"token": "test-token"},
}
# Verify lobby cache is still empty after the request
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys
def test_request_entry_waiting_participant_public_room(settings):
"""While waiting, entry request to public rooms should get accepted."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Add a waiting participant to the room's lobby cache
cache.set(
f"mocked-cache-prefix_{room.id}_participant1",
{
"id": "participant1",
"username": "user1",
"status": "waiting",
"color": "#123456",
},
)
# Simulate a browser with existing participant cookie
client.cookies.load({"mocked-cookie": "participant1"})
with (
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "user1"},
)
assert response.status_code == 200
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "participant1"
# Verify response content matches expected structure and values
assert response.json() == {
"id": "participant1",
"username": "user1",
"status": "accepted",
"color": "#123456",
"livekit": {"token": "test-token"},
}
# Verify participant remains in the lobby cache after acceptance
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert len(lobby_keys) == 1
def test_request_entry_invalid_data():
"""Should return 400 for invalid request data."""
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{}, # Missing required username field
)
assert response.status_code == 400
def test_request_entry_room_not_found():
"""Should return 404 for non-existent room."""
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{uuid.uuid4()!s}/request-entry/",
{"username": "anonymous"},
)
assert response.status_code == 404
# Tests for allow_participant_to_enter endpoint
def test_allow_participant_to_enter_anonymous():
"""Anonymous users should not be allowed to manage entry requests."""
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "test-id", "allow_entry": True},
)
assert response.status_code == 401
def test_allow_participant_to_enter_non_owner():
"""Non-privileged users should not be allowed to manage entry requests."""
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "test-id", "allow_entry": True},
)
assert response.status_code == 403
def test_allow_participant_to_enter_public_room():
"""Should return 404 for public rooms that don't use the lobby system."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
user = UserFactory()
# Make user the room owner
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "test-id", "allow_entry": True},
)
assert response.status_code == 404
assert response.json() == {"message": "Room has no lobby system."}
@pytest.mark.parametrize(
"allow_entry, updated_status", [(True, "accepted"), (False, "denied")]
)
def test_allow_participant_to_enter_success(settings, allow_entry, updated_status):
"""Should successfully update participant status when everything is correct."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory()
# Make user the room owner
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
cache.set(
f"mocked-cache-prefix_{room.id!s}_participant1",
{
"id": "test-id",
"status": "waiting",
"username": "foo",
"color": "123",
},
)
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "participant1", "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")
assert participant_data.get("status") == updated_status
def test_allow_participant_to_enter_participant_not_found(settings):
"""Should handle case when participant is not found."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory()
# Make user the room owner
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_test-id")
assert participant_data is None
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "test-id", "allow_entry": True},
)
assert response.status_code == 404
assert response.json() == {"message": "Participant not found."}
def test_allow_participant_to_enter_invalid_data():
"""Should return 400 for invalid request data."""
room = RoomFactory()
user = UserFactory()
# Make user the room owner
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{}, # Missing required fields
)
assert response.status_code == 400
# Tests for list_waiting_participants endpoint
def test_list_waiting_participants_anonymous():
"""Anonymous users should not be allowed to list waiting participants."""
room = RoomFactory()
client = APIClient()
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 401
def test_list_waiting_participants_non_owner():
"""Non-privileged users should not be allowed to list waiting participants."""
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 403
def test_list_waiting_participants_public_room():
"""Should return empty list for public rooms."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
user = UserFactory()
# Make user the room owner
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
# Lobby cache should be empty before the request
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys
with mock.patch(
"core.api.viewsets.LobbyService", autospec=True
) as mocked_lobby_service:
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
# Verify lobby service was not instantiated
mocked_lobby_service.assert_not_called()
assert response.status_code == 200
assert response.json() == {"participants": []}
def test_list_waiting_participants_success(settings):
"""Should successfully return list of waiting participants."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory()
# Make user the room owner
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Add participants in the lobby
cache.set(
f"mocked-cache-prefix_{room.id}_participant1",
{
"id": "participant1",
"username": "user1",
"status": "waiting",
"color": "#123456",
},
)
cache.set(
f"mocked-cache-prefix_{room.id}_participant2",
{
"id": "participant2",
"username": "user2",
"status": "waiting",
"color": "#654321",
},
)
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 200
participants = response.json().get("participants")
assert sorted(participants, key=lambda p: p["id"]) == [
{
"id": "participant1",
"username": "user1",
"status": "waiting",
"color": "#123456",
},
{
"id": "participant2",
"username": "user2",
"status": "waiting",
"color": "#654321",
},
]
def test_list_waiting_participants_empty(settings):
"""Should handle case when there are no waiting participants."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory()
# Make user the room owner
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 200
assert response.json() == {"participants": []}
@@ -12,6 +12,7 @@ import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
from ...models import RoomAccessLevel
pytestmark = pytest.mark.django_db
@@ -21,15 +22,34 @@ def test_api_rooms_retrieve_anonymous_private_pk():
Anonymous users should be allowed to retrieve a private room but should not be
given any token.
"""
room = RoomFactory(is_public=False)
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
assert response.status_code == 200
assert response.json() == {
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"slug": room.slug,
}
def test_api_rooms_retrieve_anonymous_trusted_pk():
"""
Anonymous users should be allowed to retrieve a room that has a trusted access_level,
but should not be given any token.
"""
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
assert response.status_code == 200
assert response.json() == {
"access_level": "trusted",
"id": str(room.id),
"is_administrable": False,
"is_public": False,
"name": room.name,
"slug": room.slug,
}
@@ -37,7 +57,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
"""It should be possible to get a room by its id stripped of its dashes."""
room = RoomFactory(is_public=False)
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
id_no_dashes = str(room.id)
client = APIClient()
@@ -45,9 +65,9 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
assert response.status_code == 200
assert response.json() == {
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
"is_public": False,
"name": room.name,
"slug": room.slug,
}
@@ -55,15 +75,15 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
def test_api_rooms_retrieve_anonymous_private_slug():
"""It should be possible to get a room by its slug."""
room = RoomFactory(is_public=False)
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
response = client.get(f"/api/v1.0/rooms/{room.slug!s}/")
assert response.status_code == 200
assert response.json() == {
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
"is_public": False,
"name": room.name,
"slug": room.slug,
}
@@ -71,15 +91,15 @@ def test_api_rooms_retrieve_anonymous_private_slug():
def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
"""Getting a room by a slug that is not normalized should work."""
room = RoomFactory(name="Réunion", is_public=False)
room = RoomFactory(name="Réunion", access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
response = client.get("/api/v1.0/rooms/Réunion/")
assert response.status_code == 200
assert response.json() == {
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
"is_public": False,
"name": room.name,
"slug": room.slug,
}
@@ -171,18 +191,18 @@ def test_api_rooms_retrieve_anonymous_unregistered_not_allowed():
)
def test_api_rooms_retrieve_anonymous_public(mock_token):
"""
Anonymous users should be able to retrieve a room with a token provided it is public.
Anonymous users should be able to retrieve a room with a token provided, if the room is public.
"""
room = RoomFactory(is_public=True)
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
client = APIClient()
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
assert response.status_code == 200
expected_name = f"{room.id!s}"
assert response.json() == {
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
"is_public": True,
"livekit": {
"url": "test_url_value",
"room": expected_name,
@@ -209,7 +229,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
which they are not related, provided the room is public.
They should not see related users.
"""
room = RoomFactory(is_public=True)
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
user = UserFactory()
client = APIClient()
@@ -222,9 +242,9 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
expected_name = f"{room.id!s}"
assert response.json() == {
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
"is_public": True,
"livekit": {
"url": "test_url_value",
"room": expected_name,
@@ -234,7 +254,53 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
"slug": room.slug,
}
mock_token.assert_called_once_with(room=expected_name, user=user, username=None)
mock_token.assert_called_once_with(
room=expected_name, user=user, username=None, color=None
)
@mock.patch("core.utils.generate_token", return_value="foo")
@override_settings(
LIVEKIT_CONFIGURATION={
"api_key": "key",
"api_secret": "secret",
"url": "test_url_value",
}
)
def test_api_rooms_retrieve_authenticated_trusted(mock_token):
"""
Authenticated users should be allowed to retrieve a room and get a token for a room to
which they are not related, provided the room has a trusted access_level.
They should not see related users.
"""
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/rooms/{room.id!s}/",
)
assert response.status_code == 200
expected_name = f"{room.id!s}"
assert response.json() == {
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
"livekit": {
"url": "test_url_value",
"room": expected_name,
"token": "foo",
},
"name": room.name,
"slug": room.slug,
}
mock_token.assert_called_once_with(
room=expected_name, user=user, username=None, color=None
)
def test_api_rooms_retrieve_authenticated():
@@ -242,7 +308,7 @@ def test_api_rooms_retrieve_authenticated():
Authenticated users should be allowed to retrieve a private room to which they
are not related but should not be given any token.
"""
room = RoomFactory(is_public=False)
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory()
client = APIClient()
@@ -254,9 +320,9 @@ def test_api_rooms_retrieve_authenticated():
assert response.status_code == 200
assert response.json() == {
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
"is_public": False,
"name": room.name,
"slug": room.slug,
}
@@ -270,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}/",
)
@@ -294,39 +359,13 @@ 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 == {
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
"is_public": room.is_public,
"livekit": {
"url": "test_url_value",
"room": expected_name,
@@ -336,7 +375,9 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
"slug": room.slug,
}
mock_token.assert_called_once_with(room=expected_name, user=user, username=None)
mock_token.assert_called_once_with(
room=expected_name, user=user, username=None, color=None
)
@mock.patch("core.utils.generate_token", return_value="foo")
@@ -347,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()
@@ -380,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,
@@ -391,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,
@@ -400,9 +448,9 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
)
expected_name = str(room.id)
assert content_dict == {
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": True,
"is_public": room.is_public,
"configuration": {},
"livekit": {
"url": "test_url_value",
@@ -413,4 +461,6 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
"slug": room.slug,
}
mock_token.assert_called_once_with(room=expected_name, user=user, username=None)
mock_token.assert_called_once_with(
room=expected_name, user=user, username=None, color=None
)
@@ -8,6 +8,7 @@ import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...models import RoomAccessLevel
pytestmark = pytest.mark.django_db
@@ -54,17 +55,18 @@ def test_api_rooms_update_members():
not be allowed to update it.
"""
user = UserFactory()
room = RoomFactory(name="Old name", users=[(user, "member")])
room = RoomFactory(
access_level=RoomAccessLevel.PUBLIC, name="Old name", users=[(user, "member")]
)
client = APIClient()
client.force_login(user)
new_is_public = not room.is_public
response = client.put(
f"/api/v1.0/rooms/{room.id!s}/",
{
"name": "New name",
"slug": "should-be-ignored",
"is_public": new_is_public,
"access_level": RoomAccessLevel.RESTRICTED,
"configuration": {"the_key": "the_value"},
},
format="json",
@@ -73,24 +75,26 @@ def test_api_rooms_update_members():
room.refresh_from_db()
assert room.name == "Old name"
assert room.slug == "old-name"
assert room.is_public != new_is_public
assert room.access_level != RoomAccessLevel.RESTRICTED
assert room.configuration == {}
def test_api_rooms_update_administrators():
"""Administrators or owners of a room should be allowed to update it."""
user = UserFactory()
room = RoomFactory(users=[(user, random.choice(["administrator", "owner"]))])
room = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED,
users=[(user, random.choice(["administrator", "owner"]))],
)
client = APIClient()
client.force_login(user)
new_is_public = not room.is_public
response = client.put(
f"/api/v1.0/rooms/{room.id!s}/",
{
"name": "New name",
"slug": "should-be-ignored",
"is_public": new_is_public,
"access_level": RoomAccessLevel.PUBLIC,
"configuration": {"the_key": "the_value"},
},
format="json",
@@ -99,7 +103,7 @@ def test_api_rooms_update_administrators():
room.refresh_from_db()
assert room.name == "New name"
assert room.slug == "new-name"
assert room.is_public == new_is_public
assert room.access_level == RoomAccessLevel.PUBLIC
assert room.configuration == {"the_key": "the_value"}
@@ -0,0 +1,191 @@
"""
Test LiveKit webhook endpoint on the rooms API.
"""
# ruff: noqa: PLR0913
# pylint: disable=R0913,W0621,R0917,W0613
import base64
import hashlib
import json
from unittest import mock
import pytest
from livekit import api
from ...services.livekit_events import ActionFailedError, LiveKitEventsService
@pytest.fixture
def webhook_event_data():
"""Sample webhook event data for testing."""
return {
"event": "room_finished",
"room": {
"sid": "RM_hycBMAjmt6Ub",
"name": "00000000-0000-0000-0000-000000000000",
"emptyTimeout": 300,
"creationTime": "1692627281",
"turnPassword": "2Pvdj+/WV1xV4EkB8klJ9xkXDWY=",
"enabledCodecs": [
{"mime": "audio/opus"},
{"mime": "video/H264"},
{"mime": "video/VP8"},
],
},
"id": "EV_eugWmGhovZmm",
"createdAt": "1692985556",
}
@pytest.fixture
def serialized_event_data(webhook_event_data):
"""Serialize event data to JSON."""
return json.dumps(webhook_event_data)
@pytest.fixture
def mock_livekit_config(settings):
"""Mock LiveKit configuration."""
settings.LIVEKIT_CONFIGURATION = {
"api_key": "test_api_key",
"api_secret": "test_api_secret",
"url": "https://test-livekit.example.com/",
}
return settings.LIVEKIT_CONFIGURATION
@pytest.fixture
def auth_token(serialized_event_data, mock_livekit_config):
"""Generate authentication token for webhook request."""
hash64 = base64.b64encode(
hashlib.sha256(serialized_event_data.encode()).digest()
).decode()
token = api.AccessToken(
mock_livekit_config["api_key"], mock_livekit_config["api_secret"]
)
token.claims.sha256 = hash64
return token.to_jwt()
def test_missing_auth_header(client, serialized_event_data, mock_livekit_config):
"""Should return 401 when auth header is missing."""
response = client.post(
"/api/v1.0/rooms/webhooks-livekit/",
data=serialized_event_data,
content_type="application/json",
)
assert response.status_code == 401
assert response.json() == {
"status": "error",
"message": "Authorization header missing",
}
def test_invalid_payload(client, auth_token, mock_livekit_config):
"""Should return 400 for invalid payload."""
response = client.post(
"/api/v1.0/rooms/webhooks-livekit/",
data=json.dumps({"invalid": "payload"}),
content_type="application/json",
HTTP_AUTHORIZATION=auth_token,
)
assert response.status_code == 400
assert response.json() == {"status": "error", "message": "Invalid webhook payload"}
def test_unknown_event_type(client, mock_livekit_config):
"""Should return 422 for unknown event type."""
event_data = json.dumps({"event": "unknown_event_type"})
# Generate auth token for this specific payload
hash64 = base64.b64encode(hashlib.sha256(event_data.encode()).digest()).decode()
token = api.AccessToken(
mock_livekit_config["api_key"], mock_livekit_config["api_secret"]
)
token.claims.sha256 = hash64
auth_token = token.to_jwt()
response = client.post(
"/api/v1.0/rooms/webhooks-livekit/",
data=event_data,
content_type="application/json",
HTTP_AUTHORIZATION=auth_token,
)
assert response.status_code == 422
assert response.json() == {
"status": "error",
"message": "Unknown webhook type: unknown_event_type",
}
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
def test_handled_event_type(
mock_handler,
client,
serialized_event_data,
auth_token,
mock_livekit_config,
):
"""Should process valid webhook successfully."""
response = client.post(
"/api/v1.0/rooms/webhooks-livekit/",
data=serialized_event_data,
content_type="application/json",
HTTP_AUTHORIZATION=auth_token,
)
mock_handler.assert_called_once()
assert response.status_code == 200
assert response.json() == {"status": "success"}
def test_unhandled_event_type(client, mock_livekit_config):
"""Should return 200 for event types that have no handler."""
event_data = json.dumps({"event": "room_started"})
hash64 = base64.b64encode(hashlib.sha256(event_data.encode()).digest()).decode()
token = api.AccessToken(
mock_livekit_config["api_key"], mock_livekit_config["api_secret"]
)
token.claims.sha256 = hash64
auth_token = token.to_jwt()
response = client.post(
"/api/v1.0/rooms/webhooks-livekit/",
data=event_data,
content_type="application/json",
HTTP_AUTHORIZATION=auth_token,
)
assert response.status_code == 200
assert response.json() == {"status": "success"}
def test_action_error(client, mock_livekit_config):
"""Should raise exceptions when errors occur during LiveKit webhook processing."""
event_data = json.dumps(
{
"event": "room_finished",
"room": {"sid": "RM_hycBMAjmt6Ub", "name": "invalid-uuid"},
}
)
hash64 = base64.b64encode(hashlib.sha256(event_data.encode()).digest()).decode()
token = api.AccessToken(
mock_livekit_config["api_key"], mock_livekit_config["api_secret"]
)
token.claims.sha256 = hash64
auth_token = token.to_jwt()
with pytest.raises(
ActionFailedError,
match="Failed to process room finished event",
):
client.post(
"/api/v1.0/rooms/webhooks-livekit/",
data=event_data,
content_type="application/json",
HTTP_AUTHORIZATION=auth_token,
)
@@ -0,0 +1,132 @@
"""
Test LiveKitEvents service.
"""
# pylint: disable=W0621,W0613, W0212
import uuid
from unittest import mock
import pytest
from core.services.livekit_events import (
ActionFailedError,
AuthenticationError,
InvalidPayloadError,
LiveKitEventsService,
UnsupportedEventTypeError,
api,
)
from core.services.lobby import LobbyService
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_livekit_config(settings):
"""Mock LiveKit configuration."""
settings.LIVEKIT_CONFIGURATION = {
"api_key": "test_api_key",
"api_secret": "test_api_secret",
"url": "https://test-livekit.example.com/",
}
return settings.LIVEKIT_CONFIGURATION
@pytest.fixture
def service(mock_livekit_config):
"""Initialize LiveKitEventsService."""
return LiveKitEventsService()
@mock.patch("livekit.api.TokenVerifier")
@mock.patch("livekit.api.WebhookReceiver")
def test_initialization(
mock_webhook_receiver, mock_token_verifier, mock_livekit_config
):
"""Should correctly initialize the service with required dependencies."""
api_key = mock_livekit_config["api_key"]
api_secret = mock_livekit_config["api_secret"]
service = LiveKitEventsService()
mock_token_verifier.assert_called_once_with(api_key, api_secret)
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
assert isinstance(service.lobby_service, LobbyService)
@mock.patch.object(LobbyService, "clear_room_cache")
def test_handle_room_finished(mock_clear_cache, service):
"""Should clear lobby cache when room is finished."""
mock_room_name = uuid.uuid4()
mock_data = mock.MagicMock()
mock_data.room.name = str(mock_room_name)
service._handle_room_finished(mock_data)
mock_clear_cache.assert_called_once_with(mock_room_name)
@mock.patch.object(
LobbyService, "clear_room_cache", side_effect=Exception("Test error")
)
def test_handle_room_finished_error(mock_clear_cache, service):
"""Should raise ActionFailedError when processing fails."""
mock_data = mock.MagicMock()
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
with pytest.raises(
ActionFailedError, match="Failed to process room finished event"
):
service._handle_room_finished(mock_data)
def test_handle_room_finished_invalid_room_name(service):
"""Should raise ActionFailedError when processing fails."""
mock_data = mock.MagicMock()
mock_data.room.name = "invalid"
with pytest.raises(
ActionFailedError, match="Failed to process room finished event"
):
service._handle_room_finished(mock_data)
@mock.patch.object(
api.WebhookReceiver, "receive", side_effect=Exception("Invalid payload")
)
def test_receive_invalid_payload(mock_receive, service):
"""Should raise InvalidPayloadError for invalid payloads."""
mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}"
with pytest.raises(InvalidPayloadError, match="Invalid webhook payload"):
service.receive(mock_request)
def test_receive_missing_auth(service):
"""Should raise AuthenticationError when auth header is missing."""
mock_request = mock.MagicMock()
mock_request.headers = {}
with pytest.raises(AuthenticationError, match="Authorization header missing"):
service.receive(mock_request)
@mock.patch.object(api.WebhookReceiver, "receive")
def test_receive_unsupported_event(mock_receive, service):
"""Should raise LiveKitWebhookError for unsupported events."""
mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}"
# Mock returned data with unsupported event type
mock_data = mock.MagicMock()
mock_data.event = "unsupported_event"
mock_receive.return_value = mock_data
with pytest.raises(
UnsupportedEventTypeError, match="Unknown webhook type: unsupported_event"
):
service.receive(mock_request)
@@ -0,0 +1,943 @@
"""
Test lobby service.
"""
# pylint: disable=W0621,W0613, W0212, R0913
# ruff: noqa: PLR0913
import json
import uuid
from unittest import mock
from django.conf import settings
from django.core.cache import cache
from django.http import HttpResponse
import pytest
from livekit.api import TwirpError
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.services.lobby import (
LobbyNotificationError,
LobbyParticipant,
LobbyParticipantNotFound,
LobbyParticipantParsingError,
LobbyParticipantStatus,
LobbyService,
)
pytestmark = pytest.mark.django_db
@pytest.fixture
def lobby_service():
"""Return a LobbyService instance."""
return LobbyService()
@pytest.fixture
def participant_id():
"""Return a string ID for test participant."""
return "test-participant-id"
@pytest.fixture
def username():
"""Return a username for test participant."""
return "test-username"
@pytest.fixture
def participant_dict():
"""Return a valid participant dictionary."""
return {
"status": "waiting",
"username": "test-username",
"id": "test-participant-id",
"color": "#123456",
}
@pytest.fixture
def participant_data():
"""Return a valid LobbyParticipant instance."""
return LobbyParticipant(
status=LobbyParticipantStatus.WAITING,
username="test-username",
id="test-participant-id",
color="#123456",
)
def test_lobby_participant_to_dict(participant_data):
"""Test LobbyParticipant serialization to dict."""
result = participant_data.to_dict()
assert result["status"] == "waiting"
assert result["username"] == "test-username"
assert result["id"] == "test-participant-id"
assert result["color"] == "#123456"
def test_lobby_participant_from_dict_success(participant_dict):
"""Test successful LobbyParticipant creation from dict."""
participant = LobbyParticipant.from_dict(participant_dict)
assert participant.status == LobbyParticipantStatus.WAITING
assert participant.username == "test-username"
assert participant.id == "test-participant-id"
assert participant.color == "#123456"
def test_lobby_participant_from_dict_default_status():
"""Test LobbyParticipant creation with missing status defaults to UNKNOWN."""
data_without_status = {
"username": "test-username",
"id": "test-participant-id",
"color": "#123456",
}
participant = LobbyParticipant.from_dict(data_without_status)
assert participant.status == LobbyParticipantStatus.UNKNOWN
assert participant.username == "test-username"
assert participant.id == "test-participant-id"
assert participant.color == "#123456"
def test_lobby_participant_from_dict_missing_fields():
"""Test LobbyParticipant creation with missing fields."""
invalid_data = {"username": "test-username"}
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
LobbyParticipant.from_dict(invalid_data)
def test_lobby_participant_from_dict_invalid_status():
"""Test LobbyParticipant creation with invalid status."""
invalid_data = {
"status": "invalid_status",
"username": "test-username",
"id": "test-participant-id",
"color": "#123456",
}
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
LobbyParticipant.from_dict(invalid_data)
def test_get_cache_key(lobby_service, participant_id):
"""Test cache key generation."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = lobby_service._get_cache_key(room.id, participant_id)
expected_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_{participant_id}"
assert cache_key == expected_key
def test_get_or_create_participant_id_from_cookie(lobby_service):
"""Test extracting participant ID from cookie."""
request = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: "existing-id"}
participant_id = lobby_service._get_or_create_participant_id(request)
assert participant_id == "existing-id"
@mock.patch("uuid.uuid4")
def test_get_or_create_participant_id_new(mock_uuid4, lobby_service):
"""Test creating new participant ID when cookie is missing."""
mock_uuid4.return_value = mock.Mock(hex="generated-id")
request = mock.Mock()
request.COOKIES = {}
participant_id = lobby_service._get_or_create_participant_id(request)
assert participant_id == "generated-id"
mock_uuid4.assert_called_once()
def test_prepare_response_existing_cookie(lobby_service, participant_id):
"""Test response preparation with existing cookie."""
response = HttpResponse()
response.cookies[settings.LOBBY_COOKIE_NAME] = "existing-cookie"
lobby_service.prepare_response(response, participant_id)
# Verify cookie wasn't set again
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
assert cookie.value == "existing-cookie"
assert cookie.value != participant_id
def test_prepare_response_new_cookie(lobby_service, participant_id):
"""Test response preparation with new cookie."""
response = HttpResponse()
lobby_service.prepare_response(response, participant_id)
# Verify cookie was set
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
assert cookie is not None
assert cookie.value == participant_id
assert cookie["httponly"] is True
assert cookie["secure"] is True
assert cookie["samesite"] == "Lax"
# It's a session cookies (no max_age specified):
assert not cookie["max-age"]
def test_can_bypass_lobby_public_room(lobby_service):
"""Should return True for public rooms regardless of user auth."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
# Anonymous user
user = mock.Mock()
user.is_authenticated = False
assert lobby_service.can_bypass_lobby(room, user) is True
# Authenticated user
user.is_authenticated = True
assert lobby_service.can_bypass_lobby(room, user) is True
def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
"""Should return True for trusted rooms with authenticated users."""
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
# Authenticated user
user = mock.Mock()
user.is_authenticated = True
assert lobby_service.can_bypass_lobby(room, user) is True
def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
"""Should return False for trusted rooms with anonymous users."""
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
# Anonymous user
user = mock.Mock()
user.is_authenticated = False
assert lobby_service.can_bypass_lobby(room, user) is False
def test_can_bypass_lobby_private_room(lobby_service):
"""Should return False for private rooms regardless of user auth."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Anonymous user
user = mock.Mock()
user.is_authenticated = False
assert lobby_service.can_bypass_lobby(room, user) is False
# Authenticated user
user.is_authenticated = True
assert lobby_service.can_bypass_lobby(room, user) is False
@mock.patch("core.utils.generate_livekit_config")
def test_request_entry_public_room(
mock_generate_config, lobby_service, participant_id, username
):
"""Test requesting entry to a public room."""
request = mock.Mock()
request.user = mock.Mock()
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
mocked_participant = LobbyParticipant(
status=LobbyParticipantStatus.UNKNOWN,
username=username,
id=participant_id,
color="#123456",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
mock_generate_config.return_value = {"token": "test-token"}
participant, livekit_config = lobby_service.request_entry(room, request, username)
assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"}
mock_generate_config.assert_called_once_with(
room_id=str(room.id),
user=request.user,
username=username,
color=participant.color,
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.utils.generate_livekit_config")
def test_request_entry_trusted_room(
mock_generate_config, lobby_service, participant_id, username
):
"""Test requesting entry to a trusted room when the user is authenticated."""
request = mock.Mock()
request.user = mock.Mock()
request.user.is_authenticated = True
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
mocked_participant = LobbyParticipant(
status=LobbyParticipantStatus.UNKNOWN,
username=username,
id=participant_id,
color="#123456",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
mock_generate_config.return_value = {"token": "test-token"}
participant, livekit_config = lobby_service.request_entry(room, request, username)
assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"}
mock_generate_config.assert_called_once_with(
room_id=str(room.id),
user=request.user,
username=username,
color=participant.color,
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.LobbyService.enter")
def test_request_entry_new_participant(
mock_enter, lobby_service, participant_id, username
):
"""Test requesting entry for a new participant."""
request = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=None)
participant_data = LobbyParticipant(
status=LobbyParticipantStatus.WAITING,
username=username,
id=participant_id,
color="#123456",
)
mock_enter.return_value = participant_data
participant, livekit_config = lobby_service.request_entry(room, request, username)
assert participant == participant_data
assert livekit_config is None
mock_enter.assert_called_once_with(room.id, participant_id, username)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.LobbyService.refresh_waiting_status")
def test_request_entry_waiting_participant(
mock_refresh, lobby_service, participant_id, username
):
"""Test requesting entry for a waiting participant."""
request = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
mocked_participant = LobbyParticipant(
status=LobbyParticipantStatus.WAITING,
username=username,
id=participant_id,
color="#123456",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
participant, livekit_config = lobby_service.request_entry(room, request, username)
assert participant.status == LobbyParticipantStatus.WAITING
assert livekit_config is None
mock_refresh.assert_called_once_with(room.id, participant_id)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.utils.generate_livekit_config")
def test_request_entry_accepted_participant(
mock_generate_config, lobby_service, participant_id, username
):
"""Test requesting entry for an accepted participant."""
request = mock.Mock()
request.user = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
mocked_participant = LobbyParticipant(
status=LobbyParticipantStatus.ACCEPTED,
username=username,
id=participant_id,
color="#123456",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
mock_generate_config.return_value = {"token": "test-token"}
participant, livekit_config = lobby_service.request_entry(room, request, username)
assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"}
mock_generate_config.assert_called_once_with(
room_id=str(room.id),
user=request.user,
username=username,
color="#123456",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.cache")
def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
"""Test refreshing waiting status for a participant."""
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service.refresh_waiting_status(room.id, participant_id)
mock_cache.touch.assert_called_once_with(
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
)
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color")
@mock.patch("core.services.lobby.LobbyService.notify_participants")
def test_enter_success(
mock_notify,
mock_generate_color,
mock_cache,
lobby_service,
participant_id,
username,
):
"""Test successful participant entry."""
mock_generate_color.return_value = "#123456"
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
participant = lobby_service.enter(room.id, participant_id, username)
mock_generate_color.assert_called_once_with(participant_id)
assert participant.status == LobbyParticipantStatus.WAITING
assert participant.username == username
assert participant.id == participant_id
assert participant.color == "#123456"
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
mock_cache.set.assert_called_once_with(
"mocked_cache_key",
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
mock_notify.assert_called_once_with(room_id=room.id)
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color")
@mock.patch("core.services.lobby.LobbyService.notify_participants")
def test_enter_with_notification_error(
mock_notify,
mock_generate_color,
mock_cache,
lobby_service,
participant_id,
username,
):
"""Test participant entry with notification error."""
mock_generate_color.return_value = "#123456"
mock_notify.side_effect = LobbyNotificationError("Error notifying")
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
participant = lobby_service.enter(room.id, participant_id, username)
mock_generate_color.assert_called_once_with(participant_id)
assert participant.status == LobbyParticipantStatus.WAITING
assert participant.username == username
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
mock_cache.set.assert_called_once_with(
"mocked_cache_key",
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
@mock.patch("core.services.lobby.cache")
def test_get_participant_not_found(mock_cache, lobby_service, participant_id):
"""Test getting a participant that doesn't exist."""
mock_cache.get.return_value = None
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
result = lobby_service._get_participant(room.id, participant_id)
assert result is None
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
mock_cache.get.assert_called_once_with("mocked_cache_key")
@mock.patch("core.services.lobby.cache")
@mock.patch("core.services.lobby.LobbyParticipant.from_dict")
def test_get_participant_parsing_error(
mock_from_dict, mock_cache, lobby_service, participant_id
):
"""Test handling corrupted participant data."""
mock_cache.get.return_value = {"some": "data"}
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
mock_from_dict.side_effect = LobbyParticipantParsingError("Invalid data")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
result = lobby_service._get_participant(room.id, participant_id)
assert result is None
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
mock_cache.delete.assert_called_once_with("mocked_cache_key")
@mock.patch("core.services.lobby.cache")
def test_list_waiting_participants_empty(mock_cache, lobby_service):
"""Test listing waiting participants when none exist."""
mock_cache.keys.return_value = []
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
result = lobby_service.list_waiting_participants(room.id)
assert result == []
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_not_called()
@mock.patch("core.services.lobby.cache")
def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
"""Test listing waiting participants with valid data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
mock_cache.keys.return_value = [cache_key]
mock_cache.get_many.return_value = {cache_key: participant_dict}
result = lobby_service.list_waiting_participants(room.id)
assert len(result) == 1
assert result[0]["status"] == "waiting"
assert result[0]["username"] == "test-username"
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_called_once_with([cache_key])
@mock.patch("core.services.lobby.cache")
def test_list_waiting_participants_multiple(mock_cache, lobby_service):
"""Test listing multiple waiting participants with valid data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
participant1 = {
"status": "waiting",
"username": "user1",
"id": "participant1",
"color": "#123456",
}
participant2 = {
"status": "waiting",
"username": "user2",
"id": "participant2",
"color": "#654321",
}
mock_cache.keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = {
cache_key1: participant1,
cache_key2: participant2,
}
result = lobby_service.list_waiting_participants(room.id)
assert len(result) == 2
# Verify both participants are in the result
assert any(p["id"] == "participant1" and p["username"] == "user1" for p in result)
assert any(p["id"] == "participant2" and p["username"] == "user2" for p in result)
# Verify all participants have waiting status
assert all(p["status"] == "waiting" for p in result)
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
@mock.patch("core.services.lobby.cache")
def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
"""Test listing waiting participants with corrupted data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
mock_cache.keys.return_value = [cache_key]
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
result = lobby_service.list_waiting_participants(room.id)
assert result == []
mock_cache.delete.assert_called_once_with(cache_key)
@mock.patch("core.services.lobby.cache")
def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service):
"""Test listing waiting participants with one valid and one corrupted entry."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
valid_participant = {
"status": "waiting",
"username": "user2",
"id": "participant2",
"color": "#654321",
}
corrupted_participant = {"invalid": "data"}
mock_cache.keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = {
cache_key1: corrupted_participant,
cache_key2: valid_participant,
}
result = lobby_service.list_waiting_participants(room.id)
# Check that only the valid participant is returned
assert len(result) == 1
assert result[0]["id"] == "participant2"
assert result[0]["status"] == "waiting"
assert result[0]["username"] == "user2"
# Verify corrupted entry was deleted
mock_cache.delete.assert_called_once_with(cache_key1)
# Verify both cache keys were queried
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
@mock.patch("core.services.lobby.cache")
def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
"""Test listing only waiting participants (not accepted/denied)."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
participant1 = {
"status": "waiting",
"username": "user1",
"id": "participant1",
"color": "#123456",
}
participant2 = {
"status": "accepted",
"username": "user2",
"id": "participant2",
"color": "#654321",
}
mock_cache.keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = {
cache_key1: participant1,
cache_key2: participant2,
}
result = lobby_service.list_waiting_participants(room.id)
assert len(result) == 1
assert result[0]["id"] == "participant1"
assert result[0]["status"] == "waiting"
@mock.patch("core.services.lobby.LobbyService._update_participant_status")
def test_handle_participant_entry_allow(mock_update, lobby_service, participant_id):
"""Test handling allowed participant entry."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service.handle_participant_entry(room.id, participant_id, allow_entry=True)
mock_update.assert_called_once_with(
room.id,
participant_id,
status=LobbyParticipantStatus.ACCEPTED,
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
)
@mock.patch("core.services.lobby.LobbyService._update_participant_status")
def test_handle_participant_entry_deny(mock_update, lobby_service, participant_id):
"""Test handling denied participant entry."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service.handle_participant_entry(room.id, participant_id, allow_entry=False)
mock_update.assert_called_once_with(
room.id,
participant_id,
status=LobbyParticipantStatus.DENIED,
timeout=settings.LOBBY_DENIED_TIMEOUT,
)
@mock.patch("core.services.lobby.cache")
def test_update_participant_status_not_found(mock_cache, lobby_service, participant_id):
"""Test updating status for non-existent participant."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
mock_cache.get.return_value = None
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
with pytest.raises(LobbyParticipantNotFound, match="Participant not found"):
lobby_service._update_participant_status(
room.id,
participant_id,
status=LobbyParticipantStatus.ACCEPTED,
timeout=60,
)
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
mock_cache.get.assert_called_once_with("mocked_cache_key")
@mock.patch("core.services.lobby.cache")
@mock.patch("core.services.lobby.LobbyParticipant.from_dict")
def test_update_participant_status_corrupted_data(
mock_from_dict, mock_cache, lobby_service, participant_id
):
"""Test updating status with corrupted participant data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
mock_cache.get.return_value = {"some": "data"}
mock_from_dict.side_effect = LobbyParticipantParsingError("Invalid data")
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
with pytest.raises(LobbyParticipantParsingError):
lobby_service._update_participant_status(
room.id,
participant_id,
status=LobbyParticipantStatus.ACCEPTED,
timeout=60,
)
mock_cache.delete.assert_called_once_with("mocked_cache_key")
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.cache")
def test_update_participant_status_success(mock_cache, lobby_service, participant_id):
"""Test successful participant status update."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
participant_dict = {
"status": "waiting",
"username": "test-username",
"id": participant_id,
"color": "#123456",
}
mock_cache.get.return_value = participant_dict
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
lobby_service._update_participant_status(
room.id,
participant_id,
status=LobbyParticipantStatus.ACCEPTED,
timeout=60,
)
expected_data = {
"status": "accepted",
"username": "test-username",
"id": participant_id,
"color": "#123456",
}
mock_cache.set.assert_called_once_with(
"mocked_cache_key", expected_data, timeout=60
)
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success_no_room(mock_create_livekit_client, lobby_service):
"""Test the notify_participants method when the LiveKit room doesn't exist yet."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock()
# Create a proper response object with an empty rooms list
class MockResponse:
"""LiveKit API response mock with empty rooms list."""
rooms = []
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Act
lobby_service.notify_participants(room.id)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was not called since no room exists
mock_api_instance.room.send_data.assert_not_called()
# Verify the connection was properly closed
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success(mock_create_livekit_client, lobby_service):
"""Test successful participant notification."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock()
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Call the function
lobby_service.notify_participants(room.id)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was called
mock_api_instance.room.send_data.assert_called_once()
send_data_request = mock_api_instance.room.send_data.call_args[0][0]
assert send_data_request.room == str(room.id)
assert (
json.loads(send_data_request.data.decode("utf-8"))["type"]
== settings.LOBBY_NOTIFICATION_TYPE
)
assert send_data_request.kind == 0 # RELIABLE mode in Livekit protocol
# Verify aclose was called
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.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, status=123)
)
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Call the function and expect an exception
with pytest.raises(
LobbyNotificationError, match="Failed to notify room participants"
):
lobby_service.notify_participants(room.id)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify send_data was called
mock_api_instance.room.send_data.assert_called_once()
# Verify aclose was still called after the exception
mock_api_instance.aclose.assert_called_once()
def test_clear_room_cache(settings, lobby_service):
"""Test clearing room cache actually removes entries from cache."""
settings.LOBBY_KEY_PREFIX = "test-lobby"
settings.LOBBY_WAITING_TIMEOUT = 10000
settings.LOBBY_ACCEPTED_TIMEOUT = 10000
settings.LOBBY_DENIED_TIMEOUT = 10000
room_id = uuid.uuid4()
cache.set(
f"test-lobby_{room_id!s}_participant1",
LobbyParticipant(
status=LobbyParticipantStatus.WAITING,
username="participant1",
id="participant1",
color="#123456",
),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
cache.set(
f"test-lobby_{room_id!s}_participant2",
LobbyParticipant(
status=LobbyParticipantStatus.ACCEPTED,
username="participant2",
id="participant2",
color="#123456",
),
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
)
cache.set(
f"test-lobby_{room_id!s}_participant3",
LobbyParticipant(
status=LobbyParticipantStatus.DENIED,
username="participant3",
id="participant3",
color="#123456",
),
timeout=settings.LOBBY_DENIED_TIMEOUT,
)
lobby_service.clear_room_cache(room_id)
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
def test_clear_room_empty(settings, lobby_service):
"""Test clearing room cache when it's already empty."""
settings.LOBBY_KEY_PREFIX = "test-lobby"
room_id = uuid.uuid4()
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
lobby_service.clear_room_cache(room_id)
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
@@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
import brevo_python
import pytest
from core.services.marketing_service import (
from core.services.marketing import (
BrevoMarketingService,
ContactCreationError,
ContactData,
@@ -144,9 +144,7 @@ def clear_marketing_cache():
def test_get_marketing_service_caching(clear_marketing_cache):
"""Test marketing service caching behavior."""
settings.BREVO_API_KEY = "test-api-key"
settings.MARKETING_SERVICE_CLASS = (
"core.services.marketing_service.BrevoMarketingService"
)
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
service1 = get_marketing_service()
service2 = get_marketing_service()
@@ -163,14 +161,12 @@ def test_get_marketing_service_invalid_class(clear_marketing_cache):
get_marketing_service()
@mock.patch("core.services.marketing_service.import_string")
@mock.patch("core.services.marketing.import_string")
def test_service_instantiation_called_once(mock_import_string, clear_marketing_cache):
"""Test service class is instantiated only once."""
settings.BREVO_API_KEY = "test-api-key"
settings.MARKETING_SERVICE_CLASS = (
"core.services.marketing_service.BrevoMarketingService"
)
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
get_marketing_service.cache_clear()
mock_service_cls = mock.Mock()
@@ -16,7 +16,7 @@ from ..factories import (
UserFactory,
UserResourceAccessFactory,
)
from ..models import ResourceAccess, RoleChoices
from ..models import ResourceAccess, RoleChoices, RoomAccessLevel
pytestmark = pytest.mark.django_db
@@ -44,13 +44,13 @@ def test_api_room_user_accesses_list_authenticated_not_related():
client = APIClient()
client.force_login(user)
public_room = RoomFactory(is_public=True)
public_room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
UserResourceAccessFactory(resource=public_room)
UserResourceAccessFactory(resource=public_room, role="member")
UserResourceAccessFactory(resource=public_room, role="administrator")
UserResourceAccessFactory(resource=public_room, role="owner")
private_room = RoomFactory(is_public=False)
private_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
UserResourceAccessFactory(resource=private_room)
UserResourceAccessFactory(resource=private_room, role="member")
UserResourceAccessFactory(resource=private_room, role="administrator")
@@ -73,13 +73,17 @@ def test_api_room_user_accesses_list_authenticated_member():
client = APIClient()
client.force_login(user)
public_room = RoomFactory(is_public=True, users=[(user, "member")])
public_room = RoomFactory(
access_level=RoomAccessLevel.PUBLIC, users=[(user, "member")]
)
UserResourceAccessFactory(resource=public_room)
UserResourceAccessFactory(resource=public_room, role="member")
UserResourceAccessFactory(resource=public_room, role="administrator")
UserResourceAccessFactory(resource=public_room, role="owner")
private_room = RoomFactory(is_public=False, users=[(user, "member")])
private_room = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED, users=[(user, "member")]
)
UserResourceAccessFactory(resource=private_room)
UserResourceAccessFactory(resource=private_room, role="member")
UserResourceAccessFactory(resource=private_room, role="administrator")
@@ -102,7 +106,7 @@ def test_api_room_user_accesses_list_authenticated_administrator():
client = APIClient()
client.force_login(user)
public_room = RoomFactory(is_public=True)
public_room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
public_room_accesses = (
# Access for the logged-in user
UserResourceAccessFactory(
@@ -115,7 +119,7 @@ def test_api_room_user_accesses_list_authenticated_administrator():
UserResourceAccessFactory(resource=public_room, role="owner"),
)
private_room = RoomFactory(is_public=False)
private_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
private_room_accesses = (
# Access for the logged-in user
UserResourceAccessFactory(
@@ -148,7 +152,7 @@ def test_api_room_user_accesses_list_authenticated_owner():
client = APIClient()
client.force_login(user)
public_room = RoomFactory(is_public=True)
public_room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
public_room_accesses = (
# Access for the logged-in user
UserResourceAccessFactory(resource=public_room, user=user, role="owner"),
@@ -158,7 +162,7 @@ def test_api_room_user_accesses_list_authenticated_owner():
UserResourceAccessFactory(resource=public_room, role="administrator"),
UserResourceAccessFactory(resource=public_room, role="owner"),
)
private_room = RoomFactory(is_public=False)
private_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
private_room_accesses = (
# Access for the logged-in user
UserResourceAccessFactory(resource=private_room, user=user, role="owner"),
@@ -252,8 +256,8 @@ def test_api_room_user_accesses_retrieve_authenticated_not_related():
client = APIClient()
client.force_login(user)
for is_public in [True, False]:
room = RoomFactory(is_public=is_public)
for access_level in [RoomAccessLevel.PUBLIC, RoomAccessLevel.RESTRICTED]:
room = RoomFactory(access_level=access_level)
assert len(RoleChoices.choices) == 3
for role, _name in RoleChoices.choices:
@@ -277,9 +281,9 @@ def test_api_room_user_accesses_retrieve_authenticated_member():
client = APIClient()
client.force_login(user)
for is_public in [True, False]:
for access_level in [RoomAccessLevel.PUBLIC, RoomAccessLevel.RESTRICTED]:
room = RoomFactory(
is_public=is_public,
access_level=access_level,
users=[(user, "member")],
)
assert len(RoleChoices.choices) == 3
@@ -305,8 +309,8 @@ def test_api_room_user_accesses_retrieve_authenticated_administrator():
client = APIClient()
client.force_login(user)
for is_public in [True, False]:
room = RoomFactory(is_public=is_public, users=[(user, "administrator")])
for access_level in [RoomAccessLevel.PUBLIC, RoomAccessLevel.RESTRICTED]:
room = RoomFactory(access_level=access_level, users=[(user, "administrator")])
assert len(RoleChoices.choices) == 3
for role, _name in RoleChoices.choices:
@@ -334,8 +338,8 @@ def test_api_room_user_accesses_retrieve_authenticated_owner():
client = APIClient()
client.force_login(user)
for is_public in [True, False]:
room = RoomFactory(is_public=is_public, users=[(user, "owner")])
for access_level in [RoomAccessLevel.PUBLIC, RoomAccessLevel.RESTRICTED]:
room = RoomFactory(access_level=access_level, users=[(user, "owner")])
assert len(RoleChoices.choices) == 3
for role, _name in RoleChoices.choices:
+31 -4
View File
@@ -22,10 +22,32 @@ def test_api_users_list_anonymous():
}
def test_api_users_list_authenticated():
def test_api_users_list_authenticated_secure(settings):
"""
Authenticated users should be able to list users.
Authenticated users should not be able to list any user
when ALLOW_UNSECURE_USER_LISTING is False.
"""
settings.ALLOW_UNSECURE_USER_LISTING = False
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.UserFactory.create_batch(2)
response = client.get(
"/api/v1.0/users/",
)
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 0
def test_api_users_list_authenticated_unsecure(settings):
"""
Authenticated users should be able to list all users
when ALLOW_UNSECURE_USER_LISTING is True.
"""
settings.ALLOW_UNSECURE_USER_LISTING = True
user = factories.UserFactory()
client = APIClient()
@@ -40,11 +62,12 @@ def test_api_users_list_authenticated():
assert len(content["results"]) == 3
def test_api_users_list_query_email():
def test_api_users_list_query_email(settings):
"""
Authenticated users should be able to list users
and filter by email.
"""
settings.ALLOW_UNSECURE_USER_LISTING = True
user = factories.UserFactory()
client = APIClient()
@@ -80,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()
@@ -98,6 +123,8 @@ def test_api_users_retrieve_me_authenticated():
"email": user.email,
"full_name": user.full_name,
"short_name": user.short_name,
"language": user.language,
"timezone": "UTC",
}
@@ -12,7 +12,8 @@ from core.factories import (
UserFactory,
UserRecordingAccessFactory,
)
from core.models import Recording, RecordingStatusChoices
from core.models import Recording, RecordingModeChoices, RecordingStatusChoices
from core.recording.enums import FileExtension
pytestmark = pytest.mark.django_db
@@ -216,3 +217,74 @@ def test_models_recording_worker_id_very_long():
too_long_id = "w" * 256
with pytest.raises(ValidationError):
RecordingFactory(worker_id=too_long_id)
# Test key property method
def test_models_recording_key_for_transcript(settings):
"""Test key property returns correct path for transcript mode."""
settings.RECORDING_OUTPUT_FOLDER = "/custom/path"
recording = RecordingFactory(mode=RecordingModeChoices.TRANSCRIPT)
expected_path = f"/custom/path/{recording.id}.{FileExtension.OGG.value}"
assert recording.key == expected_path
def test_models_recording_key_for_screen_recording(settings):
"""Test key property returns correct path for screen recording mode."""
settings.RECORDING_OUTPUT_FOLDER = "/custom/path"
recording = RecordingFactory(mode=RecordingModeChoices.SCREEN_RECORDING)
expected_path = f"/custom/path/{recording.id}.{FileExtension.MP4.value}"
assert recording.key == expected_path
def test_models_recording_key_for_unknown_mode(settings):
"""Test key property uses default extension for unknown mode."""
settings.RECORDING_OUTPUT_FOLDER = "/custom/path"
recording = RecordingFactory()
# Directly set an invalid mode (bypassing validation)
recording.mode = "unknown_mode"
expected_path = f"/custom/path/{recording.id}.{FileExtension.MP4.value}"
assert recording.key == expected_path
# Test is_saved method
def test_models_recording_is_saved_true():
"""Test is_saved property returns True for SAVED and NOTIFICATION_SUCCEEDED status."""
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
assert recording.is_saved is True
recording = RecordingFactory(status=RecordingStatusChoices.NOTIFICATION_SUCCEEDED)
assert recording.is_saved is True
def test_models_recording_is_saved_false_active():
"""Test is_saved property returns False for ACTIVE status."""
recording = RecordingFactory(status=RecordingStatusChoices.ACTIVE)
assert recording.is_saved is False
def test_models_recording_is_saved_false_initiated():
"""Test is_saved property returns False for INITIATED status."""
recording = RecordingFactory(status=RecordingStatusChoices.INITIATED)
assert recording.is_saved is False
@pytest.mark.parametrize(
"status",
[
RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.ABORTED,
],
)
def test_models_recording_is_saved_false_error_states(status):
"""Test is_saved property returns False for error statuses."""
recording = RecordingFactory(status=status)
assert recording.is_saved is False
+186 -3
View File
@@ -2,13 +2,19 @@
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
import pytest
from core.factories import RoomFactory, UserFactory
from core.models import Room
from core.models import Room, RoomAccessLevel
pytestmark = pytest.mark.django_db
@@ -80,10 +86,10 @@ def test_models_rooms_users():
assert list(room.users.all()) == [user]
def test_models_rooms_is_public_default():
def test_models_rooms_access_level_default():
"""A room should be public by default."""
room = Room.objects.create(name="room")
assert room.is_public is True
assert room.access_level == RoomAccessLevel.PUBLIC
# Access rights methods
@@ -164,3 +170,180 @@ def test_models_rooms_access_rights_owner_direct(django_assert_num_queries):
assert room.is_administrator(user) is True
with django_assert_num_queries(1):
assert room.is_owner(user) is True
def test_models_rooms_is_public_property():
"""Test the is_public property returns correctly based on access_level."""
# Test public room
public_room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
assert public_room.is_public is True
# 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, 3
room1 = RoomFactory()
assert mock_randbelow.call_count == 3
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, 3
)
mock_logger.reset_mock()
mock_randbelow.reset_mock()
settings.ROOM_TELEPHONY_PIN_MAX_RETRIES = 5
room2 = RoomFactory()
assert mock_randbelow.call_count == 5
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, 5
)
@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()
+80 -3
View File
@@ -11,8 +11,11 @@ from typing import Optional
from uuid import uuid4
from django.conf import settings
from django.core.files.storage import default_storage
from livekit.api import AccessToken, VideoGrants
import aiohttp
import botocore
from livekit.api import AccessToken, LiveKitAPI, VideoGrants
def generate_color(identity: str) -> str:
@@ -37,7 +40,9 @@ def generate_color(identity: str) -> str:
return f"hsl({hue}, {saturation}%, {lightness}%)"
def generate_token(room: str, user, username: Optional[str] = None) -> str:
def generate_token(
room: str, user, username: Optional[str] = None, color: Optional[str] = None
) -> str:
"""Generate a LiveKit access token for a user in a specific room.
Args:
@@ -45,6 +50,8 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
user (User): The user which request the access token.
username (Optional[str]): The username to be displayed in the room.
If none, a default value will be used.
color (Optional[str]): The color to be displayed in the room.
If none, a value will be generated
Returns:
str: The LiveKit JWT access token.
@@ -69,6 +76,9 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
identity = str(user.sub)
default_username = str(user)
if color is None:
color = generate_color(identity)
token = (
AccessToken(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
@@ -77,7 +87,74 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
.with_grants(video_grants)
.with_identity(identity)
.with_name(username or default_username)
.with_metadata(json.dumps({"color": generate_color(identity)}))
.with_metadata(json.dumps({"color": color}))
)
return token.to_jwt()
def generate_livekit_config(
room_id: str, user, username: str, color: Optional[str] = None
) -> dict:
"""Generate LiveKit configuration for room access.
Args:
room_id: Room identifier
user: User instance requesting access
username: Display name in room
Returns:
dict: LiveKit configuration with URL, room and access token
"""
return {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": room_id,
"token": generate_token(
room=room_id, user=user, username=username, color=color
),
}
def generate_s3_authorization_headers(key):
"""
Generate authorization headers for an s3 object.
These headers can be used as an alternative to signed urls with many benefits:
- the urls of our files never expire and can be stored in our recording' metadata
- we don't leak authorized urls that could be shared (file access can only be done
with cookies)
- access control is truly realtime
- the object storage service does not need to be exposed on internet
"""
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
"get_object",
ExpiresIn=0,
Params={"Bucket": default_storage.bucket_name, "Key": key},
)
request = botocore.awsrequest.AWSRequest(method="get", url=url)
s3_client = default_storage.connection.meta.client
# pylint: disable=protected-access
credentials = s3_client._request_signer._credentials # noqa: SLF001
frozen_credentials = credentials.get_frozen_credentials()
region = s3_client.meta.region_name
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
auth.add_auth(request)
return request
def create_livekit_client(custom_configuration=None):
"""Create and return a configured LiveKit API client."""
custom_session = None
if not settings.LIVEKIT_VERIFY_SSL:
connector = aiohttp.TCPConnector(ssl=False)
custom_session = aiohttp.ClientSession(connector=connector)
# Use default configuration if none provided
configuration = custom_configuration or settings.LIVEKIT_CONFIGURATION
return LiveKitAPI(session=custom_session, **configuration)
Binary file not shown.
@@ -0,0 +1,438 @@
# 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-16 17:04+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/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.
+337 -112
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
"POT-Creation-Date: 2025-05-16 17:04+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,192 +17,417 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:31
#: core/admin.py:26
msgid "Personal info"
msgstr ""
#: core/admin.py:33
#: core/admin.py:39
msgid "Permissions"
msgstr ""
msgstr "Permissions"
#: core/admin.py:45
#: core/admin.py:51
msgid "Important dates"
msgstr ""
msgstr "Important dates"
#: core/api/serializers.py:128
msgid "Markdown Body"
msgstr ""
#: core/api/serializers.py:63
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "You must be administrator or owner of a room to add accesses to it."
#: core/authentication.py:71
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication.py:91
msgid "Claims contained no recognizable user identification"
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:41
msgid "id"
msgstr ""
#: core/models.py:42
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:48
msgid "created on"
msgstr ""
msgstr "Owner"
#: core/models.py:49
msgid "date and time at which a record was created"
msgstr ""
msgid "Initiated"
msgstr "Initiated"
#: core/models.py:50
msgid "Active"
msgstr "Active"
#: core/models.py:51
msgid "Stopped"
msgstr "Stopped"
#: core/models.py:52
msgid "Saved"
msgstr "Saved"
#: core/models.py:53
msgid "Aborted"
msgstr "Aborted"
#: core/models.py:54
msgid "updated on"
msgstr ""
msgid "Failed to Start"
msgstr "Failed to Start"
#: core/models.py:55
msgid "date and time at which a record was last updated"
msgstr ""
msgid "Failed to Stop"
msgstr "Failed to Stop"
#: core/models.py:75
#: core/models.py:56
msgid "Notification succeeded"
msgstr "Notification succeeded"
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING"
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:90
msgid "Public Access"
msgstr "Public Access"
#: core/models.py:91
msgid "Trusted Access"
msgstr "Trusted Access"
#: core/models.py:92
msgid "Restricted Access"
msgstr "Restricted Access"
#: core/models.py:104
msgid "id"
msgstr "id"
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr "primary key for the record as UUID"
#: core/models.py:111
msgid "created on"
msgstr "created on"
#: core/models.py:112
msgid "date and time at which a record was created"
msgstr "date and time at which a record was created"
#: core/models.py:117
msgid "updated on"
msgstr "updated on"
#: core/models.py:118
msgid "date and time at which a record was last updated"
msgstr "date and time at which a record was last updated"
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: core/models.py:81
#: core/models.py:144
msgid "sub"
msgstr ""
msgstr "sub"
#: core/models.py:83
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:91
#: core/models.py:154
msgid "identity email address"
msgstr ""
msgstr "identity email address"
#: core/models.py:96
#: core/models.py:159
msgid "admin email address"
msgstr ""
msgstr "admin email address"
#: core/models.py:103
#: core/models.py:161
msgid "full name"
msgstr "full name"
#: core/models.py:163
msgid "short name"
msgstr "short name"
#: core/models.py:169
msgid "language"
msgstr ""
msgstr "language"
#: core/models.py:104
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr ""
msgstr "The language in which the user wants to see the interface."
#: core/models.py:110
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr ""
msgstr "The timezone in which the user wants to see times."
#: core/models.py:113
#: core/models.py:179
msgid "device"
msgstr ""
msgstr "device"
#: core/models.py:115
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr ""
msgstr "Whether the user is a device or a real user."
#: core/models.py:118
#: core/models.py:184
msgid "staff status"
msgstr ""
msgstr "staff status"
#: core/models.py:120
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr ""
msgstr "Whether the user can log into this admin site."
#: core/models.py:123
#: core/models.py:189
msgid "active"
msgstr ""
msgstr "active"
#: core/models.py:126
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:138
#: core/models.py:205
msgid "user"
msgstr ""
msgstr "user"
#: core/models.py:139
#: core/models.py:206
msgid "users"
msgstr ""
msgstr "users"
#: core/models.py:161
msgid "title"
msgstr ""
#: core/models.py:265
msgid "Resource"
msgstr "Resource"
#: core/models.py:162
msgid "description"
msgstr ""
#: core/models.py:266
msgid "Resources"
msgstr "Resources"
#: core/models.py:163
msgid "code"
msgstr ""
#: core/models.py:320
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:164
msgid "css"
msgstr ""
#: core/models.py:321
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:166
msgid "public"
msgstr ""
#: core/models.py:327
msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:168
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:383
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:174
msgid "Template"
msgstr ""
#: core/models.py:384
msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:175
msgid "Templates"
msgstr ""
#: core/models.py:391
msgid "Room PIN code"
msgstr "Room PIN code"
#: core/models.py:256
msgid "Template/user relation"
msgstr ""
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unique n-digit code that identifies this room in telephony mode."
#: core/models.py:257
msgid "Template/user relations"
msgstr ""
#: core/models.py:398 core/models.py:552
msgid "Room"
msgstr "Room"
#: core/models.py:263
msgid "This user is already in this template."
msgstr ""
#: core/models.py:399
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:269
msgid "This team is already in this template."
msgstr ""
#: core/models.py:563
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:275
#: core/models.py:565
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:573
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:574
msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called."
#: core/models.py:580
msgid "Recording"
msgstr "Recording"
#: core/models.py:581
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:689
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:690
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:696
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:702
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:708
msgid "Either user or team must be set, not both."
msgstr ""
msgstr "Either user or team must be set, not both."
#: meet/settings.py:134
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Your recording is ready"
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video call in progress: {sender.email} is waiting for you to connect"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email"
msgstr "Logo email"
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call"
msgstr "invites you to join an ongoing video call"
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "JOIN THE CALL"
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
msgid ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
msgstr ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:"
msgstr "Tips for a better experience:"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality"
msgstr "Use Chrome or Firefox for better call quality"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining"
msgstr "Test your microphone and camera before joining"
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Make sure you have a stable internet connection"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Thank you for using %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr "Your recording is ready!"
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
msgstr ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid " The recording will expire in %(days)s days. "
msgstr " The recording will expire in %(days)s days. "
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr "To keep this recording permanently:"
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \"Open\" button below "
msgstr "Click the \"Open\" button below "
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \"Download\" button in the interface "
msgstr "Use the \"Download\" button in the interface "
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
msgid "Save the file to your preferred location"
msgstr "Save the file to your preferred location"
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr "Open"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
msgstr ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
#: meet/settings.py:162
msgid "English"
msgstr ""
msgstr "English"
#: meet/settings.py:135
#: meet/settings.py:163
msgid "French"
msgstr ""
msgstr "French"
#: meet/settings.py:164
msgid "Dutch"
msgstr "Dutch"
#: meet/settings.py:165
msgid "German"
msgstr "German"
Binary file not shown.
+340 -109
View File
@@ -8,201 +8,432 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
"POT-Creation-Date: 2025-05-16 17:04+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:31
#: core/admin.py:26
msgid "Personal info"
msgstr ""
msgstr "Informations personnelles"
#: core/admin.py:33
#: core/admin.py:39
msgid "Permissions"
msgstr ""
msgstr "Permissions"
#: core/admin.py:45
#: core/admin.py:51
msgid "Important dates"
msgstr ""
msgstr "Dates importantes"
#: core/api/serializers.py:128
msgid "Markdown Body"
#: 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.py:71
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication.py:91
msgid "Claims contained no recognizable user identification"
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:41
msgid "id"
msgstr ""
#: core/models.py:42
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:48
msgid "created on"
msgstr ""
msgstr "Propriétaire"
#: core/models.py:49
msgid "date and time at which a record was created"
msgstr ""
msgid "Initiated"
msgstr "Initié"
#: core/models.py:50
msgid "Active"
msgstr "Actif"
#: core/models.py:51
msgid "Stopped"
msgstr "Arrêté"
#: core/models.py:52
msgid "Saved"
msgstr "Enregistré"
#: core/models.py:53
msgid "Aborted"
msgstr "Abandonné"
#: core/models.py:54
msgid "updated on"
msgstr ""
msgid "Failed to Start"
msgstr "Échec au démarrage"
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Échec à l'arrêt"
#: core/models.py:56
msgid "Notification succeeded"
msgstr "Notification réussie"
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION"
#: core/models.py:90
msgid "Public Access"
msgstr "Accès public"
#: core/models.py:91
msgid "Trusted Access"
msgstr "Accès de confiance"
#: core/models.py:92
msgid "Restricted Access"
msgstr "Accès restreint"
#: core/models.py:104
msgid "id"
msgstr "id"
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:111
msgid "created on"
msgstr "créé le"
#: core/models.py:112
msgid "date and time at which a record was created"
msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:117
msgid "updated on"
msgstr "mis à jour le"
#: core/models.py:118
msgid "date and time at which a record was last updated"
msgstr ""
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
"fois"
#: core/models.py:75
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, "
"des chiffres et les caractères @/./+/-/_."
#: core/models.py:81
#: core/models.py:144
msgid "sub"
msgstr ""
msgstr "sub"
#: core/models.py:83
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./"
"+/-/_ uniquement."
#: core/models.py:91
#: core/models.py:154
msgid "identity email address"
msgstr ""
msgstr "adresse e-mail d'identité"
#: core/models.py:96
#: core/models.py:159
msgid "admin email address"
msgstr ""
msgstr "adresse e-mail d'administrateur"
#: core/models.py:103
#: core/models.py:161
msgid "full name"
msgstr "nom complet"
#: core/models.py:163
msgid "short name"
msgstr "nom court"
#: core/models.py:169
msgid "language"
msgstr ""
msgstr "langue"
#: core/models.py:104
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr ""
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:110
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr ""
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:113
#: core/models.py:179
msgid "device"
msgstr ""
msgstr "appareil"
#: core/models.py:115
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr ""
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:118
#: core/models.py:184
msgid "staff status"
msgstr ""
msgstr "statut du personnel"
#: core/models.py:120
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr ""
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:123
#: core/models.py:189
msgid "active"
msgstr ""
msgstr "actif"
#: core/models.py:126
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:138
#: core/models.py:205
msgid "user"
msgstr ""
msgstr "utilisateur"
#: core/models.py:139
#: core/models.py:206
msgid "users"
msgstr ""
msgstr "utilisateurs"
#: core/models.py:161
msgid "title"
msgstr ""
#: core/models.py:265
msgid "Resource"
msgstr "Ressource"
#: core/models.py:162
msgid "description"
msgstr ""
#: core/models.py:266
msgid "Resources"
msgstr "Ressources"
#: core/models.py:163
msgid "code"
msgstr ""
#: core/models.py:320
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:164
msgid "css"
msgstr ""
#: core/models.py:321
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:166
msgid "public"
#: core/models.py:327
msgid "Resource access with this User and Resource already exists."
msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:168
msgid "Whether this template is public for anyone to use."
#: core/models.py:383
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:384
msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:391
msgid "Room PIN code"
msgstr "Code PIN de la salle"
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:398 core/models.py:552
msgid "Room"
msgstr "Salle"
#: core/models.py:399
msgid "Rooms"
msgstr "Salles"
#: core/models.py:563
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:565
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant "
"est conservé même lorsque le Worker s'arrête, permettant un suivi "
"facile."
#: core/models.py:174
msgid "Template"
msgstr ""
#: core/models.py:573
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:175
msgid "Templates"
msgstr ""
#: core/models.py:574
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:256
msgid "Template/user relation"
msgstr ""
#: core/models.py:580
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:257
msgid "Template/user relations"
msgstr ""
#: core/models.py:581
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:263
msgid "This user is already in this template."
msgstr ""
#: core/models.py:689
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:269
msgid "This team is already in this template."
msgstr ""
#: core/models.py:690
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:275
#: core/models.py:696
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:702
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:708
msgid "Either user or team must be set, not both."
msgstr ""
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: meet/settings.py:134
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email"
msgstr "Logo email"
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call"
msgstr "vous invite à rejoindre un appel vidéo en cours"
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "REJOINDRE L'APPEL"
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
msgid ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
msgstr ""
"Si vous ne pouvez pas cliquer sur le bouton, copiez et collez l'URL dans "
"votre navigateur pour rejoindre l'appel."
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:"
msgstr "Conseils pour une meilleure expérience :"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality"
msgstr "Utilisez Chrome ou Firefox pour une meilleure qualité d'appel"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining"
msgstr "Testez votre microphone et votre caméra avant de rejoindre"
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Assurez-vous d'avoir une connexion Internet stable"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Merci d'utiliser %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr "Votre enregistrement est prêt !"
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
msgstr ""
" Votre enregistrement de \"%(room_name)s\" du %(recording_date)s à "
"%(recording_time)s est maintenant prêt à être téléchargé. "
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid " The recording will expire in %(days)s days. "
msgstr " L'enregistrement expirera dans %(days)s jours. "
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr "Pour conserver cet enregistrement de façon permanente :"
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \"Open\" button below "
msgstr "Cliquez sur le bouton \"Ouvrir\" ci-dessous "
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \"Download\" button in the interface "
msgstr "Utilisez le bouton \"Télécharger\" dans l'interface "
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
msgid "Save the file to your preferred location"
msgstr "Enregistrez le fichier à l'emplacement de votre choix"
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. "
#: meet/settings.py:162
msgid "English"
msgstr ""
msgstr "Anglais"
#: meet/settings.py:135
#: meet/settings.py:163
msgid "French"
msgstr ""
msgstr "Français"
#: meet/settings.py:164
msgid "Dutch"
msgstr "Néerlandais"
#: meet/settings.py:165
msgid "German"
msgstr "Allemand"
Binary file not shown.
@@ -0,0 +1,433 @@
# 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-16 17:04+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/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"
+125 -11
View File
@@ -19,10 +19,10 @@ from django.utils.translation import gettext_lazy as _
import sentry_sdk
from configurations import Configuration, values
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")
def get_release():
@@ -69,10 +69,15 @@ class Base(Configuration):
API_VERSION = "v1.0"
DATA_DIR = values.Value(os.path.join("/", "data"), environ_name="DATA_DIR")
# Security
ALLOWED_HOSTS = values.ListValue([])
SECRET_KEY = values.Value(None)
SILENCED_SYSTEM_CHECKS = values.ListValue([])
ALLOW_UNSECURE_USER_LISTING = values.BooleanValue(
False, environ_name="ALLOW_UNSECURE_USER_LISTING", environ_prefix=None
)
# Application definition
ROOT_URLCONF = "meet.urls"
@@ -156,6 +161,8 @@ class Base(Configuration):
(
("en-us", _("English")),
("fr-fr", _("French")),
("nl-nl", _("Dutch")),
("de-de", _("German")),
)
)
@@ -235,7 +242,17 @@ class Base(Configuration):
# Cache
CACHES = {
"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": values.Value(
"redis://redis:6379/1",
environ_name="REDIS_URL",
environ_prefix=None,
),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
},
}
REST_FRAMEWORK = {
@@ -247,11 +264,26 @@ 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,
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_THROTTLE_RATES": {
"request_entry": values.Value(
default="150/minute",
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
environ_prefix=None,
),
"creation_callback": values.Value(
default="600/minute",
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
environ_prefix=None,
),
},
}
SPECTACULAR_SETTINGS = {
@@ -281,6 +313,12 @@ class Base(Configuration):
"silence_livekit_debug_logs": values.BooleanValue(
False, environ_name="FRONTEND_SILENCE_LIVEKIT_DEBUG", environ_prefix=None
),
"is_silent_login_enabled": values.BooleanValue(
True, environ_name="FRONTEND_IS_SILENT_LOGING_ENABLED", environ_prefix=None
),
"feedback": values.DictValue(
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
),
}
# Mail
@@ -292,9 +330,13 @@ class Base(Configuration):
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
EMAIL_FROM = values.Value("from@example.com")
EMAIL_BRAND_NAME = values.Value(None)
EMAIL_SUPPORT_EMAIL = values.Value(None)
EMAIL_LOGO_IMG = values.Value(None)
EMAIL_DOMAIN = values.Value(None)
EMAIL_APP_BASE_URL = values.Value(None)
AUTH_USER_MODEL = "core.User"
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
# CORS
CORS_ALLOW_CREDENTIALS = True
@@ -321,8 +363,8 @@ class Base(Configuration):
SESSION_COOKIE_AGE = 60 * 60 * 12
# 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
)
@@ -356,6 +398,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
)
@@ -402,6 +447,11 @@ 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 = {
@@ -411,8 +461,11 @@ class Base(Configuration):
),
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
}
RESOURCE_DEFAULT_IS_PUBLIC = values.BooleanValue(
True, environ_name="RESOURCE_DEFAULT_IS_PUBLIC", 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
)
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
@@ -425,9 +478,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",
@@ -450,12 +500,20 @@ class Base(Configuration):
RECORDING_STORAGE_EVENT_TOKEN = values.Value(
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(
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
)
SCREEN_RECORDING_BASE_URL = values.Value(
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
)
# Marketing and communication settings
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
@@ -464,7 +522,7 @@ class Base(Configuration):
environ_prefix=None,
)
MARKETING_SERVICE_CLASS = values.Value(
"core.services.marketing_service.BrevoMarketingService",
"core.services.marketing.BrevoMarketingService",
environ_name="MARKETING_SERVICE_CLASS",
environ_prefix=None,
)
@@ -478,6 +536,59 @@ class Base(Configuration):
converter=lambda x: int(x), # pylint: disable=unnecessary-lambda
)
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
BREVO_API_TIMEOUT = values.PositiveIntegerValue(
1, environ_name="BREVO_API_TIMEOUT", environ_prefix=None
)
# Lobby configurations
LOBBY_KEY_PREFIX = values.Value(
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
)
LOBBY_WAITING_TIMEOUT = values.PositiveIntegerValue(
3, environ_name="LOBBY_WAITING_TIMEOUT", environ_prefix=None
)
LOBBY_DENIED_TIMEOUT = values.PositiveIntegerValue(
5, environ_name="LOBBY_DENIED_TIMEOUT", environ_prefix=None
)
LOBBY_ACCEPTED_TIMEOUT = values.PositiveIntegerValue(
21600, # 6hrs
environ_name="LOBBY_ACCEPTED_TIMEOUT",
environ_prefix=None,
)
LOBBY_NOTIFICATION_TYPE = values.Value(
"participantWaiting",
environ_name="LOBBY_NOTIFICATION_TYPE",
environ_prefix=None,
)
LOBBY_COOKIE_NAME = values.Value(
"lobbyParticipantId",
environ_name="LOBBY_COOKIE_NAME",
environ_prefix=None,
)
# Calendar integrations
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
600, # 10 minutes
environ_name="ROOM_CREATION_CALLBACK_CACHE_TIMEOUT",
environ_prefix=None,
)
# SIP Telephony
ROOM_TELEPHONY_ENABLED = values.BooleanValue(
False,
environ_name="ROOM_TELEPHONY_ENABLED",
environ_prefix=None,
)
ROOM_TELEPHONY_PIN_LENGTH = values.PositiveIntegerValue(
10,
environ_name="ROOM_TELEPHONY_PIN_LENGTH",
environ_prefix=None,
)
ROOM_TELEPHONY_PIN_MAX_RETRIES = values.PositiveIntegerValue(
5,
environ_name="ROOM_TELEPHONY_PIN_MAX_RETRIES",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
@@ -530,6 +641,9 @@ class Base(Configuration):
scope = sentry_sdk.get_global_scope()
scope.set_tag("application", "backend")
# Ignore the logs added by the DockerflowMiddleware
ignore_logger("request.summary")
class Build(Base):
"""Settings used when the application is built.
+21 -22
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.12"
version = "0.1.22"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,40 +25,39 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.36.6",
"boto3==1.37.24",
"Brotli==1.1.0",
"brevo-python==1.1.2",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.6.0",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
"django-lasuite==0.0.7",
"django-parler==2.3",
"redis==5.2.1",
"django-redis==5.4.0",
"django-storages[s3]==1.14.4",
"django-storages[s3]==1.14.5",
"django-timezone-field>=5.1",
"django==5.1.5",
"django==5.1.9",
"djangorestframework==3.15.2",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"factory_boy==3.3.1",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"june-analytics-python==2.3.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.4",
"psycopg[binary]==3.2.6",
"PyJWT==2.10.1",
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.20.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.8.2",
"sentry-sdk==2.24.1",
"whitenoise==6.9.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==0.8.1",
"aiohttp==3.11.11",
"livekit-api==1.0.2",
"aiohttp==3.11.14",
]
[project.urls]
@@ -70,21 +69,21 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.12.1",
"drf-spectacular-sidecar==2025.3.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==8.31.0",
"pyfakefs==5.7.4",
"ipython==9.0.2",
"pyfakefs==5.8.0",
"pylint-django==2.6.1",
"pylint==3.3.3",
"pylint==3.3.6",
"pytest-cov==6.0.0",
"pytest-django==4.9.0",
"pytest==8.3.4",
"pytest-django==4.10.0",
"pytest==8.3.5",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.6",
"ruff==0.9.3",
"types-requests==2.32.0.20241016",
"responses==0.25.7",
"ruff==0.11.2",
"types-requests==2.32.0.20250306",
]
[tool.setuptools]
+8
View File
@@ -29,11 +29,19 @@ FROM meet AS meet-builder
WORKDIR /home/frontend
ARG VITE_API_BASE_URL
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
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}
+1 -1
View File
@@ -3,6 +3,6 @@
"input": ["src/**/*.{ts,tsx}", "!src/styled-system/**/*", "!src/**/*.d.ts"],
"output": "src/locales/$LOCALE/$NAMESPACE.json",
"createOldCatalogs": false,
"locales": ["en", "fr", "de"],
"locales": ["en", "fr", "de", "nl"],
"sort": true
}
+1840 -2554
View File
File diff suppressed because it is too large Load Diff
+30 -30
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.12",
"version": "0.1.22",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -13,48 +13,48 @@
"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.51.1",
"@react-aria/toast": "3.0.0-beta.19",
"@livekit/components-react": "2.9.3",
"@livekit/components-styles": "1.1.5",
"@livekit/track-processors": "0.5.6",
"@pandacss/preset-panda": "0.53.6",
"@react-aria/toast": "3.0.2",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.64.2",
"@tanstack/react-query": "5.76.0",
"crisp-sdk-web": "1.0.25",
"hoofd": "1.7.3",
"i18next": "24.2.2",
"i18next-browser-languagedetector": "8.0.2",
"i18next-parser": "9.1.0",
"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.8.1",
"posthog-js": "1.210.2",
"livekit-client": "2.11.4",
"posthog-js": "1.240.6",
"react": "18.3.1",
"react-aria-components": "1.6.0",
"react-aria-components": "1.8.0",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "4.0.3",
"valtio": "2.1.3",
"wouter": "3.4.0"
"use-sound": "5.0.0",
"valtio": "2.1.5",
"wouter": "3.7.0"
},
"devDependencies": {
"@pandacss/dev": "0.51.1",
"@tanstack/eslint-plugin-query": "5.64.2",
"@tanstack/react-query-devtools": "5.64.2",
"@types/node": "22.10.10",
"@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.21.0",
"@typescript-eslint/parser": "8.21.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.0.1",
"eslint-config-prettier": "10.1.5",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react-hooks": "5.1.0",
"eslint-plugin-react-refresh": "0.4.18",
"postcss": "8.5.1",
"prettier": "3.4.2",
"typescript": "5.7.3",
"vite": "6.0.11",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"postcss": "8.5.3",
"prettier": "3.5.3",
"typescript": "5.8.3",
"vite": "6.2.7",
"vite-tsconfig-paths": "5.1.4"
}
}
+34 -34
View File
@@ -35,40 +35,7 @@ const config: Config = {
exclude: [],
jsxFramework: 'react',
outdir: 'src/styled-system',
globalFontface: {
Marianne: [
{
src: 'url(/fonts/Marianne-Regular-subset.woff2) format("woff2")',
fontWeight: 400,
fontStyle: 'normal',
fontDisplay: 'swap',
},
{
path: 'url(/fonts/Marianne-Regular_Italic-subset.woff2) format("woff2")',
fontWeight: 400,
fontStyle: 'italic',
fontDisplay: 'swap',
},
{
path: 'url(/fonts/Marianne-Medium-subset.woff2) format("woff2")',
fontWeight: 500,
fontStyle: 'normal',
fontDisplay: 'swap',
},
{
path: 'url(/fonts/Marianne-Bold-subset.woff2) format("woff2")',
fontWeight: 700,
fontStyle: 'normal',
fontDisplay: 'swap',
},
{
path: 'url(/fonts/Marianne-ExtraBold-subset.woff2) format("woff2")',
fontWeight: 800,
fontStyle: 'normal',
fontDisplay: 'swap',
},
],
},
globalFontface: {},
theme: {
...pandaPreset.theme,
// media queries are defined in em so that zooming with text-only mode triggers breakpoints
@@ -121,6 +88,32 @@ const config: Config = {
'50%': { opacity: '0.65' },
'100%': { opacity: '1' },
},
rotate: {
'0%': {
transform: 'rotate(0deg)',
},
'100%': {
transform: 'rotate(360deg)',
},
},
prixClipFix: {
'0%': {
clipPath: 'polygon(50% 50%, 0 0, 0 0, 0 0, 0 0, 0 0)',
},
'25%': {
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 0, 100% 0, 100% 0)',
},
'50%': {
clipPath:
'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 100% 100%, 100% 100%)',
},
'75%': {
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 100%)',
},
'100%': {
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
},
},
},
tokens: defineTokens({
/* we take a few things from the panda preset but for now we clear out some stuff.
@@ -197,6 +190,7 @@ const config: Config = {
fonts: {
sans: {
value: [
'Marianne',
'Source Sans',
'Source Sans fallback',
'ui-sans-serif',
@@ -403,6 +397,12 @@ const config: Config = {
lineHeight: '1.25rem',
},
},
xs: {
value: {
fontSize: '0.825rem',
lineHeight: '1.15rem',
},
},
badge: {
value: {
fontSize: '0.75rem',
Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 955 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 986 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

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