Compare commits

...

507 Commits

Author SHA1 Message Date
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
lebaudantoine 4347d87f33 🚸(frontend) improve prejoin UX
- Always enable camera/mic by default (like Google Meet)
- Fix video state transitions and add visual feedback
- Simplify form using React Aria components
- Reduce shadow intensity for better visual balance
2025-01-29 14:14:11 +01:00
lebaudantoine 1b52d76168 ♻️(frontend) extract menu items into individual components
Each menu item is now a standalone component, improving:
- Code organization & reusability
- Maintainability by reducing OptionsMenuItems complexity

This breaks down large components.
2025-01-27 23:02:31 +01:00
lebaudantoine a44b6e8e34 (frontend) add fullscreen mode on desktop
Added an option allowing users to trigger the fullscreen mode while on desktop.
Heavily inspired by the PR #279 from @sylvinus.

Yet, this option allow user to enable/disable the fullscreen mode on the whole
ui, in the next iteration I'll add the same feature but for a given video track.

This is on purpose that the feature is available on desktop only.
The hook code has been partially written by Claude and inspired by @sylvinus
first suggestion.
2025-01-27 23:02:31 +01:00
lebaudantoine 126de638cd 🩹(frontend) fix allowed hosts by Vite using the Tilt stack
When using nip.io for local development DNS mapping (which allows hostname-based
access to localhost), we need to explicitly allow these domains in Vite's server
configuration to prevent host security violations.

This check should be ignored when using https.
2025-01-27 22:57:51 +01:00
lebaudantoine bf28c5cb84 📱(frontend) enhance control bar responsiveness for better UX
Implemented collapsible advanced options to maintain usability on narrow screens
following GMeet's UX pattern. Dialog and popover components were chosen
based on GMeet choices, though this introduces potential accessibility concerns
that should be addressed in future iterations.

Current implementation uses JS for breakpoint handling due to challenges with
Panda CSS's pure CSS approach. This workaround was necessary to resolve a
persistent issue where the popover remained open after window expansion beyond
750px, even after the lateral menu trigger was removed from view.

Technical debt note: Code needs refinement, particularly around breakpoint
management and component architecture. Prioritized shipping over perfection to
meet immediate responsive design needs.
2025-01-27 22:57:24 +01:00
renovate[bot] 4f0e7d2c52 ⬆️(dependencies) update python dependencies 2025-01-27 22:18:32 +01:00
renovate[bot] 3ab5c48296 ⬆️(dependencies) update js dependencies 2025-01-27 18:12:44 +01:00
Nathan Vasse 7f1f573af8 (front) revamp join screen
We want this screen to have a better ux, the join button was invisible on
some small screen sizes, also we want to align the style of this screen with
the ui of the video conference previously made.
2025-01-27 15:31:19 +01:00
Nathan Vasse d48a18b36b (front) make ToggleDevice work outside of a room
This component is needed on the join screen thus out of a room context.
2025-01-27 15:31:19 +01:00
Nathan Vasse eac9158734 (front) add customization to Field
We want to be able to provide some custom attributes to the FieldWrapper
and its label too to manage width, alignment.
2025-01-27 15:31:19 +01:00
Nathan Vasse 994f335266 (front) add customization to SelectToggleDevice
We want to be able to change the variant of this component.
2025-01-27 15:31:19 +01:00
lebaudantoine 30bde2fe66 ♻️(frontend) use pandas utilities
Forgot how panda css is amazing. They provide all kinds of utilities,
and few for accessibility. Amazing!
2025-01-24 13:56:32 +01:00
Nathan Vasse 96c18fc627 (front) add switch camera on mobile
We want to have a more simple control bar and give the user to switch
its back / front camera from the responsive menu.
2025-01-21 15:37:59 +01:00
Nathan Vasse c380ff5e1d (front) option to hide menu on SelectToggleDevice
On mobile we want to be able to hide the chevron and menu.
2025-01-21 15:37:59 +01:00
lebaudantoine 6587863afb 💄(frontend) use dark theme for menus in the videoconference
Inspired by GMeet. Actually, these menus are horrible to work with.
This is clearly some technical debt. I fixed the styles, but not the
code, we should refactor them to make easy to chose between two
variant, a light and a dark one.
2025-01-21 11:53:12 +01:00
lebaudantoine a06daad33d 💄(frontend) fix screen-share control alignment
Oopsie, due to its (too) big icon, the height of this control was 4px bigger
than other buttons. Fixed that minor issue.
2025-01-21 11:53:12 +01:00
renovate[bot] 10c3c67d53 ⬆️(dependencies) update js dependencies 2025-01-21 11:20:29 +01:00
lebaudantoine fa85ef5690 📌(frontend) block upgrade to react v19
Many dependencies are not supported react v19 yet.
Avoid renovate to bump react to its latest major version.
2025-01-21 11:20:29 +01:00
lebaudantoine cc676cf71b ⬆️(frontend) bump react-aria-related dependencies
No major / breaking change in these few releases.
Quickly tested all features, everything seems alright
2025-01-20 19:31:55 +01:00
lebaudantoine 90df8fe5b4 ⬆️(frontend) bump react-query-related dependencies
No major / breaking change in these few releases.
Quickly tested all features, everything seems alright
2025-01-20 19:31:55 +01:00
lebaudantoine 90cfcd31c8 ⬆️(frontend) bump LiveKit-related dependencies
No major / breaking change in these few releases.
Quickly tested all features, everything seems alright
2025-01-20 19:31:55 +01:00
renovate[bot] 0ed3416ce2 ⬆️(dependencies) update python dependencies 2025-01-20 09:35:06 +01:00
lebaudantoine e47401246f ✏️(frontend) fix feedback-s
Feedback is a mass noun.
Spotted by @bvhme
2025-01-18 22:11:35 +01:00
lebaudantoine a518179efb ⬆️(frontend) bump vite to v6.0.7
Updates build configuration to support new major version.
This might introduce some breaking changes.

I've read their migration guide, everything seems okay.
2025-01-17 18:23:48 +01:00
lebaudantoine f17471dae5 🚨(frontend) resolve key prop warning in ParticipantListItem
Key prop was incorrectly passed down as a regular prop to ParticipantListItem
instead of being used at the array mapping level. Key is a special React prop
for list rendering and cannot be accessed as a component prop.
2025-01-16 18:04:20 +01:00
lebaudantoine 36ea44befc 🚨(frontend) update deprecated react-aria Section components
Section was deprecated in Novembre 2024 release, It has been replaced
by specific components for each parent collection component,
e.g. MenuSection.
2025-01-16 18:04:20 +01:00
renovate[bot] 047ef83f17 ⬆️(dependencies) update django to v5.1.5 [SECURITY] 2025-01-16 11:10:51 +01:00
Nathan Vasse e1ece8b5af (front) add posthog log for Firefox blurring
We want to be able to track device settings and versions when a user
enables this feature.
2025-01-16 10:03:18 +01:00
Nathan Vasse 56379f2d6e (front) add blurring support on Firefox
We cannot use track-processor-js for Firefox because it uses various
approaches not compatible with Firefox. Firefox cannot run inference
on GPU, so we must use CPU, we could also not use MediaStreamTrackProcessor nor
MediaStreamTrackGenerator. So I had to make a new implementation from
the ground up, using canvas filters and CPU inferences.
2025-01-16 10:03:18 +01:00
lebaudantoine 341be37fd3 🩹(backend) remove a non-existing field on BooleanValue
I used a LLM to review my code, it introduced an hallucinated field.
Remove it.
2025-01-14 15:52:57 +01:00
lebaudantoine 4cce2e0b72 🐛(backend) fix brevo-related setting name mismatch
Error introduced while rebasing. Fixed it.
2025-01-14 15:52:57 +01:00
lebaudantoine f14536dd93 🔐(helm) configure Brevo marketing tool
Using VaultWarden, added a dev Brevo API key.
In the "dev" stack, enable Brevo to validate new users are signed-up
to the marketing emails.
2025-01-14 15:52:57 +01:00
Jacques ROUSSEL dc0e2eefb3 📝(doc) upgrade README.md
Upgrade the README.md of the helm chart meet
2025-01-14 10:15:27 +01:00
lebaudantoine a61b34400b ♻️(scripts) improve script portability
Improved script portability by switching to `/usr/bin/env bash`, ensuring
better support across environments where `bash` may not be the default shell.
2025-01-14 10:15:27 +01:00
lebaudantoine 3c8e3b9e29 (tilt) add missing certificate authority for Python
Avoid disabling SSL verification in development environment,
simply mount in the right folder, an extra volume, that declares
the certificate authority necessary to validate nip.io domains.
2025-01-14 10:15:27 +01:00
lebaudantoine 89f2ae548e ♻️(helm) simplify generate-readme script
Spotted by @rouja, replace complex file handling with a streamlined
approach.
2025-01-14 10:15:27 +01:00
lebaudantoine 1f23bbf96e (feature) support annotations in deployment to handle external secrets
Updated deployments metadata to include configurable annotations using
`.Values.xxx.dpAnnotations`. This change supports the new approach of
storing secrets in an external Vault, allowing annotations to be added in
staging/prod to trigger refreshes when external secrets change.

Will be configured accordingly in La Suite deploiement repository.
2025-01-14 10:15:27 +01:00
lebaudantoine 9972692dac ♻️(helm) refactor clusterSecretStore and ExternalSecret deployments
Refactored ClusterSecretStore and ExternalSecret deployment to support
VaultWarden custom fields beyond login/password, including multi-line
values via file input. Also made the secret template name configurable
for added flexibility.

ClusterSecretStore are supposed to be cluster-wide objects, it's useless
to precise any namespace.
2025-01-14 10:15:27 +01:00
lebaudantoine 6d08e318a7 🐛(ci) align helm chart release process with people repository
Previous merge of helm chart refactoring was incomplete. Currently,
linting only occurs during chart publication rather than on each PR.
This temporary solution will be improved in a future update.
2025-01-14 10:15:27 +01:00
lebaudantoine 203f1762e7 ♻️(helm) extract make command in a script
Existing make command wasn't working on Mac. Fixed it, plus
refactored it in a proper script, so we can share it among
projects, as for the build kind cluster one.

External secrets are created in a dedicated namespace, to avoid
duplicating them if we spawn several LaSuite applications on the
same local stack.
2025-01-14 10:15:27 +01:00
renovate[bot] 470390fc59 ⬆️(dependencies) update python dependencies 2025-01-13 20:30:38 +01:00
lebaudantoine fe9fe4dd90 🗃️(backend) add missing ordering migrations
While fixing some backend warnings, I forgot to generate
the associated migrations. Fixed my error.
Non-critical migrations about default ordering.
2025-01-13 13:36:23 +01:00
Jacques ROUSSEL 7ad9015a6b 👷(helm) fix typo in the ci
Fix the following issue :
```
The workflow is not valid. .github/workflows/release-helmchart.yml
(Line: 25, Col: 12): Job 'release' depends on unknown job
'helmfile-lint'.
```
2025-01-09 18:14:41 +01:00
lebaudantoine 3e4a7058d2 ♻️(ci) merge helmfile-lint and release chart workflows
Merge both flows related to the Helmfile.
Also, make sure lint job is run before the release one.
2025-01-06 10:19:09 +01:00
Jacques ROUSSEL 25a4e2dfc6 👷(helm) improve local stack
Use the common create_cluster.sh in order to improve cooperation
between teams.

Also, mount extra volume, to avoid setting ssl_verify to false,
while using request module in Python.
2025-01-06 10:19:09 +01:00
Jacques ROUSSEL 14e83ecaff 📝(doc) add a self-host tutorial
Add a documentation to deploy a self-hosted visio instance in a
standalone way (without AI features)
2025-01-06 10:19:09 +01:00
Jacques ROUSSEL 8bd90bd2ff 👷(helm) add a github workflow to publish a chart
We have a dedicated deployment repository, also containing
the Helm chart. To avoid duplicating and maintaining twice
a chart, we decided to publish our Helm chart.

At first we tried the official chart releaser action, however,
this ended in creating a new release on Github for each chart
update, which wasn't acceptable.
2025-01-06 10:19:09 +01:00
Jacques ROUSSEL b51f127872 🔧(helm) offer a standalone dev environment
Offer a standalone dev environment or a dinum specific dev
environment with ProConnect authentication.

Needed to refactor the way secrets are managed in the project,
and also re-organize the Helm chart to make it totally standalone.

Particulary useful for external wanting to run the project.
Work done by @rouja.
2025-01-06 10:19:09 +01:00
lebaudantoine 4c0230d537 (backend) post email to marketing tools while signing up new users
Submitting new users to the marketing service is currently handled
during signup and is performed only once.

This is a pragmatic first implementation, yet imperfect.

In the future, this should be improved by delegating the call to a Celery
worker or an async task.
2024-12-31 15:09:51 +01:00
lebaudantoine 7309df4115 ♻️(backend) add MarketingService protocol and Brevo implementation
Introduced a MarketingService protocol for typed marketing operations,
allowing easy integration of alternative services.

Implemented a Brevo wrapper following the protocol to decouple
the codebase from the sdk. These implementations are simple and pragmatic.
Feel free to refactor them.
2024-12-31 15:09:51 +01:00
lebaudantoine 18b2dfc497 (backend) install brevo client
Brevo (ex-sendinblue) is a common solution used for marketing and
communications.
2024-12-31 15:09:51 +01:00
lebaudantoine 3282da7c56 🚨(backend) fix Django UnorderedObjectListWarning on models
Found this solution googling on Stack Overflow.

Without a default ordering on a model, Django raises a warning, that
pagination may yield inconsistent results.
2024-12-31 15:09:35 +01:00
renovate[bot] 7f8a6e8685 ⬆️(dependencies) update python dependencies 2024-12-30 13:32:37 +01:00
lebaudantoine 4232c0a303 🔖(patch) bump release to 0.1.12
Release a fix while calling Docs backend API.
2024-12-18 22:54:57 +01:00
lebaudantoine a5454e48b7 🔥(frontend) remove redundant yarn.lock file
We decided to stick to npm for dependencies management.
Having both package-lock.json and yarn.lock isn't clear for
developers, simplify the stack.
2024-12-17 18:53:55 +01:00
dependabot[bot] 591706f363 🔒️(frontend) bump nanoid from 3.3.7 to 3.3.8 in /src/frontend
3.3.8 fixed a way to break Nano ID by passing non-integer size.
Raised as a moderate severity issue by Dependabot.
2024-12-17 18:53:55 +01:00
dependabot[bot] 1ea84b6e6c 🔒️(mail) bump cross-spawn to fix a high severity issue
Bump cross-spawn from 7.0.3 to 7.0.6 in /src/mail
Alert triggered by DependaBot.
2024-12-17 16:43:54 +01:00
renovate[bot] c0d101a326 ⬆️(dependencies) update PyJWT to v2.10.1 [SECURITY] 2024-12-17 16:23:05 +01:00
lebaudantoine 64cfcb6c0f 💩(summary) fix temporarily the summary microservice
The new endpoint requires title and content, breaking
the original implementation. This hotfix ensures staging works
immediately while I plan an LLM-based solution
for title generation.
2024-12-17 11:34:46 +01:00
lebaudantoine cdd69b741a 🚑️(mail) bump package version
Oopsie, I forgot to bump mail package version.
Fix it
2024-12-17 11:34:46 +01:00
Jacques ROUSSEL f5a87cc210 🔐(helm) bump secret in staging
We add a typo in secret so we bump secrets to fix it.
2024-12-17 10:19:40 +01:00
lebaudantoine 4d4ddb9ee8 🔖(patch) bump release to 0.1.11
Weekly release, responsiveness is still under construction.
2024-12-17 00:26:29 +01:00
lebaudantoine 70dbf94f7b 🩹(frontend) avoid duplicating Grist form's URL
It led to an error, with two occurrences of the form's URL being
desynchronized. Fix this minor issue, and refactor the constant
in a constant file to be shared across the app.
2024-12-17 00:08:53 +01:00
Jacques ROUSSEL 0ad37ee6de 🔨(tilt) improve local stack
Improve the local tilt file in order to be abble to start all thing
without any dependencies to DINUM environment
2024-12-16 23:41:09 +01:00
Jacques ROUSSEL ed4f7dcf6c 🔐(helm) add production secrets for recording and transcription
Added all missing secrets for the recording feature
and transcription microservice.
2024-12-16 23:08:04 +01:00
lebaudantoine c54773008c 🚨(backend) fix backend linter
I wrongly merged a backend pr, and broke the linter.
2024-12-16 23:08:04 +01:00
lebaudantoine 6848321bcc 🔧(helm) configure transcription microservice in production
Added one replica for the summary and Celery deployments.
Secret will be added in the upcoming commits.
2024-12-16 23:08:04 +01:00
lebaudantoine f161a5cf6a 🔧(helm) configure recording in production
Enabled recording feature in production. MinIO needs to trigger a webhook
when a new recording is saved. Secret will be updated in the upcoming commits.
2024-12-16 23:08:04 +01:00
lebaudantoine 51270571bc 🔧(helm) update transcription microservice in staging
Updated the webhook URL to the definitive version in docs.

Jacques also updated the webhook secret for authentication
against Impress API. Not tested locally.
2024-12-16 23:08:04 +01:00
Nathan Vasse c6fdeaf1e9 📱(front) add responsive menu on mobile
We want to have a specific responsive menu on mobile browsers.
It also implied to refactor a bit the way the settings modals is opened
because it could be opened from this responsive menu, so in order to achive
that a specific context has been created in order to allow its opening
from any sub component of the control bar.
2024-12-13 15:41:54 +01:00
Nathan Vasse f1959cbb3a ♻️(front) add more customization and event handler to toggles
We want to be able to customize the variant which those toggle uses as
well as being able to trigger an event when the toggle is pressed. This
is going to be useful to close the responsive menu after each clic as
react-aria prevent click event propgation.
2024-12-13 15:41:54 +01:00
Nathan Vasse 84cea2f658 (front) add description to Buttons
We want to display full description below buttons for mobile specific
displays.
2024-12-13 15:41:54 +01:00
Nathan Vasse db1fdb9871 (front) add useIsMobile hook
This hook tells whether the current browser is a mobile one or not.
2024-12-13 15:41:54 +01:00
lebaudantoine a8618239d1 🔧(frontend) enable dynacast optimizations
According to the documentation:

Dynacast dynamically pauses video layers that are
not being consumed by any subscribers, significantly
reducing publishing CPU and bandwidth usage.

Dynacast will be enabled if SVC codecs (VP9/AV1) are used.
Multi-codec simulcast requires dynacast

My goal is to reduce CPU and bandwidth usage for clients.

Dynacast is enabled both in OpenTalk and LiveKit demo app!
2024-12-12 11:25:06 +01:00
lebaudantoine 0eb283b75a 🔧(frontend) enable adaptive stream optimizations
Adaptive stream is a key optimization in large room.

Enabled adaptive stream to automatically manage
the quality of subscribed video tracks, optimizing
for bandwidth and CPU usage.

When video elements are visible, it adjusts
the resolution based on the size of the largest visible
element. If no video elements are visible, it
temporarily pauses the track until they are visible again.

Additionally, introduced support for custom pixel density,
which defaults to `2` for high-density screens
(devicePixelRatio ≥ 3) or `1` for standard screens.
Setting it to `screen` allows the pixel density to match
the actual screen's devicePixelRatio, optimizing video
clarity on ultra-high-definition displays.

This ensures a balance between streaming quality
and resource consumption.

This might also significantly increase the bandwidth
consumed by people streaming on high definition screens.
It needs to be battle tested.

OpenTalk uses a adaptiveStream equals true, while LiveKit
demo app uses 'screen' value. I followed OpenTalk choice,
I was scared 'screen' value creates performance issues
for user with high resolution screen in poor network conditions.
2024-12-12 11:25:06 +01:00
lebaudantoine 0104cabc5e 🔧(frontend) default to vp9 for video codec
As a mandatory codec in WebRTC specifications, VP8 serves as
the baseline for compatibility, making it the default choice
in LiveKit client configuration.

There is room for optimization.

Newer codecs like VP9 offer significant efficiency gains
compared to VP8, with a 23-33% improvement in compression
efficiency. This translates to better video quality at
the same bitrate or reduced bandwidth usage for the same quality.

VP9 is supported in Safari starting from version 15.0+,
and Firefox offers partial support. However, Firefox lacks
support for VP9's Scalable Video Coding (SVC).

With SVC, participants can send a single VP9 stream
with multiple spatial or temporal layers. This allows receivers
to dynamically adjust video quality by using lower layers when
resolution or bandwidth needs to be reduced, improving
adaptability in heterogeneous network conditions.

Simulcast, by contrast, sends multiple separate streams
at different resolutions. While widely supported in VP8 and VP9,
it consumes more bandwidth compared to SVC.

The configuration added here is based on the LiveKit demo app,
which defaults to VP9 when supported. OpenTalk’s configuration
also recommends VP9.

If a browser does not support VP9, LiveKit falls back to VP8 or
other codecs as needed. Notably, LiveKit disables VP9 encoding for
Firefox due to longstanding issues, but it can still decode VP9
streams and encode VP8 for outgoing streams. This ensures
compatibility with other participants, even in mixed environments
where some browsers use VP9 and others fallback to VP8.

In theory, participants do not all need to switch to a single codec,
as both LiveKit and browsers intelligently handle codec negotiation
on a per-participant basis. This dynamic adaptation ensures seamless
communication without manual intervention.

A similar challenge with codec compatibility was raised
in Jitsi two years ago, check issue #10657.

Before any release, this needs to be battle tested
with Firefox 115 browsers.
2024-12-12 11:25:06 +01:00
lebaudantoine dbfba564c5 🔧(backend) fix sentry deprecated scope
`sentry_sdk.configure_scope` is deprecated and will
be removed in the next major version.

(commit taken from people by @qbey)
2024-12-10 12:00:41 +01:00
renovate[bot] 4830206bb2 ⬆️(dependencies) update python dependencies 2024-12-09 18:20:12 +01:00
lebaudantoine e4f30f926c 🩹(summary) fix configuration in staging
@rouja forgot to add the right environment variable for the celery
workers. Fixed it.
2024-12-09 18:15:47 +01:00
lebaudantoine fc61f58596 🔧(summary) update LLM and whisper endpoints
@rouja fixed the issue from OutScale load balancer.
He found the right parameter in OutScale documentation,
that was causing a request timeout.
2024-12-09 18:15:47 +01:00
lebaudantoine e0021dbb80 (frontend) introduce a notification settings
I extended Room settings with a new tab, allowing users to toggle
the sound notification they want.

Their choices will be persisted.
2024-12-09 18:14:46 +01:00
lebaudantoine 83914f8307 (frontend) add notification store for managing user preferences
Introduced a global state to handle user preferences related to notifications.
The first use case is sound notifications, allowing users to disable them
based on feedback.

Additionally, the sound volume is now stored globally, making it easy to
configure in the future if needed. I've lowered the volume of
the notifications to make them more discreet.

Preferences are persisted in local storage, ensuring they are retained
between meetings.
2024-12-09 18:14:46 +01:00
lebaudantoine be54709598 (frontend) create STORAGE_KEYS constant
Add STORAGE_KEYS object to centralize localStorage keys,
ensuring no key overlaps by maintaining a single source
of truth for key declarations across the app.

Might be premature, as only the notification store will be persisted.
2024-12-09 18:14:46 +01:00
lebaudantoine 5b76ea492b (frontend) create utils function for valtio
Valtio allows state persistence in local storage, which is
necessary for the notification store. In this case, I'll need
to persist a `proxyMap`—a utility provided by Valtio to create
an observable map.

However, since `proxyMap` isn't natively serializable,
I'll need to implement two custom functions: one for serialization
and another for deserialization (revival).

Regarding the file structure, I've named the file `utils/valtio`,
but this can be discussed further. The purpose of this file is
to centralize common utility functions related to Valtio
for better organization and reuse.
2024-12-09 18:14:46 +01:00
lebaudantoine 46934a84d1 🚚(frontend) rename NotificationType items
I found the item names unclear, so I updated them for better clarity.

I also removed the unnecessary 'lowered' item and added
a TODO comment about handling the message received notification,
which is not yet implemented in the code.
2024-12-09 18:14:46 +01:00
lebaudantoine deb9ab1a1d (frontend) create a new primitive Switch
Inspired by Robin's design, I've styled a React Aria
Switch component using our DSFR theme.

This is an initial draft and isn't yet pixel-perfect compared
to Robin's design. It also hasn't been integrated into
the form inputs yet.
2024-12-09 18:14:46 +01:00
renovate[bot] d0fd16d7d2 ⬆️(dependencies) update django to v5.1.4 [SECURITY] 2024-12-08 22:21:18 +01:00
lebaudantoine 0b8181e5ce ✏️(backend) fix few typos
Fix few typos in the docstring.
2024-12-06 12:46:46 +01:00
lebaudantoine 8b2365d5f9 (backend) add ODC_VERIFY_SSL configuration
Need to disable SSL while authenticating to unsecure
OIDC provider in dev environment.
2024-12-06 12:46:46 +01:00
lebaudantoine 2dd16d1f40 🩹(backend) add missing environ_prefix on OIDC_CREATE_USER
Misleading, to not have all OIDC-related settings, with environ prefix at None.
Caught myself into troubleshooting few minutes.
2024-12-06 12:46:46 +01:00
lebaudantoine fa9484b630 (frontend) introduce a recording toaster
Notify visually users that the room is being recorded.
Draft, it will be enhance the future.
2024-12-04 18:38:26 +01:00
lebaudantoine 1e0e495cd8 ♻️(frontend) refactor pulse_mic into pulse_background
Make the keyframe more generic with an explicit naming.
2024-12-04 18:38:26 +01:00
lebaudantoine c270299179 🩹(frontend) fix submit button in feedbacks page
Since recent changes in the Color palette, the button was totally
invisible… fixed it.
2024-12-04 18:38:26 +01:00
lebaudantoine d9a84e5f0f 🔥(frontend) remove transcription menu item
This menu item was replaced by a side panel.
2024-12-04 18:38:26 +01:00
lebaudantoine 0f64d3cf3a (frontend) introduce a sidepanel for AI assistant
Introduce the content for the AI assistant panel, which describes the
feature, and offers a button to start and stop a recording.
2024-12-04 18:38:26 +01:00
lebaudantoine d1e008a844 ♻️(frontend) introduce useRoomId hook
Manipulating the room's id from the react-query cache should be
encapsulated in a dedicated hook.
2024-12-04 18:38:26 +01:00
lebaudantoine 3be5a5afc6 ♻️(frontend) package checks in useHasTranscriptAccess hook
This hook will be used by the toggle and the sidepanel to make
sure the users have sufficient permissions to access the transcript
features.
2024-12-04 18:38:26 +01:00
lebaudantoine 31468a5e7c ♻️(frontend) introduce a reusable isTranscriptEnabled
Encapsulate the logic, checking if the feature is enabled in the
backend, in a proper and reusable hook.
2024-12-04 18:38:26 +01:00
lebaudantoine b342b9d526 🚩(frontend) enable transcript toggle with a feature flag
Rely on Posthog for a first iteration on the feature flag feature.
This is a pragmatic choice, relying on an external dependency might
not suitable on the longer term, however, compare to the maturity
of our product, this is the best trade off.
2024-12-04 18:38:26 +01:00
lebaudantoine f7e7c3ba22 🚚(frontend) remove wrong 'tsx' extension
The hook only uses typescript code.
2024-12-04 18:38:26 +01:00
lebaudantoine 3902b02691 📈(frontend) check if analytic is enabled
Few frontend features rely on Posthog. Posthog is not activated in
dev environment. Offer a hook that encapsulates this logic, and
return a boolean flag.
2024-12-04 18:38:26 +01:00
lebaudantoine 7ce4390740 (frontend) initialize transcript sidebar panel
Setup base structure and styling for transcript menu sidebar
2024-12-04 18:38:26 +01:00
Jacques ROUSSEL 94d18cffe4 🔐(secrets) bump secretBump secret
In order to use our openai api we need new secret
2024-12-04 10:49:17 +01:00
lebaudantoine ad0c3eea66 🩹(summary) remove temporary audio files saved
I forgot to remove temporary files downloaded from the Minio bucket.
2024-12-04 10:49:17 +01:00
lebaudantoine 459bbf65a8 🔧(summary) use our self-deployed models
Configure dev and staging environment to use our self-deployed
models (Whisper and LLM). Secrets need to be updated btw.

Because of outscale LB bug, which timeout after 60s, we need to
connect directly to the svc.
2024-12-04 10:49:17 +01:00
lebaudantoine 4b4f16e93f 🔖(patch) bump release to 0.1.10
I made a mistake, and forgot to set summary and celery
deployment to 0 replicas.
2024-12-03 02:31:10 +01:00
lebaudantoine 3e3f964ac5 🚑️(helm) set celery and summary replicas to 0
These two services are not yet needed in production. Disable them.
2024-12-03 02:25:52 +01:00
lebaudantoine bcb111defb 🔖(patch) bump release to 0.1.9
Release the new ui, the logo, the footer, etc.
2024-12-03 02:12:36 +01:00
lebaudantoine 91a3aa4033 📱(frontend) fix few layout issues
For screen between xs and xsm, Dinum typography is creating
an overflow on mobile.

minHeight 'fit-content' is not working as expected on firefox.
2024-12-03 01:59:03 +01:00
lebaudantoine 05b74365fa 🐛(frontend) fix broken animation when motion is reduced
Feedback from one of our users.
I forgot animation can be disabled for accessibility purpose.
Fix it by bypassing the animation if reduce motion is activated.
Oopsie, mybad, I learnt something.
2024-12-03 01:05:06 +01:00
lebaudantoine 4eea4d01cd 🍱(frontend) install Marianne font
Declare Marianne font. Not sure of my configuration, lmk if
my configuration feel wrong @nvasse @manuhabitela.

It's a requirement in the public sector.
2024-12-03 01:05:06 +01:00
lebaudantoine 8caa6b7820 (frontend) add a fav icon
As I introduced the logo, let's add the missing favicon.
2024-12-03 01:05:06 +01:00
lebaudantoine e7e7bb0d09 💩(frontend) introduce an official footer
Based on Florian's feedbacks, this is mandatory as the project is
getting attention.

Bad code, needs a refactor.
2024-12-03 01:05:06 +01:00
lebaudantoine 819d3784f7 🍱(frontend) update homepage assets
Robin made a new design. Update the first image with this lit
design!!
2024-12-03 01:05:06 +01:00
lebaudantoine d83ccb3d9d (frontend) add all official logos
Based on Florian and Stéphanie feedbacks, add the 'Marianne' logo,
and a beta tag on our visio's logo.

I've slightly updated the header. Its responsive is broken in certain
situations.
2024-12-03 01:05:06 +01:00
lebaudantoine a083c8cc3a 💄(frontend) align participant placeholder with recent changes
Use a blue-ish background, instead of a gray one.
We'll continue to iterate on this component.
2024-12-03 01:05:06 +01:00
lebaudantoine a299cae8e8 🩹(frontend) prevent an error if crisp is not defined
While hot reloading the stack, I got an error, Crisp not being
defined at the time of the component rendering. Prevent such issue.
I am not 100% sure this commit is useful, WDYT?
2024-12-03 01:05:06 +01:00
lebaudantoine 60b7a35ed1 💄(frontend) replace grayscale button with a new variant
Button was looking disabled. Use an appropriate contrast.
2024-12-03 01:05:06 +01:00
lebaudantoine 39fdffd9a8 🩹(frontend) harmonize toggle colors in controls
nit-picking, match colors from the central controls
and the minor ones on the right.
2024-12-03 01:05:06 +01:00
lebaudantoine e3779d9a6e 💄(frontend) update color palette
Discussed IRL with Robin and Natan. Previous iteration wasn't
appropriated for a visioconference tool.

Try a trade off between nice colors and DSFR. We will probably
continue to iterate on these.
2024-12-03 01:05:06 +01:00
lebaudantoine b74d6e72f9 🔧(helm) configure staging to notify summary service
Add the relevant configurations, to be tested when deployed.
2024-12-02 14:33:54 +01:00
lebaudantoine 9be826fd14 ⬆️(summary) upgrade openai client to 1.55.3
Got an unexpected error while instantiating the openai client
since I installed sentry deps. I've upgraded openai version
without digging much, and it worked again.
2024-12-02 14:33:54 +01:00
lebaudantoine 4fe01ae2bf 💩(backend) notify the summary service when a new recording is available
Draft a piece of code to try the feature in staging. I'll consolidate this
implementation ASAP, as soon we have a first implementation functional.

What's missing?
- when owners are multiple
- retry when the backend cannot reach the summary service
- factorize the key oneliner, duplicated from the egress service
- optimize SQL query
- unit tests
2024-12-02 14:33:54 +01:00
lebaudantoine 300756b323 🔧(backend) configure sentry in staging and production
Share the same project as the microservice one. Will see in the future
if we need a separate sentry account for the backend. Good enough
at this project stage.
2024-12-02 14:33:54 +01:00
lebaudantoine 0af79912c5 📝(helm) generate documentation for summary microservices
Run the generate-readme script to add the documentation related to
the newly introduced microservice.
2024-12-02 14:33:54 +01:00
lebaudantoine 446270c153 🩹(summary) fix Helm Readme
Oopsie, I forgot to update the Helm readme.
Fixed it.
2024-12-02 14:33:54 +01:00
lebaudantoine e6377ce182 (summary) enable sentry monitoring
Necessary to monitor how the micro service acts.
Will tweak sentry configurations later on.
2024-12-02 14:33:54 +01:00
lebaudantoine a6b6cb7787 🔧(summary) make celery max retries an env configurations
Minor tweak ! It would be useful when we'll scale or microservice.
2024-12-02 14:33:54 +01:00
lebaudantoine 50146e95f4 🩹(summary) use the right s3 env variable
I use 'url' instead of the 'endpoint' as desired.
Leading the Minio client to fail connecting to the bucket.
Fixed it!
2024-12-02 14:33:54 +01:00
lebaudantoine 35741a1bc1 ♻️(summary) reorganize API code
I totally reorganized the API code, to gain in clarity.
2024-12-02 14:33:54 +01:00
lebaudantoine b12b14b277 🔒️(summary) run image as a non-root user
Based on @rouja's comment, this is a bad practice, running a docker
image as root. Added a User instruction with a default non-root user.
2024-12-02 14:33:54 +01:00
lebaudantoine bb30f25953 ⚰️(frontend) remove dead code
Introducing Crisp, we removed the menu item for support. I forgot
to remove its i18n content. My bad
2024-11-29 21:52:28 +01:00
lebaudantoine ebbdebf444 📱(front) improve control bar responsive
We want the bottom bar to stay at the center of the screen.

Co-authored-by: Nathan Vasse <nathan.vasse@gmail.com>
2024-11-29 21:52:28 +01:00
lebaudantoine fde10e7556 🩹(helm) add missing secret for summary service
Oopsie. I forced pushed and dropped this commit.
2024-11-29 19:00:48 +01:00
lebaudantoine 0dbb256e9f 👷(summary) build and push summary image
Copy pasted from the job in charge of building and pushing
the backend image.
2024-11-29 18:39:40 +01:00
lebaudantoine 33c8a3a9e4 💩(summary) declare microservice in staging
Add the minimal extra templates and env values to deploy the micro
service.
2024-11-29 18:39:40 +01:00
lebaudantoine dd0cb61ebc 💩(summary) kubernitize the micro service
Add the micro service to the helm chart.
2024-11-29 18:39:40 +01:00
lebaudantoine e92f084afb 💩(summary) vendor a first draft
This is written in a rush, boostrap the real logic with celery
worker to process meeting recording.

Lack of unit tests is critical, I am not proud of a it. I am
totally in a hurry for the demo. Not sure we will actually keep
this microservice.
2024-11-29 18:39:40 +01:00
lebaudantoine 4a53005ae3 💩(summary) compose the summary stack
Enhance the DX offering a docker compose with reloading.
2024-11-29 18:39:40 +01:00
lebaudantoine d7d82130d9 💩(summary) dockerize FastAPI app
Add a minimal docker file to run the FastAPI server.
2024-11-29 18:39:40 +01:00
lebaudantoine 16bee00eb4 💩(summary) init a FastAPI app
Bootsrap a generic FastAPI app to kick-start the microservice.
2024-11-29 18:39:40 +01:00
Jacques ROUSSEL 0f70b544ed 🔐(secrets) bump secret
Bump secret in order to enable recording on staging
2024-11-29 17:05:29 +01:00
lebaudantoine 2903442a67 🎨(frontend) enhance few naming
I committed in a rush the transcript item, fix my dirty code.
Enhance few naming.
2024-11-29 17:05:29 +01:00
lebaudantoine be884a8ca1 🚩(frontend) display transcript item only for admin/owner
Only room admin/owner should be abble to start a transcript.
2024-11-29 17:05:29 +01:00
lebaudantoine fe70165076 🚩(frontend) display transcript items only when back is enabled
Avoid displaying the feature in the client if it's disabled
in the backend.
2024-11-29 17:05:29 +01:00
lebaudantoine 290ba4274a ⚰️(frontend) remove dead code
With chat refactoring, few lines of code was dead.
Fix it!
2024-11-29 17:05:29 +01:00
lebaudantoine d2f239e6a2 🚧(frontend) add a temporary items to interact with transcription
This is totally temporary, only to test transcription recording.
In the upcoming commits, a proper side panel with all activities
will be created.
2024-11-29 17:05:29 +01:00
lebaudantoine c61fc40671 (frontend) add API to start/stop recording
API calls to interact with backend, minimal call. Might be improved.
2024-11-29 17:05:29 +01:00
lebaudantoine 10705ca3ac 🩹(backend) fix duplication due to a bad rebase
While rebasing, I made few mistake with a settings wrongly renamed.
Oopsie fix this huge mistake.

Plus, a line was duplicated in Event authentication.
2024-11-29 17:05:29 +01:00
lebaudantoine 6b03ebb393 🔧(backend) enable recording and storage hook
Update values for dev and staging environment to enable
recording-related endpoints. A new secret need to be created.

Production values will be added in an upcoming commit.
2024-11-29 17:05:29 +01:00
Jacques ROUSSEL 0627510f10 ♻️(tilt) simplify the local stack
This commit solves few issues:
- sharing the relevant certificates with minio so when triggering the webhook
notification, the minio pod can verify our backend domain certificates.
- making sure everything spawn in the right namespace (LiveKit and the Egress)
without relying on a dirty fix in the make start-tilt.

all these fixes were made by @rouja, I don't fully understand them yet.
He simplified the stack, removing two Kind nodes to make it lightweight.

thx @rouja.
2024-11-29 15:54:24 +01:00
NathanVss f9233c8fb3 Merge pull request #245 from numerique-gouv/feat/video-conference-ui
Enhance Conference UI
2024-11-28 11:49:00 +01:00
Nathan Vasse 0b672ef8da 🚨(front) fix ui enhancement formats
Some formatting were forgotten, here is the fix.
2024-11-28 11:41:38 +01:00
Nathan Vasse 0b0efa5bab 💄(front) improve menu and select ui
We want to introduce two variant for this lists, so it was needed
to create a slot recipe.
2024-11-28 11:30:37 +01:00
Nathan Vasse 0958206057 💄(front) enhance ui first shot
Based on recent sketches, we decided to implement the new ui. This is
a first shot, more will be coming next.
2024-11-28 11:30:32 +01:00
renovate[bot] 4e13ace815 ⬆️(dependencies) update js dependencies 2024-11-25 11:44:30 +01:00
renovate[bot] 30ee0fdce3 ⬆️(dependencies) update python dependencies 2024-11-25 11:35:11 +01:00
renovate[bot] 17453a6cee ⬆️(dependencies) update aiohttp to v3.10.11 [SECURITY] 2024-11-25 10:42:02 +01:00
lebaudantoine c1e8ebc129 💬(frontend) rename Agent Connect mentions
Feedback from C Chausse. Outdated copy writting.
2024-11-25 10:36:01 +01:00
lebaudantoine a1dbf02f9c 🩹(frontend) avoid franglish copywritting
My bad, few english terms were mixed in some french ones.
Fixed it.
2024-11-25 10:35:38 +01:00
lebaudantoine c4206b4b24 🔖(patch) bump release to 0.1.8 2024-11-19 00:24:29 +01:00
lebaudantoine 732df0768c 💄(frontend) adjust home based on Sam feedbacks
Based on Samuel's feedbacks, adjust few details.
2024-11-18 13:31:26 +01:00
lebaudantoine 8a96b6939a 🐛(helm) rename bucket
Wrong copy paste. My bad, bucket's name wasn't matching our project.
2024-11-18 10:04:21 +01:00
lebaudantoine e78b8c69fe 🩹(frontend) add missing key
Fix missing warning, key props was missing on the item of the
for loop.
2024-11-18 01:22:54 +01:00
lebaudantoine 6e49ab3230 💄(frontend) narrow home layout
Avoid having too much white space around the home content.
2024-11-18 01:22:54 +01:00
lebaudantoine 200e2d3c2f 💩(frontend) introduce multistep form
Add an open feedback form step, to allow people giving us some
feedbacks on what's wrong with our product.
2024-11-18 01:22:54 +01:00
lebaudantoine 4ffef3f94a 🚸(frontend) add hover animation on rating Button
Nitpicking, enhance small interactivity with form.
2024-11-18 01:22:54 +01:00
lebaudantoine 8e62f2ecd3 🩹(frontend) fix TextArea export
Wrongly exported the TextArea primitive, mybad.
Fixed !
2024-11-18 01:22:54 +01:00
lebaudantoine d024fb1b95 (frontend) capture from with PostHog survey
Initialize logic related to PostHog, use an API survey to avoid
having element branded with PostHog logo.
2024-11-18 01:22:54 +01:00
lebaudantoine 7e49f0f661 ⬆️(frontend) update PostHog sdk
Required by PostHog app, to benefits from latest survey features.
2024-11-18 01:22:54 +01:00
lebaudantoine fb8c0fd1b5 💄(frontend) refactor feedbacks screen
Enhance initial screen. Allow user returning to the meeting.
Also, add basic and non-functional rating component to mock,
what the form would be.
2024-11-18 01:22:54 +01:00
lebaudantoine b07e4c58b4 💄(frontend) remove slider feature on small screens
Fit the slider content into small screen, by disabling all features
related to the slider. Good enough for a first draft.
2024-11-17 16:48:54 +01:00
lebaudantoine e6bbf2d3c8 💄(frontend) enhance header responsiveness
Quick fix, not optimal. Removed a useless breaking point IMO.
I won't spend too much time on the Header component, I'll
introduce new features needing a heavy layout update.
2024-11-17 16:48:54 +01:00
lebaudantoine 28899a2aaf 🚨(frontend) fix bundler warning on build
Typo while styling the homepage. Fixed it.
Minor issue.
2024-11-17 16:48:54 +01:00
lebaudantoine a5bc66a921 💄(frontend) adjust title's responsiveness
Needed for small and phone screen.
Not a huge fan of having breaking point to manage, but
made an exception for the landing page content.
2024-11-17 16:48:54 +01:00
lebaudantoine adb09410da 💄(frontend) introduce a new breaking point
Needed a trade off between xs and sm breaking points.
Introduce xsm, I am not very satisfied with this naming.
We might refactor xs in xss. Not urgent.
2024-11-17 16:48:54 +01:00
lebaudantoine 061f12e7e2 💄(frontend) update feedback button to banner with context
Refactored the feedback alert button into a more discreet and polished banner.
The banner provides additional context about Visio being under construction.
Also updated the link to the feedback form.
2024-11-17 15:29:26 +01:00
lebaudantoine 24e819a533 🩹(frontend) use browser language if no localStorage for detection
Previously, language detection was failing to read browser settings correctly.
Added explicit detection order to ensure localStorage preferences are checked
before falling back to browser language.
2024-11-17 14:56:32 +01:00
lebaudantoine 5c48ac100a (frontend) allow user register to beta feature
I've created a beta form, allowing interested visitors to
register to be a beta user.

This form is yet created with Grist, could be enhance. I'll
investigate using PostHog.
2024-11-17 01:08:13 +01:00
lebaudantoine 4434f0265d 💫(frontend) add fadein animation on home
Elements' apparition felt quite harsh.
Add an animation to fade in content.
Used a pre-defined animation in panda configs.
2024-11-17 01:08:13 +01:00
lebaudantoine 0522696842 💫(frontend) add animation on interaction with slider
Add a minimal animation to enhance micro-interaction with our app.
I've tried being minimal, please feel free to enhance or tune this
animation.
2024-11-17 01:08:13 +01:00
lebaudantoine 8516782d79 (frontend) add slide feature in the IntroSlider
Avoided installing a dependency for such small piece of code.
I've implemented a naive component, which allow users explore
the slide presenting key feature of our app.

User experience should be ok. However, I might need to optimize
image format and loading strategy. First 'raw' iteration, gonna
optimize it in the future.
2024-11-17 01:08:13 +01:00
lebaudantoine b06880be15 💄(frontend) enhance home page
I've totally rework the homepage, heavily inspired by GMeet.
Goal: make it more pro, and polished.
2024-11-17 01:08:13 +01:00
lebaudantoine 5de9cec688 (frontend) introduce learn more link component
Component providing a 'learn more' feature. Inspired by GMeet.
Naive component.
2024-11-17 01:08:13 +01:00
lebaudantoine 7dfd86873c 🚸(frontend) update A component hover animation
I found original to hide text decoration on hover. It felt wrong
to me, I proposed a new animation. Please feel free to rework it
or enhance it in the future.
2024-11-17 01:08:13 +01:00
lebaudantoine 7cee409d22 🚧(frontend) add intro slider component with feature slides
Created a component for an intro slider to showcase app features.
Each slide will include a minimal illustration and concise descriptions.

Work In Progress, Slide logic will be added in upcomming commits.
2024-11-17 01:08:13 +01:00
lebaudantoine 7ddc448c88 💄(frontend) remove bottom border on header
The bottom border on the header was unnecessary
and removed for a cleaner design.
2024-11-17 01:08:13 +01:00
lebaudantoine 6fcb69bd3c 🩹(frontend) fix horizontal scroll issue
Oooopsie while merging chat, I forgot to test horizontal scrolling.
It ended up creating a scroll issue. Fixed!

This fix might create a new issue for mobile users, I'll refactor
their layout ASAP.
2024-11-16 00:29:58 +01:00
lebaudantoine 6aed4cb751 🚸(frontend) auto-populate prejoin screen with user's default full name
Streamline user joining a room by pre-fillig name field from
their ProConnect data. Reduces friction and typos in the join flow.

This feature will be mostly used by new users. Recurrent users have
their previous choices persisted in local storage.
2024-11-16 00:29:58 +01:00
lebaudantoine f433f59d3f ♻️(frontend) prioritize full name display before email
Why? user friendly, and more human-readable identifier. Should
be enhance later on, to display both info together.
2024-11-16 00:29:58 +01:00
lebaudantoine fba9910705 (frontend) declare full and short name in APIUser type
API now serialize these two data. Save them when requestion /me.
2024-11-16 00:29:58 +01:00
lebaudantoine dcba3330f7 🛂(backend) request given and usual name scopes from ProConnect
Request the necessary scopes from ProConnect service.
Update configurations in every environments.

Note: ask given_name and usual_name scopes to get users' info.

(these scopes should be granted by default by ProConnect when
requesting a client id client secret)
2024-11-16 00:29:58 +01:00
lebaudantoine 82bb5f0f8b (backend) persist OIDC first name and last name while authenticating
Inspired by @sampaccoud's eee2003 commit on impress, adapt the code to be more
Pythonic. Add basic test coverage for user name synchronization on login. User
name fields now update automatically at each login when new data is available.

Note: current logic doesn't handle the case where a user with existing names
logs in with missing first/last names - should we clear the names then?

Removing a field that was present in the initial form is not a valid update
operation.
2024-11-15 23:38:31 +01:00
lebaudantoine 0fd06ef6c0 ♻️(backend) isolate authentication tests when dealing only with email
Refactor a test to narrow down its scope to email-related updates.
2024-11-15 23:38:31 +01:00
lebaudantoine bd4dec6f27 (backend) serialize user name-related fields
Needed in the frontend. Updated existing tests accordingly.
Names are not yet saved while logging the user, it will be
added in the upcomming commits.
2024-11-15 23:38:31 +01:00
lebaudantoine a987830fb3 🗑️(backend) remove useless methods on User
While removing analytics code in 15e922f, I forgot to remove
related code in the User model, used nowwhere else. Oopsie.
Fixed it!
2024-11-15 23:38:31 +01:00
lebaudantoine 7f09636791 (backend) add full_name short_name on User model
Following @sampaccoud's work on impress, add new fields to handle
user names in our application.

@sampaccoud preferred having a full and short names instead of
a basic first and last ones, to follow common good practices, and
avoid having frontend formating names (from my understanding).

Please see commit eee20033 on Impress.
2024-11-15 23:38:31 +01:00
lebaudantoine 3460ec8808 ✏️(backend) fix minor typo
login is a noun, the verb needs a whitespace.
2024-11-15 23:38:31 +01:00
lebaudantoine 86cda75b39 🐛(frontend) tune connection parameters for low bandwidth clients
DINUM users face connection issues in bandwidth-constrained environments.
They cannot reach their room, the peer connection timeouts while negotiating
TURN/TLS handshake (from our current understanding).

I am not sure this issue is linked to those parameters, at least try something.
2024-11-15 23:38:31 +01:00
lebaudantoine 7afa165013 (backend) offer an endpoint to save recording
I've protected this endpoint with a feature flag, and an authentication
class, as it will be exposed on the public internet.

I've tried to keep the viewset logic as minimal as possible, I've
to ship smth and will continue iterating on this piece of code.

At some point, abstracting webhook endpoint and authentication class
might be beneficial for the project. YAGNI as of today.
2024-11-13 19:36:17 +01:00
lebaudantoine e11bdc6d28 ♻️(backend) update is_savable method
A recording is savable only if it's active or stopped. In other
status (error, already saved, etc.) it won't be possible. I might
iterate on this piece of code. Let's ship it.
2024-11-13 19:36:17 +01:00
lebaudantoine 8309545ec6 (backend) add minio event parser
When a new file is uploaded to a Minio Bucket, a webhook can be
configured to notify third parties about the event. Basically,
it's a POST call with a payload providing informations on the
event that just happened.

When a recording worker will stop, it will upload its data to a Minio
bucket, which will trigger the webhook.

Try to introduce the minimalest code to parse these events, discard
them whener it's relevant, and extract the recording ID, thus we
know which recording was successfully saved to the Minio bucket.

In the longer runner, it will trigger a callback.
2024-11-13 19:36:17 +01:00
lebaudantoine 840033fcbc 🔧(frontend) get configs related to the recording feature
Get optional config related to the recording feature. RecordingMode will
be refactored as soon I start working on the recording feature.
2024-11-13 19:23:34 +01:00
lebaudantoine 28ca2d6c37 (backend) expose recording configurations to the client
Inform frontend code, if recording a room is enabled, and which recordings modes
are available. Frontend should adapt its behavior based on this data.
2024-11-13 19:23:34 +01:00
lebaudantoine 4e77458116 🚨(backend) fix Django deprecation warning in RecordingFactory
Addressed a `DeprecationWarning` in `RecordingFactory` related to the
`_after_postgeneration` method, which will stop saving the instance after
postgeneration hooks in the next major release. To resolve this,
`skip_postgeneration_save=True` was added to `RecordingFactory.Meta` to
avoid extraneous save calls. Alternatively, if instance saving is needed,
the save call can be moved to postgeneration hooks or by overriding
`after_postgeneration`.
2024-11-13 18:34:16 +01:00
lebaudantoine d4532eeb64 ♻️(backend) remove unnecessary manipulation of the room name
Avoided unnecessary manipulation of the room name to prevent issues when
starting an egress worker. Previously, hyphens were stripped from the room
name, likely inherited from the legacy setup with Jitsi in Magnify, though
the purpose of this change is unclear and might be an undesired legacy
feature.

To ensure accurate room matching during egress worker requests, this update
removes any manipulation of the room name. This approach minimizes the risk
of errors when initiating recordings and maintains the integrity of the
original room name throughout the process.
2024-11-13 18:34:16 +01:00
lebaudantoine b84628ee95 (backend) add two new endpoints to start and stop a recording
The LiveKit egress worker interactions are proxied through the backend for
security reasons. Allowing clients to directly use tokens with sufficient
grants to start recordings could lead to misuse, enabling users to spam the
egress worker API and potentially initiate a DDOS attack on the egress
service. To prevent this, only users with room-specific privileges can
initiate recordings.

We make sure only one recording at the time can be made on a room.

The requested recording mode is stored so it can be referenced later when
the recording is saved, triggering a callback action as needed.

A feature flag was also introduced for this capability; while this is a simple
approach, a more robust system for managing feature flags could be valuable
long-term. For now, KISS (Keep It Simple, Stupid) applies.

The viewset endpoints were designed to be as straightforward as possible—
let me know if anything can be improved.
2024-11-13 18:34:16 +01:00
lebaudantoine f6f1222f47 (backend) introduce general recording worker concepts
Introducing a new worker service architecture. Sorry for the long commit.
This design adheres to several key principles, primarily the  Single
Responsibility Principle. Dependency Injection and composition are
prioritized over inheritance, enhancing modularity and maintainability.

Interactions between the backend and external workers are encapsulated in
classes implementing a common `WorkerService` interface. I chose Protocol
over an abstract class for agility, aligning closely with static typing
without requiring inheritance. Each `WorkerService` implementation can
independently manage recordings according to its specific requirements.
This flexibility ensures that adding a new worker service, such as for
LiveKit, can be done without any change to existing components.

Configuration management is centralized in a single `WorkerServiceConfig`
class, which loads and provides all settings for different worker
implementations, keeping configurations organized and extensible. The worker
service class itself handles accessing relevant configurations as needed,
simplifying the configuration process.

A basic dictionary in Django settings acts as a factory, responsible for
instantiating the correct worker service based on the client's request mode.
This approach aligns with Django development conventions, emphasizing
simplicity. While a full factory class with a builder pattern could provide
future flexibility, YAGNI (You Aren't Gonna Need It) suggests deferring
such complexity until it’s necessary.

At the core of this design is the worker mediator, which decouples worker
service implementations from the Django ORM and manages database state
according to worker state. The mediator is purposefully limited in
responsibility, handling only what’s essential. It doesn’t instantiate
worker services directly; instead, services are injected via composition,
allowing the mediator to manage any object conforming to the `WorkerService`
interface. This setup preserves flexibility and maintains a clear
separation of responsibilities. The factory create worker services,
the mediator runs it.

(sorry for this long commit)
2024-11-13 18:34:16 +01:00
lebaudantoine 7278613b20 🗃️(backend) merge duplicate user accounts on email
Write the proper ORM code to sanitize the rows in db and avoid
existing users lose access to our app.

Existing duplicate user accounts are merged, and resource accesses
are transferred.
2024-11-12 16:56:58 +01:00
lebaudantoine d370a4db10 🐛(backend) harden email matching against ambiguous cases
Handle case-sensitivity and whitespace in email lookups. Detect and block
multiple matching accounts as security precaution.
2024-11-12 16:56:58 +01:00
lebaudantoine c1bc379744 🧪(backend) add test for email matching
Add test cases for email-based user matching fallback logic:
- String comparison edge cases
- Multiple users with matching email addresses
- Invalid email format handling

Fix will follow in subsequent commit.
2024-11-12 16:56:58 +01:00
lebaudantoine 5ef6359b7c 🛂(backend) fallback to email matching when OIDC sub is not found
When OIDC providers return random values in the "sub" field instead of stable
identifiers, implement email-based user matching as fallback.

Note: Current implementation needs improvement. Tests forthcoming.

Original: @sampaccoud (ff7914f) on Impress
2024-11-12 16:56:58 +01:00
lebaudantoine 04d76acce5 (backend) handle inactive user
Handle case where user is inactive.
Previously this edge case would cause unexpected behavior.

Related to previous commit that added the test coverage.
2024-11-12 16:56:58 +01:00
lebaudantoine eeb71f90bc 🧪(backend) add test for inactive user
Add failing test for case when user is inactive.
This case was highlighted by @qbey and was previously untested.
Fix will follow in subsequent commit.
2024-11-12 16:56:58 +01:00
lebaudantoine 5db4b09106 ♻️(backend) remove redundant create_user method
Remove redundant wrapper method that duplicates SUB validation logic.
Already validated in parent class, following the pattern established by
@sampaccoud in People and Impress modules.
2024-11-12 16:56:58 +01:00
lebaudantoine 11cd85d4eb (backend) handle empty subscription string
Handle case where sub value is an empty string instead of None.
Previously this edge case would cause unexpected behavior.

Related to previous commit that added the test coverage.
2024-11-12 16:56:58 +01:00
lebaudantoine ccbeeba68f 🧪(backend) add test for empty sub string
Add failing test for corner case when sub value is an empty string.
This edge case was discovered by @sampaccoud and was previously untested.
Fix will follow in subsequent commit.
2024-11-12 16:56:58 +01:00
lebaudantoine f3ea0fca71 🐛(frontend) fix missing user label in prejoin screen
Add missing hint to guide user entering their name while
joining a room.

I introduced this bug while merging @manuhabitela works on
the newly prejoin screen.
2024-11-11 21:20:08 +01:00
lebaudantoine cb4acc32e5 🗑️(backend) remove useless analytics settings
Remove useless analytics key settings as we removed tracking
data from the backend code.
2024-11-08 10:45:58 +01:00
lebaudantoine 60f5c8486b 🩹(backend) add missing Django settings for cold storage access
After configuring the cold storage infrastructure, I forgot to add Django
settings needed to access the configuration.
2024-11-08 10:45:58 +01:00
lebaudantoine ed3a26d449 (backend) enable Django Admin on Recording
Manage Recording in the Django Admin. As of today, I have not enforced
a strict policy to avoid edit on recording rows or even creating new
data point directly from the admin. Will do in the future.
2024-11-08 10:36:38 +01:00
lebaudantoine cb4c058c5d (backend) add minimal Recording viewset for room recordings
Implements routes to manage recordings within rooms, following the patterns
established in Impress. The viewset exposes targeted endpoints rather than
full CRUD operations, with recordings being created (soon) through
room-specific routes (e.g. room/123/start-recording).

The implementation draws from @sampaccoud's initial work and advices.

Review focus areas:
- Permission implementation choices
- Serializer design and structure

Credit: Initial work by @sampaccoud
2024-11-08 10:36:31 +01:00
lebaudantoine c504b5262b (backend) introduce Recording model with independent access control
The Recording model is introduced to track recording lifecycle within rooms,
while maintaining strict separation of access controls between rooms and
recordings.

Recordings follow the BaseAccess pattern (similar to Documents in Impress),
providing independent access control from room permissions. This ensures that
joining a room doesn't automatically grant access to previous recordings,
allowing for more flexible permission management.

The implementation was driven by TDD, particularly for the get_abilities
function, resulting in reduced nesting levels and improved readability.

The Recording model is deliberately kept minimal to serve as a foundation for
upcoming AI features while maintaining flexibility for future extensions.

I have avoided LiveKit-specific terminology for better abstraction.

Note: Room access control remains unchanged in this commit, pending future
refactor to use BaseAccess pattern (discussed IRL with @sampaccoud).
2024-11-07 18:06:26 +01:00
lebaudantoine 3b3816b333 ♻️(backend) rename room-specific setting in Resource class
Renames a Django setting in the Resource base class to use resource-agnostic
terminology. While rooms are currently the only resource type, keeping base
class naming generic improves clarity.
2024-11-07 18:06:26 +01:00
lebaudantoine 15e922f9df 🔥(backend) vendor analytics code
Analytics code is now useless, we mostly use
frontend tracking.
2024-11-04 17:49:15 +01:00
lebaudantoine b69f777e5a 🔧(helm) expose MinIO console via ingress
Add ingress configuration for MinIO web console access. Include Nginx proxy
buffer annotations to support large file uploads through web interface.
2024-11-04 15:24:58 +01:00
lebaudantoine 67d004fbda ♻️(backend) refactor try/except when getting a room
Be more Pythonist simplifying try except while tracking when
user is getting a room.
2024-11-04 15:21:24 +01:00
lebaudantoine 4449b578bd 🐛(config) rename hardcoded docker-compose filename
Previously, the Docker Compose filename was hardcoded in _config.sh when used
through utility scripts. In recent commits, I've renamed the filename without
updating this configuration.

Oopsie, running make commands was fine, but running bin scripts
requiring compose failed.
2024-11-04 14:17:31 +01:00
lebaudantoine 1f0d2ce335 🚨(backend) fix Pylint warning
Python module models.py raises too-many-ancestors warning, but linter
still pass, code is rated 10/10.

Inspired from @sampaccoud's commit #eee20032 on impress.
2024-11-04 14:17:31 +01:00
lebaudantoine 0d1d0662ee ♻️(compose) rename docker-compose to compose
Docker Compose now by default search for a 'compose' file.
(according to @jonathanperret).
2024-11-03 22:31:30 +01:00
lebaudantoine 0056b4cc29 🔥(compose) remove docker compose version
The version is now automatically guessed by Docker Compose. This
commit will fix the warning raised about the uselessness of this
setting.

from @sampaccoud
2024-11-03 22:31:30 +01:00
lebaudantoine d7892cde9f (backend) move freezegun to dev dependencies
Freezegun is for testing and should not be installed in the
production image. (from @sampaccoud)
2024-11-03 22:31:30 +01:00
lebaudantoine 372db49e94 🎨(docker) convert to uppercase 'as' keyword
Match 'FROM' casing, to remove a warning.
2024-11-03 22:31:30 +01:00
lebaudantoine f7ed70dc9c (livekit) add Livekit Egress
Egress is already deployed in staging. But, while
working locally on feature relying on Egress, it's not
suitable to test your development or iterate.

Especially I'll need to test the connection between the Egress
and the minio bucket in my next PR.

We faced quite a few issue while starting the whole stack.
Egress didn't want to start. Its connection with the livekit server
while the egress participant was joining the room was not successful.

The Turn part of the livekit server helm chart was activated. We needed
to update few values to in the helm configuration to enabled this turn.

Updated CoreDNS to expose Egress pod. Egress tries connecting to MinIO at
127.0.0.1, where no instance exists. Using minio.127.0.0.1.nip.io resolves
to 127.0.0.1, causing Egress to connect to itself for uploads. The CoreDNS
rewrite directs this to the Ingress IP, correctly routing to MinIO.
2024-10-28 10:26:51 +01:00
lebaudantoine 427b23ca80 (backend) add S3 objects
Inspired by Impress and @sampaccoud's work.

We use Indie Hoster’s Kubernetes objects in staging and production.
In the "dev" environment, we install the `bitnami/minio` chart to mimic
Indie Hoster’s MinIO setup.

To access the MinIO admin interface in dev, use port forwarding;
the interface runs on port 9001.
2024-10-28 10:26:51 +01:00
lebaudantoine 99a1efc538 🐛(helm) deploy livekit in 'meet' namespace
LiveKit Helm chart doesn't support namespace parameterization
like MinIO templates. Egress and server deploy to default namespace,
but frontend and backend need all components in the same namespace.

Workaround: force kubectl context to 'meet' before starting Tilt.
Future improvement needed in chart.

Issue #105 have been opened in livekit-helm repo.
2024-10-28 10:26:51 +01:00
lebaudantoine ec22abf82b 💄(frontend) adjust few minor details
Temporary styles, will be fixed when redesigning the UI.
Inspired by GMeet.
2024-10-15 09:27:02 +02:00
lebaudantoine cde4b10794 ♻️(frontend) update icon for feedback option
This new names feels clearer that 'feedback' is an option for users
to express themselves.
2024-10-15 09:27:02 +02:00
lebaudantoine a47f1f92c4 ♻️(frontend) enhance useSidePanel
Encapsulate all interactions with the layout store concerning the
side panel into a hook. This hook exposes a clear interface.
Each variable has a single responsability, with a clear naming.
2024-10-15 09:27:02 +02:00
lebaudantoine 0db36c788b ♻️(frontend) rename useWidget to useSidePanel
Rename useWidget interaction to useSidePanel. Remove LiveKit-specific naming
as we no longer use LiveKit elements in the layout context. This change
improves clarity and reflects the current functionality of the hook.
2024-10-15 09:27:02 +02:00
lebaudantoine a84b76170d 💩(frontend) integrate chat into sidepanel and revamp UI
Properly integrate chat into the sidepanel to improve UX and avoid disruptions.
Implement initial styling based on Google Meet's design, with plans for future
enhancements. Some details remain to be refined, such as preserving newline
characters in the message formatter.

This substantial commit refactors and cleans up a significant legacy component.
Chat notifications will be addressed in a separate PR.

Note: While this is a large commit, it represents a major improvement in user
experience (in my opinion).
2024-10-15 09:27:02 +02:00
lebaudantoine 998382020d ♻️(frontend) refactor backported LiveKit sources
Backported changes to LiveKit sources, eliminating the need to listen for
specific Participant events.
2024-10-15 09:27:02 +02:00
lebaudantoine 2a12715673 🚸(frontend) enhance side panel UX
Implement smooth animations and DOM persistence for sidepanel.
Side panel state should be kept alive, to match initial LiveKit team
intent.

Temporarily, remove chat component. Chat functionality will be absent
until next commits. It will be reintegrated in the side panel.
2024-10-15 09:27:02 +02:00
lebaudantoine 54d4330a97 🩹(frontend) clean up notifications when participant quits
When a participant leaves a videoconference, remove all notifications they
authored. It's generally unnecessary to keep notifications about actions from
users who are no longer present, with few exceptions.

As we currently support only two notification types (Joined and Raised Hand),
it's appropriate to close all of them when the author leaves the room.
2024-10-15 09:27:02 +02:00
lebaudantoine 5dcbe56e56 ♻️(frontend) rename hook useRaisedHand filename
Was using a wrong extension .tsx, renamed it to .ts.
Trivial change.
2024-10-15 09:27:02 +02:00
lebaudantoine 1a52221ef2 ♻️(frontend) remove deprecated chat options
Changes introduced by LiveKit which deprecated some chat's options.
As we duplicated their code, let's just removed them.
They are not useful, and not in use anywhere.
2024-10-15 09:27:02 +02:00
lebaudantoine 2f3e64b389 ♻️(frontend) use newly created chat's input
Refactor and adapt LiveKit original component to use the newly
introduced text area, which is more accessible and
also internationalized.
2024-10-15 09:27:02 +02:00
lebaudantoine 2dcaf814e1 (frontend) introduce chat/input component
Introduce a draft chat input component inspired by Google Meet design.
This component is not yet in use and requires further enhancements.

To be improved:
- Avoid sizing in pixels
- Replace hardcoded colors with theme variables

This lays the groundwork for a more interactive chat experience in the future.
2024-10-15 09:27:02 +02:00
lebaudantoine 583f5b8e70 💄(frontend) add style for disabled icon button
Necessary when the submit message button is disabled.
Bad color management again …
2024-10-15 09:27:02 +02:00
lebaudantoine fe8fd36467 (frontend) introduce a TextArea primitive
Needed for the Chat text message input.
Basic styled RAC text area.
2024-10-15 09:27:02 +02:00
lebaudantoine 0370d9cad0 ♻️(frontend) delegate scroll to side panel content
Allow more flexibility in side panel behavior. Some panels, such as the chat,
require specific scrolling functionality:
- Header and footer should remain fixed
- Only chat messages should be scrollable

This change enables customization of scroll behavior for different panel types,
improving component reusability.
2024-10-15 09:27:02 +02:00
lebaudantoine 0da8aa846a ♻️(frontend) encapsulate side panel values
Refactor values in an enum, enhance code's typing.
2024-10-15 09:27:02 +02:00
lebaudantoine 70ed99b6c9 💩(frontend) duplicate chat prefabs component
Duplicate LiveKit component to start customizing it.
2024-10-15 09:27:02 +02:00
lebaudantoine 1875a394c6 ♻️(frontend) simplify button primitive
In object-oriented terms, the previous implementation violated the Liskov
Substitution Principle. Props between these two components (Button and Link)
were not substitutable.

This led to TypeScript errors and increased overall complexity without
significant DX gains. To address this, the LinkButton has been extracted
into a dedicated component.
2024-10-09 16:40:59 +02:00
renovate[bot] 211ba332dc ⬆️(dependencies) update js dependencies 2024-10-09 16:40:59 +02:00
renovate[bot] 7333c21632 ⬆️(dependencies) update vite to v5.4.6 [SECURITY] 2024-10-09 15:09:59 +02:00
lebaudantoine 78ebd1a8fd 👷(ci) update build push action to v6
Update the build push action.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL 682b69fc11 🔒️(backend) migrate backend image to alpine
Enhancement made by @rouja while working on the vulnerabilities
found by Trivy scan.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL 7a73bf8fc2 💚(frontend) fix frontend image vulnerabilities
Fixed vulnerabilities found by the Trivy Scan.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL 1e934957f5 💚(backend) fix backend image vulnerabilities
Fixed vulnerabilities with setup tools found by the Trivy Scan.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL 5a7584a3ad 👷(ci) scan for vulnerabilities on Docker images
Configure Trivy Scan in the CI to detect vulnerabilities on our
Docker image. Enhance stack security.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL fb9bf6b08e 🔐(tilt) add Samuel's key
Add Samuel's key to handle secret.
2024-10-09 11:14:11 +02:00
Jacques ROUSSEL 11b8541005 🔧(backend) fix configuration to avoid different ssl warning
Fix following warning messages :
- You have not set a value for the SECURE_HSTS_SECONDS setting.
- Your SECURE_SSL_REDIRECT setting is not set to True.
2024-09-27 18:46:26 +02:00
lebaudantoine 64607ae8d0 (frontend) introduce push to talk on microphone
I am not a huge fan of changing the component's behavior based on
a if, but that the only way I found to have something quickly.

Actually, the push to talk feature will always only applies to the
microphone. No other devices will be concerned.

Reuse the active speaker indicator to quickly bootstrap the feature.
Some details should be improved in terms of UI. The UX is quite
decent.
2024-09-27 16:34:32 +02:00
lebaudantoine c403bbc347 ♻️(frontend) extract toggle device component
Encapsulate toggle logic in a dedicated component.
Prepare the introduction of the push to talk feature.
2024-09-27 16:34:32 +02:00
lebaudantoine 62855fe12c (frontend) introduce active speaker pushToTalk variant
Will be used to indicate when the push to talk is open.
It gives the user a feedback wether or not her mic is active.
2024-09-27 16:34:32 +02:00
lebaudantoine f27d451eb6 ♻️(frontend) make active speaker keyframe relative to height
Keyframe can now be reused in various context, when the children
size are not fixed to 4px.
2024-09-27 16:34:32 +02:00
lebaudantoine 68792d8019 ✏️(frontend) fix typo in active speaker keyframe
Found a typo and fixed it.
2024-09-27 16:34:32 +02:00
lebaudantoine ebb8c14eeb ♻️(frontend) register shortcut using a hook
Encapsulate the logic for shortcuts registering in a proper hook.
It makes the code reusable and easier to read.
2024-09-27 16:34:32 +02:00
lebaudantoine 15d43b8d5e ♻️(frontend) handle proper shortcut object
Created a type to properly manipulate any data related to a
shortcut. Make the code more concise.
2024-09-27 16:34:32 +02:00
lebaudantoine 1faae96ccd (frontend) introduce long press keyboard shortcut
Needed to support push to talk using the spacebar.
Introduce a utility hook. Will be called by the mic toggle
in the upcoming commits.

Inspired by Jitsi code.
2024-09-27 16:34:32 +02:00
lebaudantoine 651cc0e5bd (frontend) introduce keyboard shortcut
Inspired by Gmeet for the UX and by Jitsi for the code.
Naive implementation of Keyboard shortcuts listener.

Will be enhanced in the upcoming commits.
2024-09-27 16:34:32 +02:00
lebaudantoine 0dadd472ff 🔖(patch) bump release to 0.1.7
Bump release to 0.1.7
2024-09-25 20:05:29 +02:00
lebaudantoine be35ee0258 🐛(frontend) prevent side panel and chat from opening simultaneously
Fixed issue where side panel could open while chat was already open.
Resolved recent bug introduced in a previous commit.
2024-09-25 19:53:48 +02:00
lebaudantoine bece79f47b ♻️(frontend) increase silent login attempts to every 5 minutes
Maximize logged-in users. This ensures that users remain authenticated
more consistently across sessions without manual intervention.
2024-09-25 14:45:09 +02:00
lebaudantoine a499331960 🐛(frontend) prevent silent login during active calls
React Query refetches stale data on window focus, which triggers
silent login attempts for logged-out users.

If user data becomes stale (after 1 hour), silent login redirects
the user to the auth domain, potentially disconnecting them during calls.

To prevent this, user data is now considered non-stale
during active sessions.

If the user logs in via another tab, a manual page
reload will be required to refresh their session.
2024-09-25 14:45:09 +02:00
lebaudantoine d50d167d0a 🐛(helm) fix certificates configuration
Newly introduced ingress for PostHog were misconfigured, oopsie.
Here is the fix.
2024-09-25 11:58:34 +02:00
lebaudantoine e4c7bc0826 ♻️(helm) separate PostHog ingress
Based on feedback from @rouja, I've updated the Helm configuration for PostHog
to use separate ingress resources for each service. Although the documentation
suggests sharing the same ingress, the services have different externalName
values, which conflicts with the use of a vhost in the ingress annotations.
This change ensures proper service redirection by aligning each service with
its own ingress.
2024-09-25 11:40:44 +02:00
lebaudantoine b5244a5ec0 🔧(helm) configure support and analytics
Declare the expected support and analytics env variables
expected by the frontend for each environment. To avoid consuming
too much credits from our PostHog free tier plan.
2024-09-25 11:40:44 +02:00
lebaudantoine 8cc2cc83c6 (frontend) initialize application from remote configs
Query API to get the App's configs.
Replacement for env variables, thus with a single image,
we can dynamically update frontend's behavior.

Code inspired by Marsha. Not sure having a single component
returning null is a good idea, to be discussed.
2024-09-25 11:40:44 +02:00
lebaudantoine e591d09b00 (backend) add endpoint for frontend's configuration
Inspired by Joanie.

In Frontend context, env variables are only available at build time,
not runtime. This is one of the easiest way to pass frontend dynamic
configurations while running.

This commit only exposes the view already existing.
2024-09-25 11:40:44 +02:00
Jacques ROUSSEL eeb4dae12d 💚(ci) fix argocd job which handles sync
Fixed by @rouja. ArgoCD should be more robust than ever, while
syncing with our code.
2024-09-25 11:40:44 +02:00
lebaudantoine 271389d459 🚨(helm) fix linter errors
Initial set-up errors. Fixed them. Removed unused Preprod environment,
as secrets are not configured yet, it raises an error.
2024-09-25 11:40:44 +02:00
Jacques ROUSSEL fe6eefa1f0 👷(ci) lint helmfile
Introduced by @rouja. Added a new linter to ensure helm and yaml
files can be properly parsed into templates.
ArgoCD can not break anymore.
2024-09-25 11:40:44 +02:00
lebaudantoine a276517278 📈(frontend) setup a reverse proxy for analytics
Proxy analytics requests through our backend to minimize
ad-blockers impact. I configured the Helm Charts following
PostHog official documentation.
2024-09-25 11:40:44 +02:00
renovate[bot] 9e9b9015f4 ⬆️(dependencies) update js dependencies 2024-09-24 10:49:28 +02:00
lebaudantoine 3f0378aada ♻️(frontend) remove Tchap support
Tchap is no longer relevant due to the integration of Crisp.
The Tchap channel will be deleted.
2024-09-24 10:49:14 +02:00
Jacques ROUSSEL 90c88a8bd3 🔒️(helm) change domainon production
Add ingress in order to migrate from meet.numerique.gouv.fr to
visio.numerique.gouv.fr
2024-09-23 20:20:56 +02:00
lebaudantoine 1103902c12 🔖(minor) bump release to 0.1.6
Release to 0.1.6
2024-09-23 20:10:16 +02:00
lebaudantoine 812016d09c (frontend) add user support feature using Crisp
Implement a chat feature that allows users to communicate directly with an
engineer for assistance with issues. We use Crisp for consistency, as it’s
already utilized in all other LaSuite products. Additionally, we need to
configure an environment variable in the frontend for better flexibility.

This is the initial implementation, and session handling will be refined
in future updates.
2024-09-23 20:06:29 +02:00
lebaudantoine cadc5c0034 (frontend) add crisp-sdk-web dependency
La Suite uses Crisp for Chat support.
Added their react sdk.
2024-09-23 20:06:29 +02:00
lebaudantoine b083d837f8 ♻️(frontend) encapsulate PostHog logic in dedicated functions
Decouple the code from the PostHog dependency by wrapping it in a custom hook
and several utility functions. This enhances code maintainability and separation
of concerns.
2024-09-23 18:58:38 +02:00
lebaudantoine db8445f4ab 🐛(backend) add missing Django settings
Fixed an issue where a setting cloned from Joanie was missing
in the  Django configuration. Although its value was provided, it was
not applied due to the missing reference in the settings file.
2024-09-23 18:12:44 +02:00
lebaudantoine 0e5c2be445 (frontend) replace login link by ProConnect button
Following their documentation, replace our generic login button,
by a more specific one. Asked by several users.
2024-09-23 17:31:07 +02:00
lebaudantoine 0131a65509 ✏️(frontend) fix minors typos in i18n
Found and fixed minor typos in our copywritting.
2024-09-23 16:34:11 +02:00
lebaudantoine fb3727e73e 👔(frontend) rename 'meet' to 'visio'
In alignment with French government guidelines,
ensuring no English terms slip through.
2024-09-23 16:34:11 +02:00
Jacques ROUSSEL 3391165e4b 🔒️(helm) change domain
Change the domain to visio-staging.beta.numerique.gouv.fr
2024-09-23 12:07:13 +02:00
Jacques ROUSSEL 0be94aa572 🔒️(helm) setup temporary redirect
Add a specific certificate to prepare redirect
2024-09-23 12:07:13 +02:00
lebaudantoine b309f91095 💡(frontend) highlight technical debt
We should use participant attributes, rather than metadata, to store hand
status (raised/lowered). However, the useParticipantInfo hook is currently
not suited for this, as it does not refresh when attributes change.
2024-09-22 20:02:33 +02:00
lebaudantoine c50a749293 ♻️(frontend) simplify raised hand notification
Listen to metadata changed events instead of messaging the whole
room. Less code, might be less clean, to be discussed.
2024-09-22 20:02:33 +02:00
lebaudantoine cd9e7c8a1f 🚸(frontend) inform user Blurring effects are experimental
Be transparent with users: this version lacks quality and is currently
limited to Chrome, which may lead to frustration.

In addition to providing information, we should implement a method to
collect user feedback and reactions.
2024-09-22 20:02:33 +02:00
lebaudantoine 9d0767ccfe ♻️(frontend) refactor useParticipants to streamline updatesOnlyOn logic
Simplified the logic to avoid redundancy. Removed unnecessary duplication of
eventGroups, as it's already exported from the core. My bad for the initial
complication — code is now cleaner.
2024-09-22 20:02:33 +02:00
lebaudantoine 021e52d9eb 💄(frontend) organize blurring options
Add visual container and a heading to indicate buttons are
blurring options.
2024-09-22 20:02:33 +02:00
lebaudantoine 607a5fc99d (frontend) introduce blurring effects for Chromium-based users
Implemented using MediaPipe, which is not supported on Firefox due to
limitations outlined in issue #38 of the track-processors-js repo.

We decided to release this first version exclusively for Chrome to quickly
offer a solution to users. Future iterations will address broader compatibility.

An informational message will be added to notify users that the feature is
experimental. For now, a text label will be used in place of an icon.
2024-09-22 20:02:33 +02:00
lebaudantoine 756be17cc7 (frontend) introduce a side panel for effects
It simply renders the video track if enabled. It's a basis
for building the 'blur your screen' feature.

More in the upcoming commits.
2024-09-22 20:02:33 +02:00
lebaudantoine 00fa7beebd ♻️(frontend) introduce a side panel
Refactor side panel into a reusable component to display any interactive
content like menus, messages, participant lists, or effects. Establish it
as a core feature of the videoconference tool.

Improve extensibility and better share responsibilities. The next step is to
refactor the chat to render inside the side panel.
2024-09-22 20:02:33 +02:00
lebaudantoine b9d13de591 (frontend) introduce 'effects' menu item
Increased all icons size, as they looked a bit small on screen.
Introduce a new component separator, to organize menu items
in section.

Work in progress, added the 'effects' item, which triggers no
action yet.
2024-09-22 20:02:33 +02:00
lebaudantoine 7896890ffc ♻️(frontend) refactor translation keys to use prefix
Code cleaning. Avoid duplication.
2024-09-22 20:02:33 +02:00
lebaudantoine a2043abb51 (frontend) install track-processors-js from LiveKit
Recommended dependency to process video tracks, to add a virtual
background, or a blurring effect. Created and maintained by the
core LiveKit's team.

Please note, few issues are known. track-processors-js relies on
MediaPipe (by Google), which is not supported on Firefox …

Quick-win to ship a first draft.
2024-09-22 20:02:33 +02:00
rouja 64bb1b3bb5 Merge pull request #161 from numerique-gouv/fix-secret-mep-issue
🔒️(helm) fix secret sync precedence
2024-09-20 15:04:29 +02:00
Jacques ROUSSEL a1a56402d1 🔒️(helm) fix secret sync precedence
When new secret is added to backend secret, it's not sync at the
beginning of argocd synchronisation and jobs are blocked. Theses new
annotations fix this issue.
2024-09-20 14:55:15 +02:00
lebaudantoine 582d3774a4 ⬆️(frontend) fix persistent choices nesting issue
Upgraded to livekit/components-react v2.5.4, fixing a bug in v2.5.3 that
caused repeated nesting of persistent user choices, exhausting the quota.
Thanks to Lukas for the quick fix.
2024-09-17 19:43:40 +02:00
lebaudantoine fab08cfaf8 🚑️(frontend) fix posthog user's ID
Quotes were misplaced, leading to the same ID being generated for every user.
Fixed the issue, which was a trivial error.
2024-09-17 18:26:53 +02:00
lebaudantoine 550c48f29e 📈(frontend) track room join events with room slug
Focus on join events as a core metric for user room participation.
Added properties to differentiate events by room slug.
2024-09-17 15:28:52 +02:00
lebaudantoine 0c64580abf 📈(frontend) track page view events in SPA
Implement page view tracking in the SPA following PostHog’s guidelines.
Manually capture page's location when it changes.
2024-09-17 15:28:52 +02:00
lebaudantoine 0ad9b7e233 📈(frontend) limit event tracking to stay within free tier limits
Optimize event tracking to be frugal and reduce event volume,
even though filtering can be done later if needed.
2024-09-17 15:28:52 +02:00
lebaudantoine b9bcc45cce 📈(frontend) add explicit data attributes for key event tracking
PostHog's autocapture is useful, but explicit tracking ensures cleaner, more
focused data. This initial pass targets high-impact actions; we'll iterate as
needed.
2024-09-17 15:28:52 +02:00
lebaudantoine bcd285e368 ♻️(frontend) refactor tooltip on screen share toggle
Reorganize tooltip to follow others toggle buttons.
2024-09-17 15:28:52 +02:00
lebaudantoine c0ad98eb34 ♻️(frontend) refactor keyprefix in translation
Factorized translations' prefix.
2024-09-17 15:28:52 +02:00
lebaudantoine 178278235e (frontend) login and logout from Posthog
Following Posthog documentation, login and logout users directly
from the client. Note: Logout function should be encapsulate in
a proper function.
2024-09-17 15:28:52 +02:00
lebaudantoine 11664956c7 (frontend) initialize posthog
Initialize Posthog script with our project's keys.
This key is public, it's not a secret.

Our data is hosted in Europe, followed Tchap integration,
which was reviewed by the ANSI.
2024-09-17 15:28:52 +02:00
lebaudantoine f020979188 (frontend) add posthog dependency
Posthog is a product analysis tool.
Installed the js SDK to track product usage.
2024-09-17 15:28:52 +02:00
renovate[bot] da5c8d6773 ⬆️(dependencies) update python dependencies 2024-09-16 18:08:35 +02:00
lebaudantoine 31051cd6c4 🚸(frontend) improve share screen UX
Enhanced the share screen button by adding a tooltip and improving contrast for
better accessibility. Created a temporary icon by combining two from Remix, but
it’s bulky and will need refactoring soon.
2024-09-16 18:02:53 +02:00
lebaudantoine c51058e6ac ✏️(frontend) fix a minor typo
Minor typo copied/pasted from LiveKit sources. Fixed it.
2024-09-16 18:02:53 +02:00
lebaudantoine 3ba913bb21 ♻️(frontend) use keyprefix args
My bad, avoid repeating the same prefix.
2024-09-16 18:02:53 +02:00
lebaudantoine 95116f70e8 🚸(frontend) enhance quit button UX
Improved handling of the disconnect call by properly managing the returned
promise. Simplified the approach by skipping the useDisconnectButton hook.
Updated the button to display only an icon for a cleaner interface.
2024-09-16 18:02:53 +02:00
lebaudantoine 5b282b62e7 🩹(frontend) dismiss notification toasts on room disconnect
Ensure notification toasts are cleared when a participant disconnects,
preventing stale notifications from showing if the user quickly rejoins.
This resolves issues with duplicate or outdated notifications appearing.
2024-09-16 18:02:53 +02:00
lebaudantoine 5e74fce6e2 🚸(frontend) simplify audio and video select inputs
Based on @manuhabitela's works.
Align UX with common tools as Gmeet or Jitsi.
Enhanced accessibility.
2024-09-16 18:02:53 +02:00
lebaudantoine 96b279b350 🚨(frontend) fix eslint errors
Recent dependencies' updates triggered new
eslint errors. Fixed them.
2024-09-15 17:57:21 +02:00
lebaudantoine fc4ad562ae 📌(frontend) pin eslint dependency
Conflicts with eslint-plugin-react-hooks dependency,
which doesn't support the latest v9 of eslint.
2024-09-15 17:57:21 +02:00
renovate[bot] 480a8df31a ⬆️(dependencies) update js dependencies 2024-09-15 17:57:21 +02:00
485 changed files with 65925 additions and 6921 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:
+50 -60
View File
@@ -1,4 +1,5 @@
name: Docker Hub Workflow
run-name: Docker Hub Workflow
on:
workflow_dispatch:
@@ -18,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
@@ -47,10 +31,16 @@ 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
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
target: backend-production
@@ -62,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
@@ -91,10 +64,16 @@ 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
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/meet-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/Dockerfile
@@ -104,37 +83,48 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-summary:
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/meet-summary
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Build and push
uses: docker/build-push-action@v6
with:
context: ./src/summary
file: ./src/summary/Dockerfile
target: production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
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
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
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/numerique-gouv/lasuite-deploiement"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}" | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}
+51 -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,6 +131,7 @@ 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
@@ -144,7 +155,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]
@@ -175,25 +186,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 +249,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
+33
View File
@@ -0,0 +1,33 @@
name: Release Chart
run-name: Release Chart
on:
push:
paths:
- src/helm/meet/**
jobs:
release:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cleanup
run: rm -rf ./src/helm/extra
- name: Install Helm
uses: azure/setup-helm@v4
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Publish Helm charts
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
with:
charts_dir: ./src/helm
linting: on
token: ${{ secrets.GITHUB_TOKEN }}
+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
+2
View File
@@ -8,3 +8,5 @@ creation_rules:
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa #marie
- age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw #argocd
- age18fgn6j2vwwswqcpv9xpcehq8mrf9zs2sglwkamp3tzwx8d9jq9jsrskrk9 #manuuu
- age1hm2hsfgjezpsc3k0y5w5feq9t8vl3seq04qjhgt6ztd6403wfvpsgxu09m # github-repo
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
+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! 👍
+22 -31
View File
@@ -1,18 +1,17 @@
# Django Meet
# ---- base image to inherit from ----
FROM python:3.10-slim-bullseye as base
FROM python:3.12.6-alpine3.20 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip
RUN python -m pip install --upgrade pip setuptools
# Upgrade system packages to install security updates
RUN apt-get update && \
apt-get -y upgrade && \
rm -rf /var/lib/apt/lists/*
RUN apk update && \
apk upgrade
# ---- Back-end builder image ----
FROM base as back-builder
FROM base AS back-builder
WORKDIR /builder
@@ -24,7 +23,7 @@ RUN mkdir /install && \
# ---- mails ----
FROM node:20 as mail-builder
FROM node:20 AS mail-builder
COPY ./src/mail /mail/app
@@ -35,15 +34,12 @@ RUN yarn install --frozen-lockfile && \
# ---- static link collector ----
FROM base as link-collector
FROM base AS link-collector
ARG MEET_STATIC_ROOT=/data/static
# Install libpangocairo & rdfind
RUN apt-get update && \
apt-get install -y \
libpangocairo-1.0-0 \
rdfind && \
rm -rf /var/lib/apt/lists/*
RUN apk add \
pango \
rdfind
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
@@ -62,21 +58,18 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${MEET_STATIC_ROOT}
# ---- Core application image ----
FROM base as core
FROM base AS core
ENV PYTHONUNBUFFERED=1
# Install required system libs
RUN apt-get update && \
apt-get install -y \
gettext \
libcairo2 \
libffi-dev \
libgdk-pixbuf2.0-0 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
shared-mime-info && \
rm -rf /var/lib/apt/lists/*
RUN apk add \
gettext \
cairo \
libffi-dev \
gdk-pixbuf \
pango \
shared-mime-info
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -100,15 +93,13 @@ WORKDIR /app
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
# ---- Development image ----
FROM core as backend-development
FROM core AS backend-development
# Switch back to the root user to install development dependencies
USER root:root
# Install psql
RUN apt-get update && \
apt-get install -y postgresql-client && \
rm -rf /var/lib/apt/lists/*
RUN apk add postgresql-client
# Uninstall Meet and re-install it in editable mode along with development
# dependencies
@@ -128,7 +119,7 @@ ENV DB_HOST=postgresql \
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
# ---- Production image ----
FROM core as backend-production
FROM core AS backend-production
ARG MEET_STATIC_ROOT=/data/static
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2023 Direction Interministérielle du Numérique - Gouvernement Français
Copyright (c) 2021-2023 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) 2021-2023 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.
+45 -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
@@ -301,7 +336,14 @@ build-k8s-cluster: ## build the kubernetes cluster using kind
./bin/start-kind.sh
.PHONY: build-k8s-cluster
install-external-secrets: ## install the kubernetes secrets from Vaultwarden
./bin/install-external-secrets.sh
.PHONY: build-k8s-cluster
start-tilt: ## start the kubernetes cluster using kind
tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
+97 -133
View File
@@ -1,155 +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="posthoglogo" src="./docs/assets/visio-logo.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://go.crisp.chat/chat/embed/?website_id=58ea6697-8eba-4492-bc59-ad6562585041">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
</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="Visio Demonstration">
</a>
</p>
Finally, you can check all available Make rules using:
## Visio: 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/), Visio offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
### 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
Visio is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
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
```
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
...
### Visio Cloud (Recommended)
Sign up for Visio Cloud, designed for European public servants. Hosted on SecNumCloud-compliant providers and accessible via government SSO, [ProConnect](https://www.proconnect.gouv.fr/). The easiest way to try our product. Reach out if your entity isn't connected yet to our sso.
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 Visio on your own infrastructure using [our self-hosting guide](https://github.com/suitenumerique/meet/blob/main/docs/installation.md). Our open-source deployment is optimized for Kubernetes, and we're working on supporting additional deployment options. Keycloak integration and any SSO are supported. We offer customer support for open-source setups—just reach out for assistance.
## Docs
We're currently working on both technical and user documentation for Visio. In the meantime, many of the essential aspects are already well covered by the [LiveKit documentation](https://docs.livekit.io/home/) and their [self-hosting guide](https://docs.livekit.io/home/self-hosting/deployment/). Stay tuned for more updates!
## 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 Visio locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
## Philosophy
Were relentlessly focused on building the best open-source video conferencing product—Visio. Growth comes from creating something people truly need, not just from chasing metrics.
Our users come first. Were committed to making Visio 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 Visio 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.
+35 -1
View File
@@ -2,6 +2,9 @@ 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='..',
@@ -16,6 +19,7 @@ docker_build(
)
]
)
clean_old_images('localhost:5001/meet-backend')
docker_build(
'localhost:5001/meet-frontend:latest',
@@ -27,8 +31,38 @@ docker_build(
sync('../src/frontend', '/home/frontend'),
]
)
clean_old_images('localhost:5001/meet-frontend')
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e dev template .'))
docker_build(
'localhost:5001/meet-summary:latest',
context='../src/summary',
dockerfile='../src/summary/Dockerfile',
only=['.', '../../docker', '../../.dockerignore'],
target = 'production',
live_update=[
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 "$(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 .'))
migration = '''
set -eu
+1 -1
View File
@@ -5,7 +5,7 @@ set -eo pipefail
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
UNSET_USER=0
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
COMPOSE_FILE="${REPO_DIR}/compose.yml"
COMPOSE_PROJECT="meet"
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
set -o errexit
CURRENT_DIR=$(pwd)
NAMESPACE=${1:-meet}
SECRET_NAME=${2:-bitwarden-cli-meet}
TEMP_SECRET_FILE=$(mktemp)
cleanup() {
rm -f "${TEMP_SECRET_FILE}"
}
trap cleanup EXIT
# Check if kubectl is available
check_prerequisites() {
if ! command -v kubectl &> /dev/null; then
echo "Error: kubectl is not installed or not in PATH"
exit 1
fi
}
# Check if secret already exists
check_secret_exists() {
kubectl -n "${NAMESPACE}" get secrets "${SECRET_NAME}" &> /dev/null
}
# Collect user input securely
get_user_input() {
echo "Please provide the following information:"
read -p "Enter your Vaultwarden email login: " LOGIN
read -s -p "Enter your Vaultwarden password: " PASSWORD
echo
read -p "Enter your Vaultwarden server url: " URL
}
# Create and apply the secret
create_secret() {
cat > "${TEMP_SECRET_FILE}" << EOF
apiVersion: v1
kind: Secret
metadata:
name: ${SECRET_NAME}
namespace: ${NAMESPACE}
type: Opaque
stringData:
BW_HOST: ${URL}
BW_PASSWORD: ${PASSWORD}
BW_USERNAME: ${LOGIN}
EOF
kubectl -n "${NAMESPACE}" apply -f "${TEMP_SECRET_FILE}"
}
# Install external-secrets using Helm
install_external_secrets() {
if ! kubectl get ns external-secrets &>/dev/null; then
echo "Installing external-secrets…"
helm repo add external-secrets https://charts.external-secrets.io
helm upgrade --install external-secrets \
external-secrets/external-secrets \
-n external-secrets \
--create-namespace \
--set installCRDs=true
else
echo "External secrets already deployed"
fi
}
main() {
check_prerequisites
if check_secret_exists; then
echo "Secret '${SECRET_NAME}' already present in namespace '${NAMESPACE}'"
exit 0
fi
echo -e ${TEMP_SECRET_FILE}
get_user_input
echo -e "\nCreating Vaultwarden secret…"
create_secret
install_external_secrets
echo "Secret installation completed successfully"
}
main "$@"
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
mkdir -p "$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/"
PRE_COMMIT_FILE="$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/pre-commit"
+2 -102
View File
@@ -1,103 +1,3 @@
#!/bin/sh
set -o errexit
#!/usr/bin/env bash
CURRENT_DIR=$(pwd)
echo "0. Create ca"
# 0. Create ca
mkcert -install
cd /tmp
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
cd $CURRENT_DIR
echo "1. Create registry container unless it already exists"
# 1. Create registry container unless it already exists
reg_name='kind-registry'
reg_port='5001'
if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then
docker run \
-d --restart=always -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
registry:2
fi
echo "2. Create kind cluster with containerd registry config dir enabled"
# 2. Create kind cluster with containerd registry config dir enabled
# TODO: kind will eventually enable this by default and this patch will
# be unnecessary.
#
# See:
# https://github.com/kubernetes-sigs/kind/issues/2875
# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration
# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md
cat <<EOF | kind create cluster --config=-
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
containerdConfigPatches:
- |-
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
nodes:
- role: control-plane
image: kindest/node:v1.27.3
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
- containerPort: 80
hostPort: 80
protocol: TCP
- containerPort: 443
hostPort: 443
protocol: TCP
- role: worker
image: kindest/node:v1.27.3
- role: worker
image: kindest/node:v1.27.3
EOF
echo "3. Add the registry config to the nodes"
# 3. Add the registry config to the nodes
#
# This is necessary because localhost resolves to loopback addresses that are
# network-namespace local.
# In other words: localhost in the container is not localhost on the host.
#
# We want a consistent name that works from both ends, so we tell containerd to
# alias localhost:${reg_port} to the registry container when pulling images
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${reg_port}"
for node in $(kind get nodes); do
docker exec "${node}" mkdir -p "${REGISTRY_DIR}"
cat <<EOF | docker exec -i "${node}" cp /dev/stdin "${REGISTRY_DIR}/hosts.toml"
[host."http://${reg_name}:5000"]
EOF
done
echo "4. Connect the registry to the cluster network if not already connected"
# 4. Connect the registry to the cluster network if not already connected
# This allows kind to bootstrap the network but ensures they're on the same network
if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${reg_name}")" = 'null' ]; then
docker network connect "kind" "${reg_name}"
fi
echo "5. Document the local registry"
# 5. Document the local registry
# https://github.com/kubernetes/enhancements/tree/master/keps/sig-cluster-lifecycle/generic/1755-communicating-a-local-registry
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: local-registry-hosting
namespace: kube-public
data:
localRegistryHosting.v1: |
host: "localhost:${reg_port}"
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
EOF
echo "6. Install ingress-nginx"
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"}]'
curl https://raw.githubusercontent.com/numerique-gouv/tools/refs/heads/main/kind/create_cluster.sh | bash -s -- meet
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
git submodule update --init --recursive
git submodule foreach 'git fetch origin; git checkout $(git rev-parse --abbrev-ref HEAD); git reset --hard origin/$(git rev-parse --abbrev-ref HEAD); git submodule update --recursive; git clean -dfx'
+1 -1
View File
@@ -1,3 +1,3 @@
#!/bin/bash
#!/usr/bin/env bash
find . -name "*.enc.*" -exec sops updatekeys -y {} \;
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -e
HELMFILE=src/helm/helmfile.yaml
environments=$(awk '/environments:/ {flag=1; next} flag && NF {print} !NF {flag=0}' "$HELMFILE" | grep -E '^[[:space:]]{2}[a-zA-Z]+' | sed 's/^[[:space:]]*//;s/:.*//')
for env in $environments; do
echo "################### $env lint ###################"
helmfile -e $env -f src/helm/helmfile.yaml lint || exit 1
echo -e "\n"
done
+13 -1
View File
@@ -1,4 +1,3 @@
version: '3.8'
services:
postgresql:
@@ -96,8 +95,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
+405 -405
View File
File diff suppressed because it is too large Load Diff
+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: 28 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/).
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
replicaCount: 1
terminationGracePeriodSeconds: 18000
livekit:
keys:
devkey: secret
log_level: debug
rtc:
use_external_ip: false
port_range_start: 50000
port_range_end: 60000
tcp_port: 7881
redis:
address: redis-master:6379
password: pass
turn:
enabled: true
udp_port: 443
domain: livekit.127.0.0.1.nip.io
loadBalancerAnnotations: {}
loadBalancer:
type: nginx
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
nginx.ingress.kubernetes.io/ssl-redirect: "true"
tls:
- hosts:
- livekit.127.0.0.1.nip.io
secretName: livekit-dinum-cert
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 5
targetCPUUtilizationPercentage: 60
nodeSelector: {}
resources: {}
+120
View File
@@ -0,0 +1,120 @@
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "latest"
backend:
replicas: 1
envVars:
DJANGO_CSRF_TRUSTED_ORIGINS: https://meet.127.0.0.1.nip.io,http://meet.127.0.0.1.nip.io
DJANGO_CONFIGURATION: Production
DJANGO_ALLOWED_HOSTS: meet.127.0.0.1.nip.io
DJANGO_SECRET_KEY: ThisCouldBeAReallyGoodOrPerhapsABadKeyToUseSometimes
DJANGO_SETTINGS_MODULE: meet.settings
DJANGO_SILENCED_SYSTEM_CHECKS: security.W004, security.W008
DJANGO_SUPERUSER_PASSWORD: admin
DJANGO_EMAIL_HOST: "mailcatcher"
DJANGO_EMAIL_PORT: 1025
DJANGO_EMAIL_USE_SSL: False
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/session/end
OIDC_RP_CLIENT_ID: meet
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
OIDC_REDIRECT_ALLOWED_HOSTS: https://meet.127.0.0.1.nip.io
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
DB_HOST: postgresql
DB_NAME: meet
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: meet
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
LIVEKIT_API_SECRET: secret
LIVEKIT_API_KEY: devkey
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
ALLOW_UNREGISTERED_ROOMS: False
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
migrate:
command:
- "/bin/sh"
- "-c"
- |
python manage.py migrate --no-input &&
python manage.py create_demo --force
restartPolicy: Never
command:
- "gunicorn"
- "-c"
- "/usr/local/etc/gunicorn/meet.py"
- "meet.wsgi:application"
- "--reload"
createsuperuser:
command:
- "/bin/sh"
- "-c"
- |
python manage.py createsuperuser --email admin@example.com --password admin
restartPolicy: Never
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts:
- name: certs
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
subPath: cacert.pem
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumes:
- name: certs
configMap:
name: certifi
items:
- key: cacert.pem
path: cacert.pem
frontend:
envVars:
VITE_PORT: 8080
VITE_HOST: 0.0.0.0
VITE_API_BASE_URL: https://meet.127.0.0.1.nip.io/
replicas: 1
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "latest"
ingress:
enabled: true
host: meet.127.0.0.1.nip.io
ingressAdmin:
enabled: true
host: meet.127.0.0.1.nip.io
posthog:
ingress:
enabled: false
ingressAssets:
enabled: false
summary:
replicas: 0
celery:
replicas: 0
+7
View File
@@ -0,0 +1,7 @@
auth:
username: dinum
password: pass
database: meet
tls:
enabled: true
autoGenerated: true
+3
View File
@@ -0,0 +1,3 @@
auth:
password: pass
architecture: standalone
+233
View File
@@ -0,0 +1,233 @@
# Installation on a k8s cluster
This document is a step-by-step guide that describes how to install Visio on a k8s cluster without AI features.
## Prerequisites
- k8s cluster with an nginx-ingress controller
- an OIDC provider (if you don't have one, we will provide an example)
- a LiveKit server (if you don't have one, we will provide an example)
- a PostgreSQL server (if you don't have one, we will provide an example)
- a Memcached server (if you don't have one, we will provide an example)
### 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**.
To be able to use the script, you will need to install:
- 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)
```
$ ./bin/start-kind.sh
0. Create ca
The local CA is already installed in the system trust store! 👍
The local CA is already installed in the Firefox and/or Chrome/Chromium trust store! 👍
Created a new certificate valid for the following names 📜
- "127.0.0.1.nip.io"
- "*.127.0.0.1.nip.io"
Reminder: X.509 wildcards only go one level deep, so this won't match a.b.127.0.0.1.nip.io
The certificate is at "./127.0.0.1.nip.io+1.pem" and the key at "./127.0.0.1.nip.io+1-key.pem" ✅
It will expire on 23 March 2027 🗓
1. Create registry container unless it already exists
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 💾
Set kubectl context to "kind-visio"
You can now use your cluster with:
kubectl cluster-info --context kind-visio
Thanks for using kind! 😊
3. Add the registry config to the nodes
4. Connect the registry to the cluster network if not already connected
5. Document the local registry
configmap/local-registry-hosting created
Warning: resource configmaps/coredns is missing the kubectl.kubernetes.io/last-applied-configuration annotation which is required by kubectl apply. kubectl apply should only be used on resources created declaratively by either kubectl create --save-config or kubectl apply. The missing annotation will be patched automatically.
configmap/coredns configured
deployment.apps/coredns restarted
6. Install ingress-nginx
namespace/ingress-nginx created
serviceaccount/ingress-nginx created
serviceaccount/ingress-nginx-admission created
role.rbac.authorization.k8s.io/ingress-nginx created
role.rbac.authorization.k8s.io/ingress-nginx-admission created
clusterrole.rbac.authorization.k8s.io/ingress-nginx created
clusterrole.rbac.authorization.k8s.io/ingress-nginx-admission created
rolebinding.rbac.authorization.k8s.io/ingress-nginx created
rolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx created
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
configmap/ingress-nginx-controller created
service/ingress-nginx-controller created
service/ingress-nginx-controller-admission created
deployment.apps/ingress-nginx-controller created
job.batch/ingress-nginx-admission-create created
job.batch/ingress-nginx-admission-patch created
ingressclass.networking.k8s.io/nginx created
validatingwebhookconfiguration.admissionregistration.k8s.io/ingress-nginx-admission created
secret/mkcert created
deployment.apps/ingress-nginx-controller patched
7. Setup namespace
namespace/meet created
Context "kind-visio" modified.
secret/mkcert created
$ kind get clusters
visio
$ kubectl -n ingress-nginx get po
NAME READY STATUS RESTARTS AGE
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.
## Preparation
### 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).
If you haven't run the script **bin/start-kind.sh**, you'll need to manually create the namespace by running the following command:
```
$ kubectl create namespace meet
```
If you have already run the script, you can skip this step and proceed to the next instruction.
```
$ kubectl config set-context --current --namespace=meet
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
$ #wait until
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 6m48s
keycloak-postgresql-0 1/1 Running 0 6m48s
```
From here the important information you will need are :
```
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/session/end
OIDC_RP_CLIENT_ID: meet
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
```
You can find these values in **examples/keycloak.values.yaml**
### Find livekit server connexion values
Visio use livekit for streaming part so if you have a livekit provider, obtain the necessary information to use it. If you do not have a provider, you can install a livekit testing environment as follows:
Livekit need a redis (and meet too) so we will start by deploying a redis :
```
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
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.
```
$ helm repo add livekit https://helm.livekit.io
$ helm repo update
$ helm install livekit livekit/livekit-server -f examples/livekit.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 30m
keycloak-postgresql-0 1/1 Running 0 30m
livekit-livekit-server-5c5fb87f7f-ct6x5 1/1 Running 0 7s
redis-master-0 1/1 Running 0 4m30s
$ curl https://livekit.127.0.0.1.nip.io
OK
```
From here important information you will need are :
```
LIVEKIT_API_SECRET: secret
LIVEKIT_API_KEY: devkey
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
REDIS_URL: redis://default:pass@redis-master:6379/1
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
```
### Find postgresql connexion values
Visio uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
```
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 45m
keycloak-postgresql-0 1/1 Running 0 45m
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 :
```
DB_HOST: postgres-postgresql
DB_NAME: meet
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: meet
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
```
## Deployment
Now you are ready to deploy Visio without AI. AI required more dependencies (Openai-compliant API, LiveKit Egress, Cold storage and a docs deployment to push resumes). To deploy meet you need to provide all previous information to the helm chart.
```
$ helm repo add meet https://suitenumerique.github.io/meet/
$ helm repo update
$ helm install meet meet/meet -f examples/meet.values.yaml
```
## Test your deployment
In order to test your deployment you have to log in to your instance. If you use exclusively our examples you can do:
```
$ kubectl get ingress
NAME CLASS HOSTS ADDRESS PORTS AGE
keycloak <none> keycloak.127.0.0.1.nip.io localhost 80 58m
livekit-livekit-server <none> livekit.127.0.0.1.nip.io localhost 80, 443 106m
meet <none> meet.127.0.0.1.nip.io localhost 80, 443 52m
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.
+3 -1
View File
@@ -13,7 +13,9 @@
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": []
"matchPackageNames": [
"eslint", "react", "react-dom", "@types/react-dom", "@types/react", "react-i18next"
]
}
]
}
Submodule secrets deleted from f5fbc16e6e
+2 -2
View File
@@ -447,10 +447,10 @@ max-bool-expr=5
max-branches=12
# Maximum number of locals for function / method body
max-locals=15
max-locals=20
# Maximum number of parents for a class (see R0901).
max-parents=7
max-parents=10
# Maximum number of public methods for a class (see R0904).
max-public-methods=20
+48 -4
View File
@@ -22,7 +22,19 @@ class UserAdmin(auth_admin.UserAdmin):
)
},
),
(_("Personal info"), {"fields": ("sub", "email", "language", "timezone")}),
(
_("Personal info"),
{
"fields": (
"sub",
"email",
"full_name",
"short_name",
"language",
"timezone",
)
},
),
(
_("Permissions"),
{
@@ -52,6 +64,8 @@ class UserAdmin(auth_admin.UserAdmin):
"sub",
"admin_email",
"email",
"full_name",
"short_name",
"is_active",
"is_staff",
"is_superuser",
@@ -60,9 +74,24 @@ class UserAdmin(auth_admin.UserAdmin):
"updated_at",
)
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
readonly_fields = ("id", "sub", "email", "created_at", "updated_at")
search_fields = ("id", "sub", "admin_email", "email")
ordering = (
"is_active",
"-is_superuser",
"-is_staff",
"-is_device",
"-updated_at",
"full_name",
)
readonly_fields = (
"id",
"sub",
"email",
"full_name",
"short_name",
"created_at",
"updated_at",
)
search_fields = ("id", "sub", "admin_email", "email", "full_name")
class ResourceAccessInline(admin.TabularInline):
@@ -77,3 +106,18 @@ class RoomAdmin(admin.ModelAdmin):
"""Room admin interface declaration."""
inlines = (ResourceAccessInline,)
class RecordingAccessInline(admin.TabularInline):
"""Inline admin class for recording accesses."""
model = models.RecordingAccess
extra = 0
@admin.register(models.Recording)
class RecordingAdmin(admin.ModelAdmin):
"""Recording admin interface declaration."""
inlines = (RecordingAccessInline,)
list_display = ("id", "status", "room", "created_at", "worker_id")
-58
View File
@@ -1,58 +0,0 @@
"""
Meet analytics class.
"""
import uuid
from django.conf import settings
from june import analytics as jAnalytics
class Analytics:
"""Analytics integration
This class wraps the June analytics code to avoid coupling our code directly
with this third-party library. By doing so, we create a generic interface
for analytics that can be easily modified or replaced in the future.
"""
def __init__(self):
key = getattr(settings, "ANALYTICS_KEY", None)
if key is not None:
jAnalytics.write_key = key
self._enabled = key is not None
def _is_anonymous_user(self, user):
"""Check if the user is anonymous."""
return user is None or user.is_anonymous
def identify(self, user, **kwargs):
"""Identify a user"""
if self._is_anonymous_user(user) or not self._enabled:
return
traits = kwargs.pop("traits", {})
traits.update({"email": user.email_anonymized})
jAnalytics.identify(user_id=user.sub, traits=traits, **kwargs)
def track(self, user, **kwargs):
"""Track an event"""
if not self._enabled:
return
event_data = {}
if self._is_anonymous_user(user):
event_data["anonymous_id"] = str(uuid.uuid4())
else:
event_data["user_id"] = user.sub
jAnalytics.track(**event_data, **kwargs)
analytics = Analytics()
+4
View File
@@ -37,6 +37,10 @@ def get_frontend_configuration(request):
"""Returns the frontend configuration dict as configured in settings."""
frontend_configuration = {
"LANGUAGE_CODE": settings.LANGUAGE_CODE,
"recording": {
"is_enabled": settings.RECORDING_ENABLE,
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
},
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration)
+41 -5
View File
@@ -1,5 +1,7 @@
"""Permission handlers for the Meet core app."""
from django.conf import settings
from rest_framework import permissions
from ..models import RoleChoices
@@ -65,15 +67,11 @@ class RoomPermissions(permissions.BasePermission):
return obj.is_administrator(user)
class ResourceAccessPermission(permissions.BasePermission):
class ResourceAccessPermission(IsAuthenticated):
"""
Permissions for a room that can only be updated by room administrators.
"""
def has_permission(self, request, view):
"""Only allow authenticated users."""
return request.user.is_authenticated
def has_object_permission(self, request, view, obj):
"""
Check that the logged-in user is administrator of the linked room.
@@ -83,3 +81,41 @@ class ResourceAccessPermission(permissions.BasePermission):
return obj.user == user
return obj.resource.is_administrator(user)
class HasAbilityPermission(IsAuthenticated):
"""Permission class for access objects."""
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
return obj.get_abilities(request.user).get(view.action, False)
class HasPrivilegesOnRoom(IsAuthenticated):
"""Check if user has privileges on a given room."""
message = "You must have privileges to start a recording."
def has_object_permission(self, request, view, obj):
"""Determine if user has privileges on room."""
return obj.is_owner(request.user) or obj.is_administrator(request.user)
class IsRecordingEnabled(permissions.BasePermission):
"""Check if the recording feature is enabled."""
message = "Access denied, recording is disabled."
def has_permission(self, request, view):
"""Determine if access is allowed based on settings."""
return settings.RECORDING_ENABLE
class IsStorageEventEnabled(permissions.BasePermission):
"""Check if the storage event feature is enabled."""
message = "Access denied, storage event is disabled."
def has_permission(self, request, view):
"""Determine if access is allowed based on settings."""
return settings.RECORDING_STORAGE_EVENT_ENABLE
+87 -14
View File
@@ -1,6 +1,5 @@
"""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
@@ -14,8 +13,8 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ["id", "email"]
read_only_fields = ["id", "email"]
fields = ["id", "email", "full_name", "short_name"]
read_only_fields = ["id", "email", "full_name", "short_name"]
class ResourceAccessSerializerMixin:
@@ -87,12 +86,21 @@ class NestedResourceAccessSerializer(ResourceAccessSerializer):
user = UserSerializer(read_only=True)
class ListRoomSerializer(serializers.ModelSerializer):
"""Serialize Room model for a list API endpoint."""
class Meta:
model = models.Room
fields = ["id", "name", "slug", "access_level"]
read_only_fields = ["id", "slug"]
class RoomSerializer(serializers.ModelSerializer):
"""Serialize Room model for the API."""
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):
@@ -120,19 +128,84 @@ 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}".replace("-", "")
should_access_room = (
(
instance.access_level == models.RoomAccessLevel.TRUSTED
and request.user.is_authenticated
)
or role is not None
or instance.is_public
)
if should_access_room:
room_id = f"{instance.id!s}"
username = request.query_params.get("username", None)
output["livekit"] = {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": slug,
"token": utils.generate_token(
room=slug, user=request.user, username=username
),
}
output["livekit"] = utils.generate_livekit_config(
room_id=room_id, user=request.user, username=username
)
output["is_administrable"] = is_admin
return output
class RecordingSerializer(serializers.ModelSerializer):
"""Serialize Recording for the API."""
room = ListRoomSerializer(read_only=True)
class Meta:
model = models.Recording
fields = ["id", "room", "created_at", "updated_at", "status"]
read_only_fields = fields
class StartRecordingSerializer(serializers.Serializer):
"""Validate start recording requests."""
mode = serializers.ChoiceField(
choices=models.RecordingModeChoices.choices,
required=True,
error_messages={
"required": "Recording mode is required.",
"invalid_choice": "Invalid recording mode. Choose between "
"screen_recording or transcript.",
},
)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")
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("StartRecordingSerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer 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("StartRecordingSerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")
+310 -21
View File
@@ -1,6 +1,7 @@
"""API endpoints"""
import uuid
from logging import getLogger
from django.conf import settings
from django.db.models import Q
@@ -8,23 +9,51 @@ 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, throttling, viewsets
from rest_framework import (
decorators,
mixins,
pagination,
viewsets,
exceptions as drf_exceptions,
)
from rest_framework import (
response as drf_response,
)
from rest_framework import (
status as drf_status,
)
from core import models, utils
from core.recording.event.authentication import StorageEventAuthentication
from core.recording.event.exceptions import (
InvalidBucketError,
InvalidFileTypeError,
ParsingEventDataError,
)
from core.recording.event.notification import notification_service
from core.recording.event.parsers import get_parser
from core.recording.worker.exceptions import (
RecordingStartError,
RecordingStopError,
)
from core.recording.worker.factories import (
get_worker_service,
)
from core.recording.worker.mediator import (
WorkerServiceMediator,
)
from core.services.livekit_events import (
LiveKitEventsService,
LiveKitWebhookError,
)
from core.services.lobby import (
LobbyParticipantNotFound,
LobbyService,
)
from ..analytics import analytics
from . import permissions, serializers
# pylint: disable=too-many-ancestors
logger = getLogger(__name__)
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
@@ -125,9 +154,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", ""):
@@ -152,6 +180,12 @@ class UserViewSet(
)
class RequestEntryAnonRateThrottle(throttling.AnonRateThrottle):
"""Throttle Anonymous user requesting room entry"""
scope = "request_entry"
class RoomViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
@@ -186,13 +220,6 @@ class RoomViewSet(
"""
try:
instance = self.get_object()
analytics.track(
user=self.request.user,
event="Get Room",
properties={"slug": instance.slug},
)
except Http404:
if not settings.ALLOW_UNREGISTERED_ROOMS:
raise
@@ -241,14 +268,198 @@ class RoomViewSet(
role=models.RoleChoices.OWNER,
)
analytics.track(
user=self.request.user,
event="Create Room",
properties={
"slug": room.slug,
},
@decorators.action(
detail=True,
methods=["post"],
url_path="start-recording",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.IsRecordingEnabled,
],
)
def start_room_recording(self, request, pk=None): # pylint: disable=unused-argument
"""Start recording a room."""
serializer = serializers.StartRecordingSerializer(data=request.data)
if not serializer.is_valid():
return drf_response.Response(
{"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST
)
mode = serializer.validated_data["mode"]
room = self.get_object()
# May raise exception if an active or initiated recording already exist for the room
recording = models.Recording.objects.create(room=room, mode=mode)
models.RecordingAccess.objects.create(
user=self.request.user, role=models.RoleChoices.OWNER, recording=recording
)
worker_service = get_worker_service(mode=recording.mode)
worker_manager = WorkerServiceMediator(worker_service=worker_service)
try:
worker_manager.start(recording)
except RecordingStartError:
return drf_response.Response(
{"error": f"Recording failed to start for room {room.slug}"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{"message": f"Recording successfully started for room {room.slug}"},
status=drf_status.HTTP_201_CREATED,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="stop-recording",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.IsRecordingEnabled,
],
)
def stop_room_recording(self, request, pk=None): # pylint: disable=unused-argument
"""Stop room recording."""
room = self.get_object()
try:
recording = models.Recording.objects.get(
room=room, status=models.RecordingStatusChoices.ACTIVE
)
except models.Recording.DoesNotExist as e:
raise drf_exceptions.NotFound(
"No active recording found for this room."
) from e
worker_service = get_worker_service(mode=recording.mode)
worker_manager = WorkerServiceMediator(worker_service=worker_service)
try:
worker_manager.stop(recording)
except RecordingStopError:
return drf_response.Response(
{"error": f"Recording failed to stop for room {room.slug}"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{"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"""
serializer = serializers.RequestEntrySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
room = self.get_object()
lobby_service = LobbyService()
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)
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
)
class ResourceAccessListModelMixin:
"""List mixin for resource access API."""
@@ -293,3 +504,81 @@ class ResourceAccessViewSet(
permission_classes = [permissions.ResourceAccessPermission]
queryset = models.ResourceAccess.objects.all()
serializer_class = serializers.ResourceAccessSerializer
class RecordingViewSet(
mixins.DestroyModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""
API endpoints to access and perform actions on recordings.
"""
pagination_class = Pagination
permission_classes = [permissions.HasAbilityPermission]
queryset = models.Recording.objects.all()
serializer_class = serializers.RecordingSerializer
def get_queryset(self):
"""Restrict recordings to the user's ones."""
user = self.request.user
return (
super()
.get_queryset()
.filter(Q(accesses__user=user) | Q(accesses__team__in=user.get_teams()))
)
@decorators.action(
detail=False,
methods=["post"],
url_path="storage-hook",
authentication_classes=[StorageEventAuthentication],
permission_classes=[permissions.IsStorageEventEnabled],
)
def on_storage_event_received(self, request, pk=None): # pylint: disable=unused-argument
"""Handle incoming storage hook events for recordings."""
parser = get_parser()
try:
recording_id = parser.get_recording_id(request.data)
except ParsingEventDataError as e:
raise drf_exceptions.PermissionDenied(f"Invalid request data: {e}") from e
except InvalidBucketError as e:
raise drf_exceptions.PermissionDenied("Invalid bucket specified") from e
except InvalidFileTypeError as e:
return drf_response.Response(
{"message": f"Ignore this file type, {e}"},
)
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 not recording.is_savable():
raise drf_exceptions.PermissionDenied(
f"Recording with ID {recording_id} cannot be saved because it is either,"
" in an error state or has already been saved."
)
# Attempt to notify external services about the recording
# This is a non-blocking operation - failures are logged but don't interrupt the flow
notification_succeeded = notification_service.notify_external_services(
recording
)
recording.status = (
models.RecordingStatusChoices.NOTIFICATION_SUCCEEDED
if notification_succeeded
else models.RecordingStatusChoices.SAVED
)
recording.save()
return drf_response.Response(
{"message": "Event processed."},
)
+97 -27
View File
@@ -1,6 +1,7 @@
"""Authentication Backends for the Meet core app."""
from django.core.exceptions import SuspiciousOperation
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from django.utils.translation import gettext_lazy as _
import requests
@@ -9,8 +10,11 @@ from mozilla_django_oidc.auth import (
)
from core.models import User
from ..analytics import analytics
from core.services.marketing import (
ContactCreationError,
ContactData,
get_marketing_service,
)
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
@@ -68,36 +72,102 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
user_info = self.get_userinfo(access_token, id_token, payload)
sub = user_info.get("sub")
if sub is None:
if not sub:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
try:
user = User.objects.get(sub=sub)
except User.DoesNotExist:
if self.get_settings("OIDC_CREATE_USER", True):
user = self.create_user(user_info)
else:
user = None
email = user_info.get("email")
user = self.get_existing_user(sub, email)
analytics.identify(user=user)
return user
def create_user(self, claims):
"""Return a newly created User instance."""
sub = claims.get("sub")
if sub is None:
raise SuspiciousOperation(
_("Claims contained no recognizable user identification")
claims = {
"email": email,
"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,
)
user = User.objects.create(
sub=sub,
email=claims.get("email"),
password="!", # noqa: S106
)
if settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
self.signup_to_marketing_email(email)
elif not user:
return None
if not user.is_active:
raise SuspiciousOperation(_("User account is disabled"))
self.update_user_if_needed(user, claims)
return user
@staticmethod
def signup_to_marketing_email(email):
"""Pragmatic approach to newsletter signup during authentication flow.
Details:
1. Uses a very short timeout (1s) to prevent blocking the auth process
2. Silently fails if the marketing service is down/slow to prioritize user experience
3. Trade-off: May miss some signups but ensures auth flow remains fast
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
"""
try:
marketing_service = get_marketing_service()
contact_data = ContactData(
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
)
marketing_service.create_contact(
contact_data, timeout=settings.BREVO_API_TIMEOUT
)
except (ContactCreationError, ImproperlyConfigured, ImportError):
pass
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return User.objects.get(sub=sub)
except User.DoesNotExist:
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return User.objects.get(email__iexact=email)
except User.DoesNotExist:
pass
except User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
_("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)
+2 -8
View File
@@ -22,8 +22,6 @@ from mozilla_django_oidc.views import (
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
)
from ..analytics import analytics
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
@@ -100,10 +98,6 @@ class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
logout_url = self.redirect_url
analytics.track(
user=request.user,
event="Signed Out",
)
if request.user.is_authenticated:
logout_url = self.construct_oidc_logout_url(request)
@@ -151,7 +145,7 @@ class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
"""Custom callback view for handling the silent loging flow."""
"""Custom callback view for handling the silent login flow."""
@property
def failure_url(self):
@@ -168,7 +162,7 @@ class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
class OIDCAuthenticationRequestView(MozillaOIDCAuthenticationRequestView):
"""Custom authentication view for handling the silent loging flow."""
"""Custom authentication view for handling the silent login flow."""
def get_extra_params(self, request):
"""Handle 'prompt' extra parameter for the silent login flow
+55 -2
View File
@@ -23,6 +23,8 @@ class UserFactory(factory.django.DjangoModelFactory):
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
full_name = factory.Faker("name")
short_name = factory.Faker("first_name")
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
password = make_password("password")
@@ -32,8 +34,7 @@ class ResourceFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.Resource
is_public = factory.Faker("boolean", chance_of_getting_true=50)
skip_postgeneration_save = True
@factory.post_generation
def users(self, create, extracted, **kwargs):
@@ -45,6 +46,8 @@ class ResourceFactory(factory.django.DjangoModelFactory):
else:
UserResourceAccessFactory(resource=self, user=item[0], role=item[1])
self.save()
class UserResourceAccessFactory(factory.django.DjangoModelFactory):
"""Create fake resource user accesses for testing."""
@@ -65,3 +68,53 @@ 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):
"""Create fake recording for testing."""
class Meta:
model = models.Recording
skip_postgeneration_save = True
room = factory.SubFactory(RoomFactory)
status = models.RecordingStatusChoices.INITIATED
mode = models.RecordingModeChoices.SCREEN_RECORDING
worker_id = None
@factory.post_generation
def users(self, create, extracted, **kwargs):
"""Add users to recording from a given list of users with or without roles."""
if create and extracted:
for item in extracted:
if isinstance(item, models.User):
UserRecordingAccessFactory(recording=self, user=item)
else:
UserRecordingAccessFactory(
recording=self, user=item[0], role=item[1]
)
self.save()
class UserRecordingAccessFactory(factory.django.DjangoModelFactory):
"""Create fake recording user accesses for testing."""
class Meta:
model = models.RecordingAccess
recording = factory.SubFactory(RecordingFactory)
user = factory.SubFactory(UserFactory)
role = factory.fuzzy.FuzzyChoice(models.RoleChoices.values)
class TeamRecordingAccessFactory(factory.django.DjangoModelFactory):
"""Create fake recording team accesses for testing."""
class Meta:
model = models.RecordingAccess
recording = factory.SubFactory(RecordingFactory)
team = factory.Sequence(lambda n: f"team{n}")
role = factory.fuzzy.FuzzyChoice(models.RoleChoices.values)
@@ -0,0 +1,67 @@
# Generated by Django 5.1.1 on 2024-11-06 14:31
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0004_alter_user_language'),
]
operations = [
migrations.CreateModel(
name='Recording',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('status', models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop')], default='initiated', max_length=20)),
('worker_id', models.CharField(blank=True, help_text='Enter an identifier for the worker recording.This ID is retained even when the worker stops, allowing for easy tracking.', max_length=255, null=True, verbose_name='Worker ID')),
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recordings', to='core.room', verbose_name='Room')),
],
options={
'verbose_name': 'Recording',
'verbose_name_plural': 'Recordings',
'db_table': 'meet_recording',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='RecordingAccess',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('team', models.CharField(blank=True, max_length=100)),
('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)),
('recording', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.recording')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Recording/user relation',
'verbose_name_plural': 'Recording/user relations',
'db_table': 'meet_recording_access',
'ordering': ('-created_at',),
},
),
migrations.AddConstraint(
model_name='recording',
constraint=models.UniqueConstraint(condition=models.Q(('status__in', ['active', 'initiated'])), fields=('room',), name='unique_initiated_or_active_recording_per_room'),
),
migrations.AddConstraint(
model_name='recordingaccess',
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'recording'), name='unique_recording_user', violation_error_message='This user is already in this recording.'),
),
migrations.AddConstraint(
model_name='recordingaccess',
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'recording'), name='unique_recording_team', violation_error_message='This team is already in this recording.'),
),
migrations.AddConstraint(
model_name='recordingaccess',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_recording_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
),
]
@@ -0,0 +1,89 @@
from django.db import migrations
from django.db.models import Count
from core.models import RoleChoices
def merge_duplicate_user_accounts(apps, schema_editor):
"""Merge user accounts that share the same email address.
Historical Context:
Previously, ProConnect authentication could return users with the same email
but different sub, leading to duplicate user accounts. While the application
now prevents this scenario, this migration is needed to clean up existing
duplicate accounts to ensure users can continue to connect without being blocked
by unique email constraints.
Performance of this migration is poor, this implementation prioritizes readability
and maintainability. Consider refactoring this code to avoid individual db queries
on each iteration.
"""
User = apps.get_model('core', 'User')
ResourceAccess = apps.get_model('core', 'ResourceAccess')
emails_with_duplicates = (
User.objects.values('email')
.annotate(count=Count('id'))
.filter(count__gt=1)
.values_list('email', flat=True)
)
for email in emails_with_duplicates:
# Keep the oldest user
primary_user = User.objects.filter(email=email).order_by('created_at').first()
duplicate_user_accounts = User.objects.filter(email=email).exclude(id=primary_user.id)
# Get IDs of duplicate accounts to be merged
duplicate_account_ids = list(duplicate_user_accounts.values_list('id', flat=True))
resource_accesses_to_transfer = ResourceAccess.objects.filter(user_id__in=duplicate_account_ids)
# Transfer resource access permissions to primary user
# This process handles role hierarchy where:
# OWNER > ADMIN > MEMBER
for resource_access in resource_accesses_to_transfer:
# Determine if primary user already has access to this resource
existing_primary_access = ResourceAccess.objects.filter(
user_id=primary_user.id,
resource_id=resource_access.resource.id
).first()
if existing_primary_access:
# Skip if primary user is already OWNER as it's the highest privilege level
# No need to modify or downgrade owner access
if existing_primary_access.role == RoleChoices.OWNER:
continue
# Skip if primary user already has the exact same role
# No need to update when roles match
elif existing_primary_access.role == resource_access.role:
continue
# Skip if new role is MEMBER since user already has base access
# All existing access includes at least MEMBER privileges
elif resource_access.role == RoleChoices.MEMBER:
continue
# Update the role only if it represents a higher privilege level
# Preserves existing access record while updating the role
existing_primary_access.role = resource_access.role
existing_primary_access.save()
else:
# Transfer access to primary user
resource_access.user_id = primary_user.id
resource_access.save()
# Delete duplicate accounts - CASCADE will automatically remove any untransferred
# ResourceAccess records and other related data for these users
duplicate_user_accounts.delete()
class Migration(migrations.Migration):
dependencies = [
('core', '0005_recording_recordingaccess_and_more'),
]
operations = [
migrations.RunPython(merge_duplicate_user_accounts, reverse_code=migrations.RunPython.noop),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2024-11-12 10:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0006_merge_duplicate_users'),
]
operations = [
migrations.AddField(
model_name='recording',
name='mode',
field=models.CharField(choices=[('screen_recording', 'SCREEN_RECORDING'), ('transcript', 'TRANSCRIPT')], default='screen_recording', help_text='Defines the mode of recording being called.', max_length=20, verbose_name='Recording mode'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.1.1 on 2024-11-13 09:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0007_recording_mode'),
]
operations = [
migrations.AddField(
model_name='user',
name='full_name',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='full name'),
),
migrations.AddField(
model_name='user',
name='short_name',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='short name'),
)
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.3 on 2024-12-02 13:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0008_user_full_name_user_short_name'),
]
operations = [
migrations.AlterField(
model_name='recording',
name='status',
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded')], default='initiated', max_length=50),
),
]
@@ -0,0 +1,21 @@
# Generated by Django 5.1.4 on 2025-01-13 12:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0009_alter_recording_status'),
]
operations = [
migrations.AlterModelOptions(
name='resourceaccess',
options={'ordering': ('-created_at',), 'verbose_name': 'Resource access', 'verbose_name_plural': 'Resource accesses'},
),
migrations.AlterModelOptions(
name='user',
options={'ordering': ('-created_at',), 'verbose_name': 'user', 'verbose_name_plural': 'users'},
),
]
@@ -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),
)
]
+305 -10
View File
@@ -4,6 +4,7 @@ Declare and configure the models for the Meet core application
import uuid
from logging import getLogger
from typing import List
from django.conf import settings
from django.contrib.auth import models as auth_models
@@ -38,6 +39,55 @@ class RoleChoices(models.TextChoices):
return role == cls.OWNER
class RecordingStatusChoices(models.TextChoices):
"""Enumeration of possible states for a recording operation."""
INITIATED = "initiated", _("Initiated")
ACTIVE = "active", _("Active")
STOPPED = "stopped", _("Stopped")
SAVED = "saved", _("Saved")
ABORTED = "aborted", _("Aborted")
FAILED_TO_START = "failed_to_start", _("Failed to Start")
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
@classmethod
def is_final(cls, status):
"""Determine if the recording status represents a final state.
A final status indicates the recording flow has completed, either
successfully or unsuccessfully.
"""
return status in {
cls.STOPPED,
cls.SAVED,
cls.ABORTED,
cls.FAILED_TO_START,
cls.FAILED_TO_STOP,
}
@classmethod
def is_unsuccessful(cls, status):
"""Determine if the recording status represents an unsuccessful state."""
return status in {cls.ABORTED, cls.FAILED_TO_START, cls.FAILED_TO_STOP}
class RecordingModeChoices(models.TextChoices):
"""Recording mode choices."""
SCREEN_RECORDING = "screen_recording", _("SCREEN_RECORDING")
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
@@ -100,11 +150,14 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
email = models.EmailField(_("identity email address"), blank=True, null=True)
# Unlike the "email" field which stores the email coming from the OIDC token, this field
# stores the email used by staff users to login to the admin site
# stores the email used by staff users to log in to the admin site
admin_email = models.EmailField(
_("admin email address"), unique=True, blank=True, null=True
)
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
short_name = models.CharField(
_("short name"), max_length=100, null=True, blank=True
)
language = models.CharField(
max_length=10,
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
@@ -144,6 +197,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
class Meta:
db_table = "meet_user"
ordering = ("-created_at",)
verbose_name = _("user")
verbose_name_plural = _("users")
@@ -163,18 +217,38 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""
return []
@property
def email_anonymized(self):
"""Anonymize the email address by replacing the local part with asterisks."""
if not self.email:
return ""
return f"***@{self.email.split('@')[1]}"
def get_resource_roles(resource: models.Model, user: User) -> List[str]:
"""
Get all roles assigned to a user for a specific resource, including team-based roles.
Args:
resource: The resource to check permissions for
user: The user to get roles for
Returns:
List of role strings assigned to the user
"""
if not user.is_authenticated:
return []
# Use pre-annotated roles if available from viewset optimization
if hasattr(resource, "user_roles"):
return resource.user_roles or []
try:
return list(
resource.accesses.filter_user(user)
.values_list("role", flat=True)
.distinct()
)
except (IndexError, models.ObjectDoesNotExist):
return []
class Resource(BaseModel):
"""Model to define access control"""
is_public = models.BooleanField(default=settings.DEFAULT_ROOM_IS_PUBLIC)
users = models.ManyToManyField(
User,
through="ResourceAccess",
@@ -238,6 +312,7 @@ class ResourceAccess(BaseModel):
class Meta:
db_table = "meet_resource_access"
ordering = ("-created_at",)
verbose_name = _("Resource access")
verbose_name_plural = _("Resource accesses")
constraints = [
@@ -293,7 +368,11 @@ 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,
@@ -325,3 +404,219 @@ class Room(Resource):
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
class BaseAccessManager(models.Manager):
"""Base manager for handling resource access control."""
def filter_user(self, user):
"""Filter accesses for a given user, including both direct and team-based access."""
return self.filter(models.Q(user=user) | models.Q(team__in=user.get_teams()))
class BaseAccess(BaseModel):
"""Base model for accesses to handle resources."""
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
null=True,
blank=True,
)
team = models.CharField(max_length=100, blank=True)
role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
)
objects = BaseAccessManager()
class Meta:
abstract = True
def _get_abilities(self, resource, user):
"""
Compute and return abilities for a given user taking into account
the current state of the object.
"""
roles = get_resource_roles(resource, user)
is_owner = RoleChoices.OWNER in roles
has_privileges = is_owner or RoleChoices.ADMIN in roles
# Default values for unprivileged users
set_role_to = set()
can_delete = False
# Special handling when modifying an owner's access
if self.role == RoleChoices.OWNER:
# Prevent orphaning the resource
can_delete = (
is_owner
and resource.accesses.filter(role=RoleChoices.OWNER).count() > 1
)
if can_delete:
set_role_to = {RoleChoices.ADMIN, RoleChoices.OWNER, RoleChoices.MEMBER}
elif has_privileges:
can_delete = True
set_role_to = {RoleChoices.ADMIN, RoleChoices.MEMBER}
if is_owner:
set_role_to.add(RoleChoices.OWNER)
# Remove the current role as we don't want to propose it as an option
set_role_to.discard(self.role)
return {
"destroy": can_delete,
"update": bool(set_role_to),
"partial_update": bool(set_role_to),
"retrieve": bool(roles),
"set_role_to": sorted(r.value for r in set_role_to),
}
class Recording(BaseModel):
"""Model for recordings that take place in a room.
Recording Status Flow:
1. INITIATED: Initial state when recording is requested
2. ACTIVE: Recording is currently in progress
3. STOPPED: Recording has been stopped by user/system
4. SAVED: Recording has been successfully processed and stored
4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording
Error States:
- FAILED_TO_START: Worker failed to initialize recording
- FAILED_TO_STOP: Worker failed during stop operation
- ABORTED: Recording was terminated before completion
Warning: Worker failures may lead to database inconsistency between the actual
recording state and its status in the database.
"""
room = models.ForeignKey(
Room,
on_delete=models.CASCADE,
related_name="recordings",
verbose_name=_("Room"),
)
status = models.CharField(
max_length=50,
choices=RecordingStatusChoices.choices,
default=RecordingStatusChoices.INITIATED,
)
worker_id = models.CharField(
max_length=255,
null=True,
blank=True,
verbose_name=_("Worker ID"),
help_text=_(
"Enter an identifier for the worker recording."
"This ID is retained even when the worker stops, allowing for easy tracking."
),
)
mode = models.CharField(
max_length=20,
choices=RecordingModeChoices.choices,
default=RecordingModeChoices.SCREEN_RECORDING,
verbose_name=_("Recording mode"),
help_text=_("Defines the mode of recording being called."),
)
class Meta:
db_table = "meet_recording"
ordering = ("-created_at",)
verbose_name = _("Recording")
verbose_name_plural = _("Recordings")
constraints = [
models.UniqueConstraint(
fields=["room"],
condition=models.Q(
status__in=[
RecordingStatusChoices.ACTIVE,
RecordingStatusChoices.INITIATED,
]
),
name="unique_initiated_or_active_recording_per_room",
)
]
def __str__(self):
return f"Recording {self.id} ({self.status})"
def get_abilities(self, user):
"""Compute and return abilities for a given user on the recording."""
roles = set(get_resource_roles(self, user))
is_owner_or_admin = bool(
roles.intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
is_final_status = RecordingStatusChoices.is_final(self.status)
return {
"destroy": is_owner_or_admin and is_final_status,
"partial_update": False,
"retrieve": is_owner_or_admin,
"stop": is_owner_or_admin and not is_final_status,
"update": False,
}
def is_savable(self) -> bool:
"""Determine if the recording can be saved based on its current status."""
return self.status in {
RecordingStatusChoices.ACTIVE,
RecordingStatusChoices.STOPPED,
}
class RecordingAccess(BaseAccess):
"""Relation model to give access to a recording for a user or a team with a role."""
recording = models.ForeignKey(
Recording,
on_delete=models.CASCADE,
related_name="accesses",
)
class Meta:
db_table = "meet_recording_access"
ordering = ("-created_at",)
verbose_name = _("Recording/user relation")
verbose_name_plural = _("Recording/user relations")
constraints = [
models.UniqueConstraint(
fields=["user", "recording"],
condition=models.Q(user__isnull=False), # Exclude null users
name="unique_recording_user",
violation_error_message=_("This user is already in this recording."),
),
models.UniqueConstraint(
fields=["team", "recording"],
condition=models.Q(team__gt=""), # Exclude empty string teams
name="unique_recording_team",
violation_error_message=_("This team is already in this recording."),
),
models.CheckConstraint(
condition=models.Q(user__isnull=False, team="")
| models.Q(user__isnull=True, team__gt=""),
name="check_recording_access_either_user_or_team",
violation_error_message=_("Either user or team must be set, not both."),
),
]
def __str__(self):
return f"{self.user!s} is {self.role:s} in {self.recording!s}"
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the recording access.
"""
return self._get_abilities(self.recording, user)
@@ -0,0 +1 @@
"""Meet event parser classes, authentication and exceptions."""
@@ -0,0 +1,94 @@
"""Authentication class for storage event token validation."""
import logging
import secrets
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
logger = logging.getLogger(__name__)
class MachineUser:
"""Represent a non-interactive system user for automated storage operations."""
def __init__(self) -> None:
self.pk = None
self.username = "storage_event_user"
self.is_active = True
@property
def is_authenticated(self):
"""Indicate if this machine user is authenticated."""
return True
@property
def is_anonymous(self) -> bool:
"""Indicate if this is an anonymous user."""
return False
def get_username(self) -> str:
"""Return the machine user identifier."""
return self.username
class StorageEventAuthentication(BaseAuthentication):
"""Authenticate requests using a Bearer token for storage event integration.
This class validates Bearer tokens for storage events that don't map to database users.
It's designed for S3-compatible storage integrations and similar use cases.
Events are submitted when a webhook is configured on some bucket's events.
"""
AUTH_HEADER = "Authorization"
TOKEN_TYPE = "Bearer" # noqa S105
def authenticate(self, request):
"""Validate the Bearer token from the Authorization header."""
if not settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
return MachineUser(), None
required_token = settings.RECORDING_STORAGE_EVENT_TOKEN
if not required_token:
if settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
raise AuthenticationFailed(
_("Authentication is enabled but token is not configured.")
)
return MachineUser(), None
auth_header = request.headers.get(self.AUTH_HEADER)
if not auth_header:
logger.warning(
"Authentication failed: Missing Authorization header (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed(_("Authorization header is required"))
auth_parts = auth_header.split(" ")
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
logger.warning(
"Authentication failed: Invalid authorization header (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed(_("Invalid authorization header."))
token = auth_parts[1]
# Use constant-time comparison to prevent timing attacks
if not secrets.compare_digest(token.encode(), required_token.encode()):
logger.warning(
"Authentication failed: Invalid token (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed(_("Invalid token"))
return MachineUser(), token
def authenticate_header(self, request):
"""Return the WWW-Authenticate header value."""
return f"{self.TOKEN_TYPE} realm='Storage event API'"
@@ -0,0 +1,17 @@
"""Storage parsers specific exceptions."""
class ParsingEventDataError(Exception):
"""Raised when the request data is malformed, incomplete, or missing."""
class InvalidBucketError(Exception):
"""Raised when the bucket name in the request does not match the expected one."""
class InvalidFileTypeError(Exception):
"""Raised when the file type in the request is not supported."""
class InvalidFilepathError(Exception):
"""Raised when the filepath in the request is invalid."""
@@ -0,0 +1,93 @@
"""Service to notify external services when a new recording is ready."""
import logging
from django.conf import settings
import requests
from core import models
logger = logging.getLogger(__name__)
class NotificationService:
"""Service for processing recordings and notifying external services."""
def notify_external_services(self, recording):
"""Process a recording based on its mode."""
if recording.mode == models.RecordingModeChoices.TRANSCRIPT:
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
logger.error(
"Unknown recording mode %s for recording %s",
recording.mode,
recording.id,
)
return False
@staticmethod
def _notify_summary_service(recording):
"""Notify summary service about a new recording."""
if (
not settings.SUMMARY_SERVICE_ENDPOINT
or not settings.SUMMARY_SERVICE_API_TOKEN
):
logger.error("Summary service not configured")
return False
owner_access = (
models.RecordingAccess.objects.select_related("user")
.filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
)
.first()
)
if not owner_access:
logger.error("No owner found for recording %s", recording.id)
return False
key = f"{settings.RECORDING_OUTPUT_FOLDER}/{recording.id}.ogg"
payload = {
"filename": key,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {settings.SUMMARY_SERVICE_API_TOKEN}",
}
try:
response = requests.post(
settings.SUMMARY_SERVICE_ENDPOINT,
json=payload,
headers=headers,
timeout=30,
)
response.raise_for_status()
except requests.HTTPError as exc:
logger.exception(
"Summary service HTTP error for recording %s. URL: %s. Exception: %s",
recording.id,
settings.SUMMARY_SERVICE_ENDPOINT,
exc,
)
return False
return True
notification_service = NotificationService()
+147
View File
@@ -0,0 +1,147 @@
"""Meet storage event parser classes."""
import logging
import re
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Dict, Optional, Protocol
from django.conf import settings
from django.utils.module_loading import import_string
from .exceptions import (
InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError,
ParsingEventDataError,
)
logger = logging.getLogger(__name__)
@dataclass
class StorageEvent:
"""Represents a storage event with relevant metadata.
Attributes:
filepath: Identifier for the affected recording
filetype: Type of storage event
bucket_name: When the event occurred
metadata: Additional event data
"""
filepath: str
filetype: str
bucket_name: str
metadata: Optional[Dict[str, Any]]
def __post_init__(self):
if self.filepath is None:
raise TypeError("filepath cannot be None")
if self.filetype is None:
raise TypeError("filetype cannot be None")
if self.bucket_name is None:
raise TypeError("bucket_name cannot be None")
class EventParser(Protocol):
"""Interface for parsing storage events."""
def __init__(self, bucket_name, allowed_filetypes=None):
"""Initialize parser with bucket name and optional allowed filetypes."""
def parse(self, data: Dict) -> StorageEvent:
"""Extract storage event data from raw dictionary input."""
def validate(self, data: StorageEvent) -> None:
"""Verify storage event data meets all requirements."""
def get_recording_id(self, data: Dict) -> str:
"""Extract recording ID from event dictionary."""
@lru_cache(maxsize=1)
def get_parser() -> EventParser:
"""Return cached instance of configured event parser.
Uses function memoization instead of a factory class since the only
varying parameter is the parser class from settings. A factory class
would add unnecessary complexity when a cached function provides the
same singleton behavior with simpler code.
"""
event_parser_cls = import_string(settings.RECORDING_EVENT_PARSER_CLASS)
return event_parser_cls(bucket_name=settings.AWS_STORAGE_BUCKET_NAME)
class MinioParser:
"""Handle parsing and validation of Minio storage events."""
def __init__(self, bucket_name: str, allowed_filetypes=None):
"""Initialize parser with target bucket name and accepted filetypes."""
if not bucket_name:
raise ValueError("Bucket name cannot be None or empty")
self._bucket_name = bucket_name
self._allowed_filetypes = allowed_filetypes or {"audio/ogg", "video/mp4"}
# pylint: disable=line-too-long
self._filepath_regex = re.compile(
r"(?P<url_encoded_folder_path>(?:[^%]+%2F)*)?(?P<recording_id>[0-9a-fA-F\-]{36})\.(?P<extension>[a-zA-Z0-9]+)"
)
@staticmethod
def parse(data):
"""Convert raw Minio event dictionary to StorageEvent object."""
if not data:
raise ParsingEventDataError("Received empty data.")
try:
record = data["Records"][0]
s3 = record["s3"]
bucket_name = s3["bucket"]["name"]
file_object = s3["object"]
filepath = file_object["key"]
filetype = file_object["contentType"]
except (KeyError, IndexError) as e:
raise ParsingEventDataError(f"Missing or malformed key: {e}.") from e
try:
return StorageEvent(
filepath=filepath,
filetype=filetype,
bucket_name=bucket_name,
metadata=None,
)
except TypeError as e:
raise ParsingEventDataError(f"Missing essential data fields: {e}") from e
def validate(self, event_data: StorageEvent) -> str:
"""Verify StorageEvent matches bucket, filetype and filepath requirements."""
if event_data.bucket_name != self._bucket_name:
raise InvalidBucketError(
f"Invalid bucket: expected {self._bucket_name}, got {event_data.bucket_name}"
)
if not event_data.filetype in self._allowed_filetypes:
raise InvalidFileTypeError(
f"Invalid file type, expected {self._allowed_filetypes},"
f"got '{event_data.filetype}'"
)
match = self._filepath_regex.match(event_data.filepath)
if not match:
raise InvalidFilepathError(
f"Invalid filepath structure: {event_data.filepath}"
)
recording_id = match.group("recording_id")
return recording_id
def get_recording_id(self, data):
"""Extract recording ID from Minio event through parsing and validation."""
event_data = self.parse(data)
recording_id = self.validate(event_data)
return recording_id
@@ -0,0 +1 @@
"""Meet worker services classes and exceptions."""
@@ -0,0 +1,21 @@
"""Recording and worker services specific exceptions."""
class WorkerRequestError(Exception):
"""Raised when there is an issue with the worker request"""
class WorkerConnectionError(Exception):
"""Raised when there is an issue connecting to the worker."""
class WorkerResponseError(Exception):
"""Raised when the worker's response is not as expected."""
class RecordingStartError(Exception):
"""Raised when there is an error starting the recording."""
class RecordingStopError(Exception):
"""Raised when there is an error stopping the recording."""
@@ -0,0 +1,75 @@
"""Factory, configurations and Protocol to create worker services"""
import logging
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, ClassVar, Dict, Optional, Protocol, Type
from django.conf import settings
from django.utils.module_loading import import_string
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class WorkerServiceConfig:
"""Declare Worker Service common configurations"""
output_folder: str
server_configurations: Dict[str, Any]
verify_ssl: Optional[bool]
bucket_args: Optional[dict]
@classmethod
@lru_cache
def from_settings(cls) -> "WorkerServiceConfig":
"""Load configuration from Django settings with caching for efficiency."""
logger.debug("Loading WorkerServiceConfig from settings.")
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,
"secret": settings.AWS_S3_SECRET_ACCESS_KEY,
"region": settings.AWS_S3_REGION_NAME,
"bucket": settings.AWS_STORAGE_BUCKET_NAME,
"force_path_style": True,
},
)
class WorkerService(Protocol):
"""Define the interface for interacting with a worker service."""
hrid: ClassVar[str]
def __init__(self, config: WorkerServiceConfig):
"""Initialize the service with the given configuration."""
def start(self, room_id: str, recording_id: str) -> str:
"""Start a recording for a specified room."""
def stop(self, worker_id: str) -> str:
"""Stop recording for a specified worker."""
def get_worker_service(mode: str) -> WorkerService:
"""Instantiate a worker service by its mode."""
worker_registry: Dict[str, str] = settings.RECORDING_WORKER_CLASSES
try:
worker_class_path = worker_registry[mode]
except KeyError as e:
raise ValueError(
f"Recording mode '{mode}' not found in RECORDING_WORKER_CLASSES. "
f"Available modes: {list(worker_registry.keys())}"
) from e
worker_class: Type[WorkerService] = import_string(worker_class_path)
config = WorkerServiceConfig.from_settings()
return worker_class(config=config)
@@ -0,0 +1,98 @@
"""Mediator between the worker service and recording instances in the Django ORM."""
import logging
from core.models import Recording, RecordingStatusChoices
from .exceptions import (
RecordingStartError,
RecordingStopError,
WorkerConnectionError,
WorkerRequestError,
WorkerResponseError,
)
from .factories import WorkerService
logger = logging.getLogger(__name__)
class WorkerServiceMediator:
"""Mediate interactions between a worker service and a recording instance.
A mediator class that decouples the worker from Django ORM, handles recording updates
based on worker status, and transforms worker errors into user-friendly exceptions.
Implements Mediator pattern.
"""
def __init__(self, worker_service: WorkerService):
"""Initialize the WorkerServiceMediator with the provided worker service."""
self._worker_service = worker_service
def start(self, recording: Recording):
"""Start the recording process using the worker service.
If the operation is successful, the recording's status will
transition from INITIATED to ACTIVE, else to FAILED_TO_START to keep track of errors.
Args:
recording (Recording): The recording instance to start.
Raises:
RecordingStartError: If there is an error starting the recording.
"""
if recording.status != RecordingStatusChoices.INITIATED:
logger.error("Cannot start recording in %s status.", recording.status)
raise RecordingStartError()
room_name = str(recording.room.id)
try:
worker_id = self._worker_service.start(room_name, recording.id)
except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e:
logger.exception(
"Failed to start recording for room %s: %s", recording.room.slug, e
)
recording.status = RecordingStatusChoices.FAILED_TO_START
raise RecordingStartError() from e
else:
recording.worker_id = worker_id
recording.status = RecordingStatusChoices.ACTIVE
finally:
recording.save()
logger.info(
"Worker started for room %s (worker ID: %s)",
recording.room,
recording.worker_id,
)
def stop(self, recording: Recording):
"""Stop the recording process using the worker service.
If the operation is successful, the recording's status will transition
from ACTIVE to STOPPED, else to FAILED_TO_STOP to keep track of errors.
Args:
recording (Recording): The recording instance to stop.
Raises:
RecordingStopError: If there is an error stopping the recording.
"""
if recording.status != RecordingStatusChoices.ACTIVE:
logger.error("Cannot stop recording in %s status.", recording.status)
raise RecordingStopError()
try:
response = self._worker_service.stop(worker_id=recording.worker_id)
except (WorkerConnectionError, WorkerResponseError) as e:
logger.exception(
"Failed to stop recording for room %s: %s", recording.room.slug, e
)
recording.status = RecordingStatusChoices.FAILED_TO_STOP
raise RecordingStopError() from e
else:
recording.status = RecordingStatusChoices[response]
finally:
recording.save()
logger.info("Worker stopped for room %s", recording.room)
@@ -0,0 +1,140 @@
"""Worker services in charge of recording a room."""
# 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 .exceptions import WorkerConnectionError, WorkerResponseError
from .factories import WorkerServiceConfig
class BaseEgressService:
"""Base egress defining common methods to manage and interact with LiveKit egress processes."""
def __init__(self, config: WorkerServiceConfig):
self._config = config
self._s3 = livekit_api.S3Upload(**config.bucket_args)
def _get_filepath(self, filename: str, extension: str) -> str:
"""Construct the file path for a given filename and extension.
Unsecure method, doesn't handle paths robustly and securely.
"""
return f"{self._config.output_folder}/{filename}.{extension}"
@async_to_sync
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)
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
return response
def stop(self, worker_id: str) -> str:
"""Stop an ongoing egress worker.
The StopEgressRequest is shared among all types of egress,
so a single implementation in the base class should be sufficient.
"""
request = livekit_api.StopEgressRequest(
egress_id=worker_id,
)
response = self._handle_request(request, "stop_egress")
if not response.status:
raise WorkerResponseError(
"LiveKit response is missing the recording status."
)
# To avoid exposing EgressStatus values and coupling with LiveKit outside of this class,
# the response status is mapped to simpler "ABORTED", "STOPPED" or "FAILED_TO_STOP" strings.
if response.status == livekit_api.EgressStatus.EGRESS_ABORTED:
return "ABORTED"
if response.status == livekit_api.EgressStatus.EGRESS_ENDING:
return "STOPPED"
return "FAILED_TO_STOP"
def start(self, room_name, recording_id):
"""Start the egress process for a recording (not implemented in the base class).
Each derived class must implement this method, providing the necessary parameters for
its specific egress type (e.g. audio_only, streaming output).
"""
raise NotImplementedError("Subclass must implement this method.")
class VideoCompositeEgressService(BaseEgressService):
"""Record multiple participant video and audio tracks into a single output '.mp4' file."""
hrid = "video-recording-composite-livekit-egress"
def start(self, room_name, recording_id):
"""Start the video composite egress process for a recording."""
# Save room's recording as a mp4 video file.
file_type = livekit_api.EncodedFileType.MP4
filepath = self._get_filepath(filename=recording_id, extension="mp4")
file_output = livekit_api.EncodedFileOutput(
file_type=file_type,
filepath=filepath,
s3=self._s3,
)
request = livekit_api.RoomCompositeEgressRequest(
room_name=room_name,
file_outputs=[file_output],
)
response = self._handle_request(request, "start_room_composite_egress")
if not response.egress_id:
raise WorkerResponseError("Egress ID not found in the response.")
return response.egress_id
class AudioCompositeEgressService(BaseEgressService):
"""Record multiple participant audio tracks into a single output '.ogg' file."""
hrid = "audio-recording-composite-livekit-egress"
def start(self, room_name, recording_id):
"""Start the audio composite egress process for a recording."""
# Save room's recording as an ogg audio file.
file_type = livekit_api.EncodedFileType.OGG
filepath = self._get_filepath(filename=recording_id, extension="ogg")
file_output = livekit_api.EncodedFileOutput(
file_type=file_type,
filepath=filepath,
s3=self._s3,
)
request = livekit_api.RoomCompositeEgressRequest(
room_name=room_name, file_outputs=[file_output], audio_only=True
)
response = self._handle_request(request, "start_room_composite_egress")
if not response.egress_id:
raise WorkerResponseError("Egress ID not found in the response.")
return response.egress_id
+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
+385
View File
@@ -0,0 +1,385 @@
"""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,
LiveKitAPI,
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 = LiveKitAPI(**settings.LIVEKIT_CONFIGURATION)
try:
room_response = await lkapi.room.list_rooms(
ListRoomsRequest(
names=[str(room_id)],
)
)
# Check if the room exists
if not room_response.rooms:
return
await lkapi.room.send_data(
SendDataRequest(
room=str(room_id),
data=json.dumps(notification_data).encode("utf-8"),
kind="RELIABLE",
)
)
except TwirpError as e:
logger.exception("Failed to notify room participants")
raise LobbyNotificationError("Failed to notify room participants") from e
finally:
await lkapi.aclose()
def clear_room_cache(self, room_id: UUID) -> None:
"""Clear all participant entries from the cache for a specific room."""
pattern = self._get_cache_key(room_id, "*")
keys = cache.keys(pattern)
if not keys:
return
cache.delete_many(keys)
+134
View File
@@ -0,0 +1,134 @@
"""Marketing service in charge of pushing data for marketing automation."""
import logging
from dataclasses import dataclass
from functools import lru_cache
from typing import Dict, List, Optional, Protocol
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
import brevo_python
logger = logging.getLogger(__name__)
class ContactCreationError(Exception):
"""Raised when the contact creation fails."""
@dataclass
class ContactData:
"""Contact data for marketing service integration."""
email: str
attributes: Optional[Dict[str, str]] = None
list_ids: Optional[List[int]] = None
update_enabled: bool = True
class MarketingServiceProtocol(Protocol):
"""Interface for marketing automation service integrations."""
def create_contact(
self, contact_data: ContactData, timeout: Optional[int] = None
) -> dict:
"""Create or update a contact.
Args:
contact_data: Contact information and attributes
timeout: API request timeout in seconds
Returns:
dict: Service response
Raises:
ContactCreationError: If contact creation fails
"""
class BrevoMarketingService:
"""Brevo marketing automation integration.
Handles:
- Contact management and segmentation
- Marketing campaigns and automation
- Email communications
Configuration via Django settings:
- BREVO_API_KEY: API authentication
- BREVO_API_CONTACT_LIST_IDS: Default contact lists
- BREVO_API_CONTACT_ATTRIBUTES: Default contact attributes
"""
def __init__(self):
"""Initialize Brevo (ex-sendinblue) marketing service."""
if not settings.BREVO_API_KEY:
raise ImproperlyConfigured("Brevo API key is required")
configuration = brevo_python.Configuration()
configuration.api_key["api-key"] = settings.BREVO_API_KEY
self._api_client = brevo_python.ApiClient(configuration)
def create_contact(self, contact_data: ContactData, timeout=None) -> dict:
"""Create or update a Brevo contact.
Args:
contact_data: Contact information and attributes
timeout: API request timeout in seconds
Returns:
dict: Brevo API response
Raises:
ContactCreationError: If contact creation fails
ImproperlyConfigured: If required settings are missing
Note:
Contact attributes must be pre-configured in Brevo.
Changes to attributes can impact existing workflows.
"""
if not settings.BREVO_API_CONTACT_LIST_IDS:
raise ImproperlyConfigured(
"Default Brevo List IDs must be configured in settings."
)
contact_api = brevo_python.ContactsApi(self._api_client)
attributes = {
**settings.BREVO_API_CONTACT_ATTRIBUTES,
**(contact_data.attributes or {}),
}
list_ids = (contact_data.list_ids or []) + settings.BREVO_API_CONTACT_LIST_IDS
contact = brevo_python.CreateContact(
email=contact_data.email,
attributes=attributes,
list_ids=list_ids,
update_enabled=contact_data.update_enabled,
)
api_configurations = {}
if timeout is not None:
api_configurations["_request_timeout"] = timeout
try:
response = contact_api.create_contact(contact, **api_configurations)
except brevo_python.rest.ApiException as err:
logger.exception("Failed to create contact in Brevo")
raise ContactCreationError("Failed to create contact in Brevo") from err
return response
@lru_cache(maxsize=1)
def get_marketing_service() -> MarketingServiceProtocol:
"""Return cached instance of configured marketing service."""
marketing_service_cls = import_string(settings.MARKETING_SERVICE_CLASS)
return marketing_service_cls()
@@ -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>
@@ -1,42 +1,48 @@
"""Unit tests for the Authentication Backends."""
from django.core.exceptions import SuspiciousOperation
from unittest import mock
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
import pytest
from core import models
from core.authentication.backends import OIDCAuthenticationBackend
from core.factories import UserFactory
from core.services import marketing
pytestmark = pytest.mark.django_db
def test_authentication_getter_existing_user_no_email(
django_assert_num_queries, monkeypatch
):
def test_authentication_getter_existing_user(monkeypatch):
"""
If an existing user matches the user's info sub, the user should be returned.
If an existing user matches, the user should be returned.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory()
db_user = UserFactory(email="foo@mail.com")
def get_userinfo_mocked(*args):
return {"sub": db_user.sub}
return {"sub": db_user.sub, "email": "some@mail.com"}
def get_existing_user(*args):
return db_user
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
monkeypatch.setattr(
OIDCAuthenticationBackend, "get_existing_user", get_existing_user
)
with django_assert_num_queries(1):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
def test_authentication_getter_new_user_no_email(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
If no user matches, a user should be created.
User's info doesn't contain an email, created user's email should be empty.
"""
klass = OIDCAuthenticationBackend()
@@ -58,16 +64,15 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
def test_authentication_getter_new_user_with_email(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
User's email and name should be set on the identity.
The "email" field on the User model should not be set as it is reserved for staff users.
If no user matches, a user should be created.
User's info contains an email, created user's email should be filled.
"""
klass = OIDCAuthenticationBackend()
email = "meet@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
return {"sub": "123", "email": email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
@@ -77,6 +82,34 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert user.sub == "123"
assert user.email == email
assert user.full_name is None
assert user.short_name is None
assert user.password == "!"
assert models.User.objects.count() == 1
@pytest.mark.parametrize("email", [None, "johndoe@foo.com"])
def test_authentication_getter_new_user_with_names(monkeypatch, email):
"""
If no user matches, a user should be created.
User's info contains name-related field, created user's full and short names should be filled,
whether the email is filled
"""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {"sub": "123", "given_name": "John", "usual_name": "Doe", "email": email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.sub == "123"
assert user.email == email
assert user.full_name == "John Doe"
assert user.short_name == "John"
assert user.password == "!"
assert models.User.objects.count() == 1
@@ -102,3 +135,419 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
def test_models_oidc_user_getter_empty_sub(django_assert_num_queries, monkeypatch):
"""The user's info contains a sub, but it's an empty string."""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {"test": "123", "sub": ""}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(0),
pytest.raises(
SuspiciousOperation,
match="User info contained no recognizable user identification",
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
def test_authentication_get_inactive_user(monkeypatch):
"""Test an exception is raised when attempting to authenticate inactive user."""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(is_active=False)
def get_userinfo_mocked(*args):
return {"sub": db_user.sub}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
pytest.raises(
SuspiciousOperation,
match="User account is disabled",
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
def test_finds_user_by_sub(django_assert_num_queries):
"""Should return user when found by sub, and email is matching."""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email="foo@mail.com")
with django_assert_num_queries(1):
user = klass.get_existing_user(db_user.sub, db_user.email)
assert user == db_user
def test_finds_user_when_email_fallback_disabled(django_assert_num_queries, settings):
"""Should not return a user when not found by sub, and email fallback is disabled."""
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email="foo@mail.com")
with django_assert_num_queries(1):
user = klass.get_existing_user("wrong-sub", db_user.email)
assert user is None
def test_finds_user_when_email_is_none(django_assert_num_queries, settings):
"""Should not return a user when not found by sub, and email is empty."""
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
klass = OIDCAuthenticationBackend()
UserFactory(email="foo@mail.com")
empty_email = ""
with django_assert_num_queries(1):
user = klass.get_existing_user("wrong-sub", empty_email)
assert user is None
def test_finds_user_by_email(django_assert_num_queries, settings):
"""Should return user when found by email, and sub is not matching."""
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email="foo@mail.com")
with django_assert_num_queries(2):
user = klass.get_existing_user("wrong-sub", db_user.email)
assert user == db_user
def test_finds_user_case_insensitive_email(django_assert_num_queries, settings):
"""Should match email case-insensitively when falling back to email."""
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email="foo@mail.com")
with django_assert_num_queries(2):
user = klass.get_existing_user("wrong-sub", "FOO@MAIL.COM")
assert user == db_user
def test_finds_user_multiple_users_same_email(django_assert_num_queries, settings):
"""Should handle multiple users with same email appropriately."""
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
klass = OIDCAuthenticationBackend()
email = "foo@mail.com"
UserFactory(email=email)
UserFactory(email=email) # Second user with same email
with (
django_assert_num_queries(2),
pytest.raises(
SuspiciousOperation,
match="Multiple user accounts share a common email.",
),
):
klass.get_existing_user("wrong-sub", email)
def test_finds_user_whitespace_email(django_assert_num_queries, settings):
"""Should not match emails with whitespace."""
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_CREATE_USER = False
klass = OIDCAuthenticationBackend()
UserFactory(email="foo@mail.com")
with django_assert_num_queries(2):
user = klass.get_existing_user("wrong-sub", " foo@mail.com ")
assert user is None
@pytest.mark.parametrize(
"email",
[
"john.doe@xample.com", # Fullwidth character in domain
"john.doe@еxample.com", # Cyrillic 'е' in domain
"JOHN.DOe@exam𝔭le.com", # Mixed Gothic '𝔭' in domain
"john.doe@exаmple.com", # Cyrillic 'а' (a) in domain
"john.doe@e𝓧𝓪𝓶𝓹𝓵𝓮.com", # Mixed fullwidth and cursive in domain
],
)
def test_authentication_getter_existing_user_email_tricky(email, monkeypatch, settings):
"""Test email matching security against visually similar but non-ASCII domains.
Validates that emails with Unicode characters that visually resemble ASCII
(homoglyphs) are treated as distinct from their ASCII counterparts for security,
per RFC compliance requirements for hostnames.
"""
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email="john.doe@example.com")
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user != db_user
@pytest.mark.parametrize(
"given_name, usual_name, email",
[
("Jack", "Doe", "john.doe@example.com"),
("John", "Duy", "john.doe@example.com"),
("John", "Doe", "jack.duy@example.com"),
("Jack", "Duy", "jack.duy@example.com"),
],
)
def test_authentication_getter_existing_user_change_fields(
given_name, usual_name, email, django_assert_num_queries, monkeypatch
):
"""It should update the email or name fields on the user when they change."""
klass = OIDCAuthenticationBackend()
user = UserFactory(
full_name="John Doe", short_name="John", email="john.doe@example.com"
)
def get_userinfo_mocked(*args):
return {
"sub": user.sub,
"email": email,
"given_name": given_name,
"usual_name": usual_name,
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(2):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
user.refresh_from_db()
assert user.email == email
assert user.full_name == f"{given_name:s} {usual_name:s}"
assert user.short_name == given_name
@pytest.mark.parametrize(
"user_info, expected_name",
[
({"given_name": "John", "family_name": "Doe"}, "John Doe"),
(
{"given_name": "John", "middle_name": "M", "family_name": "Doe"},
"John M Doe",
),
({"family_name": "Doe"}, "Doe"),
({"given_name": "", "family_name": ""}, None),
({}, None),
],
)
def test_compute_full_name(user_info, expected_name, settings):
"""Test full name computation from OIDC user info fields."""
settings.OIDC_USERINFO_FULLNAME_FIELDS = [
"given_name",
"middle_name",
"family_name",
]
klass = OIDCAuthenticationBackend()
assert klass.compute_full_name(user_info) == expected_name
def test_compute_full_name_no_fields(settings):
"""Test full name computation with empty field configuration."""
settings.OIDC_USERINFO_FULLNAME_FIELDS = []
klass = OIDCAuthenticationBackend()
assert klass.compute_full_name({"given_name": "John"}) is None
@pytest.mark.parametrize(
"claims",
[
{"email": "john.doe@example.com"}, # Same data - no change needed
{"email": ""}, # Empty strings should not override
{"non_related_field": "foo"}, # Unrelated fields should be ignored
{}, # Empty claims should not affect user
{"email": None}, # None values should be ignored
],
)
def test_update_user_when_no_update_needed(django_assert_num_queries, claims):
"""Test that user attributes remain unchanged when claims don't require updates."""
user = UserFactory(
full_name="John Doe", short_name="John", email="john.doe@example.com"
)
klass = OIDCAuthenticationBackend()
with django_assert_num_queries(0):
klass.update_user_if_needed(user, claims)
user.refresh_from_db()
assert user.email == "john.doe@example.com"
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
def test_marketing_signup_new_user_enabled(mock_signup, monkeypatch, settings):
"""Test marketing signup for new user with settings enabled."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
klass = OIDCAuthenticationBackend()
email = "test@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user("test-token", None, None)
assert user.email == email
mock_signup.assert_called_once_with(email)
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
def test_marketing_signup_new_user_disabled(mock_signup, monkeypatch, settings):
"""Test no marketing signup for new user with settings disabled."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = False
klass = OIDCAuthenticationBackend()
email = "test@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user("test-token", None, None)
assert user.email == email
mock_signup.assert_not_called()
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
def test_marketing_signup_new_user_default_disabled(mock_signup, monkeypatch):
"""Test no marketing signup for new user with settings by default disabled."""
klass = OIDCAuthenticationBackend()
email = "test@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user("test-token", None, None)
assert user.email == email
mock_signup.assert_not_called()
@pytest.mark.parametrize(
"is_signup_enabled",
[True, False],
)
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
def test_marketing_signup_existing_user(
mock_signup, monkeypatch, settings, is_signup_enabled
):
"""Test no marketing signup for existing user regardless of settings."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = is_signup_enabled
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email="test@example.com")
def get_userinfo_mocked(*args):
return {"sub": db_user.sub, "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user("test-token", None, None)
assert user == db_user
mock_signup.assert_not_called()
@mock.patch("core.authentication.backends.get_marketing_service")
def test_signup_to_marketing_email_success(mock_marketing):
"""Test successful marketing signup."""
email = "test@example.com"
# Call the method
OIDCAuthenticationBackend.signup_to_marketing_email(email)
# Verify service interaction
mock_service = mock_marketing.return_value
mock_service.create_contact.assert_called_once()
@pytest.mark.parametrize(
"error",
[
ImportError,
ImproperlyConfigured,
],
)
@mock.patch("core.authentication.backends.get_marketing_service")
def test_marketing_signup_handles_service_initialization_errors(
mock_marketing, error, settings
):
"""Tests errors that occur when trying to get/initialize the marketing service."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
mock_marketing.side_effect = error
# Should not raise any exception
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
@pytest.mark.parametrize(
"error",
[
marketing.ContactCreationError,
ImproperlyConfigured,
ImportError,
],
)
@mock.patch("core.authentication.backends.get_marketing_service")
def test_marketing_signup_handles_contact_creation_errors(
mock_marketing, error, settings
):
"""Tests errors that occur during the contact creation process."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
mock_marketing.return_value.create_contact.side_effect = error
# Should not raise any exception
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
@@ -0,0 +1,145 @@
"""
Test event authentication.
"""
# pylint: disable=E1128
from django.test import RequestFactory
import pytest
from rest_framework.exceptions import AuthenticationFailed
from core.recording.event.authentication import (
MachineUser,
StorageEventAuthentication,
)
def test_successful_authentication(settings):
"""Test successful authentication with valid token."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/")
request.headers = {"Authorization": "Bearer valid-test-token"}
user, token = StorageEventAuthentication().authenticate(request)
assert token == "valid-test-token"
assert isinstance(user, MachineUser)
def test_disabled_authentication_with_header(settings):
"""Authentication should pass when no auth is configured, and header is present."""
settings.RECORDING_STORAGE_EVENT_TOKEN = None
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
request = RequestFactory().get("/")
request.headers = {"Authorization": "Bearer some-token"}
user, token = StorageEventAuthentication().authenticate(request)
assert token is None
assert isinstance(user, MachineUser)
def test_disabled_authentication_without_header(settings):
"""Authentication should pass when no auth is configured, and no header is present."""
settings.RECORDING_STORAGE_EVENT_TOKEN = None
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
request = RequestFactory().get("/")
user, token = StorageEventAuthentication().authenticate(request)
assert token is None
assert isinstance(user, MachineUser)
def test_authentication_when_disabled(settings):
"""Authentication should pass when disabled, regardless of token configuration."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "some-token"
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
request = RequestFactory().get("/")
user, token = StorageEventAuthentication().authenticate(request)
assert token is None
assert isinstance(user, MachineUser)
def test_authentication_fails_when_token_not_configured(settings):
"""Authentication should fail when authentication is enabled but no token is configured."""
# By default RECORDING_ENABLE_STORAGE_EVENT_AUTH should be True
settings.RECORDING_STORAGE_EVENT_TOKEN = None
request = RequestFactory().get("/")
with pytest.raises(
AuthenticationFailed,
match="Authentication is enabled but token is not configured",
):
StorageEventAuthentication().authenticate(request)
def test_missing_auth_header(settings):
"""Test failure when Authorization header is missing."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/")
request.headers = {}
with pytest.raises(AuthenticationFailed, match="Authorization header is required"):
StorageEventAuthentication().authenticate(request)
def test_invalid_auth_header_format(settings):
"""Test failure when Authorization header has invalid format."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/")
request.headers = {"Authorization": "InvalidFormat"}
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
StorageEventAuthentication().authenticate(request)
def test_invalid_token_type(settings):
"""Test failure when token type is not Bearer."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/")
request.headers = {"Authorization": "Basic some-token"}
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
StorageEventAuthentication().authenticate(request)
def test_invalid_token(settings):
"""Test failure when token is invalid."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/")
request.headers = {"Authorization": "Bearer wrong-token"}
with pytest.raises(AuthenticationFailed, match="Invalid token"):
StorageEventAuthentication().authenticate(request)
def test_malformed_auth_header(settings):
"""Test failure when Authorization header is malformed."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/")
request.headers = {"Authorization": "Bearer"} # Missing token part
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
StorageEventAuthentication().authenticate(request)
def test_authenticate_header():
"""Test the WWW-Authenticate header value."""
request = RequestFactory().get("/")
header = StorageEventAuthentication().authenticate_header(request)
assert header == "Bearer realm='Storage event API'"
def test_multiple_spaces_in_auth_header(settings):
"""Test failure when Authorization header contains multiple spaces."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/")
request.headers = {"Authorization": "Bearer extra-spaces-token"}
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
StorageEventAuthentication().authenticate(request)
@@ -0,0 +1,310 @@
"""
Test event parsers.
"""
# pylint: disable=W0212,W0621,W0613
from unittest import mock
from django.conf import settings
import pytest
from core.recording.event.exceptions import (
InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError,
ParsingEventDataError,
)
from core.recording.event.parsers import (
MinioParser,
StorageEvent,
get_parser,
)
@pytest.fixture
def valid_minio_event():
"""Mock a valid Minio event."""
return {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"contentType": "audio/ogg",
},
}
}
]
}
@pytest.fixture
def minio_parser():
"""Mock a Minio parser."""
return MinioParser(bucket_name="test-bucket")
def test_parse_valid_event(minio_parser, valid_minio_event):
"""Test parsing a valid Minio event."""
event = minio_parser.parse(valid_minio_event)
assert isinstance(event, StorageEvent)
assert event.filepath == "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
assert event.filetype == "audio/ogg"
assert event.bucket_name == "test-bucket"
assert event.metadata is None
def test_parse_empty_data(minio_parser):
"""Test parsing empty event data raises error."""
with pytest.raises(ParsingEventDataError, match="Received empty data."):
minio_parser.parse({})
def test_parse_missing_keys(minio_parser):
"""Test parsing event with missing key."""
invalid_minio_event = {
"Records": [
{
"s3": {
"bucket": {"name": None},
# Missing 'object' key
}
}
]
}
with pytest.raises(ParsingEventDataError, match="Missing or malformed key"):
minio_parser.parse(invalid_minio_event)
def test_parse_none_key(minio_parser):
"""Test parsing event with None field."""
invalid_minio_event = {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"contentType": None, # 'contentType' should not be None
},
}
}
]
}
with pytest.raises(ParsingEventDataError, match="Missing essential data fields"):
minio_parser.parse(invalid_minio_event)
def test_validate_invalid_bucket(minio_parser):
"""Test validation with wrong bucket name."""
event = StorageEvent(
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/ogg",
bucket_name="wrong-bucket",
metadata=None,
)
with pytest.raises(InvalidBucketError):
minio_parser.validate(event)
def test_validate_invalid_filetype(minio_parser):
"""Test validation with unsupported file type."""
event = StorageEvent(
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.txt",
filetype="text/plain", # Not included in the default allowed filetypes
bucket_name="test-bucket",
metadata=None,
)
with pytest.raises(InvalidFileTypeError):
minio_parser.validate(event)
@pytest.mark.parametrize(
"invalid_filepath",
[
"invalid_filepath",
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"recording%2F46d1a1212426484d8fb309b5d886f7a8.ogg",
],
)
def test_validate_invalid_filepath(invalid_filepath, minio_parser):
"""Test validation with malformed filepath."""
event = StorageEvent(
filepath=invalid_filepath,
filetype="audio/ogg",
bucket_name="test-bucket",
metadata=None,
)
with pytest.raises(InvalidFilepathError):
minio_parser.validate(event)
def test_validate_valid_event(minio_parser):
"""Test validation with valid event data."""
event = StorageEvent(
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/ogg",
bucket_name="test-bucket",
metadata=None,
)
recording_id = minio_parser.validate(event)
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
def test_get_recording_id_success(minio_parser, valid_minio_event):
"""Test successful extraction of recording ID."""
recording_id = minio_parser.get_recording_id(valid_minio_event)
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
def test_validate_filepath_with_folder(minio_parser):
"""Test validation of filepath with folder structure."""
event = StorageEvent(
filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/ogg",
bucket_name="test-bucket",
metadata=None,
)
recording_id = minio_parser.validate(event)
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
def test_parse_with_video_type(minio_parser):
"""Test parsing event with video file type."""
video_event = {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "46d1a121-2426-484d-8fb3-09b5d886f7a8.mp4",
"contentType": "video/mp4",
},
}
}
]
}
event = minio_parser.parse(video_event)
assert event.filetype == "video/mp4"
assert event.filepath.endswith(".mp4")
def test_empty_allowed_filetypes():
"""Test MinioParser with empty allowed_filetypes."""
empty_types = set()
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=empty_types)
assert parser._allowed_filetypes == {"audio/ogg", "video/mp4"}
def test_custom_allowed_filetypes():
"""Test MinioParser with empty allowed_filetypes."""
custom_types = {"audio/mp3", "video/mov"}
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=custom_types)
assert parser._allowed_filetypes == {"audio/mp3", "video/mov"}
def test_validate_custom_filetypes():
"""Test validation of filepath with folder structure."""
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"})
event = StorageEvent(
filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/mp3",
bucket_name="test-bucket",
metadata=None,
)
parser.validate(event)
def test_constructor_none_bucket():
"""Test MinioParser constructor with None bucket name."""
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
MinioParser(bucket_name=None)
def test_constructor_empty_bucket():
"""Test MinioParser constructor with empty bucket name."""
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
MinioParser(bucket_name="")
@pytest.fixture
def clear_lru_cache():
"""Fixture to clear the LRU cache between tests."""
get_parser.cache_clear()
yield
get_parser.cache_clear()
def test_returns_correct_instance(clear_lru_cache):
"""Test if get_parser returns the correct parser instance."""
settings.AWS_STORAGE_BUCKET_NAME = "test-bucket"
parser = get_parser()
assert isinstance(parser, MinioParser)
assert parser._bucket_name == "test-bucket"
def test_caching_behavior(clear_lru_cache):
"""Test if the function properly caches the parser instance."""
settings.AWS_STORAGE_BUCKET_NAME = "test-bucket"
parser1 = get_parser()
parser2 = get_parser()
assert parser1 is parser2 # Check object identity
def test_different_settings_new_instance():
"""Test if changing settings creates a new instance."""
settings.AWS_STORAGE_BUCKET_NAME = "different-bucket"
parser = get_parser()
assert parser._bucket_name == "different-bucket"
def test_import_error_handling(clear_lru_cache):
"""Test handling of import errors for invalid parser class."""
settings.RECORDING_EVENT_PARSER_CLASS = "invalid.parser.path"
with pytest.raises(ImportError):
get_parser()
@mock.patch("core.recording.event.parsers.import_string")
def test_parser_instantiation_called_once(mock_import_string, clear_lru_cache):
"""Test that parser class is instantiated only once due to caching."""
mock_parser_cls = type(
"MockParser",
(),
{
"__init__": lambda self, bucket_name: setattr(
self, "_bucket_name", bucket_name
)
},
)
mock_import_string.return_value = mock_parser_cls
# First call
parser1 = get_parser()
# Second call
parser2 = get_parser()
# Verify import_string was called only once
mock_import_string.assert_called_once_with(settings.RECORDING_EVENT_PARSER_CLASS)
assert parser1 is parser2
def test_cache_clear_behavior(clear_lru_cache, settings):
"""Test that cache clearing creates new instance."""
settings.RECORDING_EVENT_PARSER_CLASS = "core.recording.event.parsers.MinioParser"
parser1 = get_parser()
get_parser.cache_clear()
parser2 = get_parser()
assert parser1 is not parser2 # Should be different instances after cache clear
@@ -0,0 +1,114 @@
"""
Test recordings API endpoints in the Meet core app: delete.
"""
import pytest
from rest_framework.test import APIClient
from ...factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
from ...models import Recording
pytestmark = pytest.mark.django_db
def test_api_recordings_delete_anonymous():
"""Anonymous users should not be allowed to destroy a recording."""
recording = RecordingFactory()
client = APIClient()
response = client.delete(
f"/api/v1.0/recordings/{recording.id!s}/",
)
assert response.status_code == 401
assert Recording.objects.count() == 1
def test_api_recordings_delete_authenticated():
"""
Authenticated users should not be allowed to delete a recording
from which they are not related.
"""
recording = RecordingFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/recordings/{recording.id!s}/",
)
assert response.status_code == 404
assert Recording.objects.count() == 1
def test_api_recordings_delete_members():
"""
Authenticated users should not be allowed to delete a recording
from which they are only a member.
"""
user = UserFactory()
access = UserRecordingAccessFactory(role="member", user=user)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/recordings/{access.recording.id}/",
)
assert response.status_code == 403
assert Recording.objects.count() == 1
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_api_recordings_delete_active(role):
"""
Authenticated users cannot delete active recordings, even with deletion privileges.
"""
user = UserFactory()
recording = RecordingFactory(status="active")
access = UserRecordingAccessFactory(role=role, user=user, recording=recording)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/recordings/{access.recording.id}/",
)
assert response.status_code == 403
assert Recording.objects.count() == 1
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_api_recordings_delete_final(role):
"""
Authenticated users should not be allowed to delete an active recording
from which they are an admin or owner.
"""
user = UserFactory()
recording = RecordingFactory(status="saved")
access = UserRecordingAccessFactory(role=role, user=user, recording=recording)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/recordings/{access.recording.id}/",
)
assert response.status_code == 204
assert Recording.objects.count() == 0
@@ -0,0 +1,180 @@
"""
Test recordings API endpoints in the Meet core app: list.
"""
import operator
from unittest import mock
import pytest
from rest_framework.pagination import PageNumberPagination
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_recordings_list_anonymous():
"""Anonymous users should not be able to list recordings."""
factories.RecordingFactory()
response = APIClient().get("/api/v1.0/recordings/")
assert response.status_code == 401
@pytest.mark.parametrize(
"role",
["administrator", "member", "owner"],
)
def test_api_recordings_list_authenticated_direct(role):
"""
Authenticated users listing recordings, should only see the recordings
to which they are related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
access = factories.UserRecordingAccessFactory(role=role, user=user)
factories.UserRecordingAccessFactory(user=other_user)
recording = access.recording
room = recording.room
response = client.get(
"/api/v1.0/recordings/",
)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 1
expected_ids = {
str(recording.id),
}
result_ids = {result["id"] for result in results}
assert expected_ids == result_ids
assert results[0] == {
"id": str(recording.id),
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"status": "initiated",
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
}
def test_api_recording_list_authenticated_via_team(mock_user_get_teams):
"""
Authenticated users should be able to list recordings they are a
owner/administrator/member of via a team.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
mock_user_get_teams.return_value = ["team1", "team2", "unknown"]
recordings_team1 = [
access.recording
for access in factories.TeamRecordingAccessFactory.create_batch(2, team="team1")
]
recordings_team2 = [
access.recording
for access in factories.TeamRecordingAccessFactory.create_batch(3, team="team2")
]
expected_ids = {
str(recording.id) for recording in recordings_team1 + recordings_team2
}
response = client.get("/api/v1.0/recordings/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
results_id = {result["id"] for result in results}
assert expected_ids == results_id
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
def test_api_recordings_list_pagination(_mock_page_size):
"""Pagination should work as expected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
recording_ids = [
str(access.recording_id)
for access in factories.UserRecordingAccessFactory.create_batch(3, user=user)
]
response = client.get("/api/v1.0/recordings/")
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] == "http://testserver/api/v1.0/recordings/?page=2"
assert content["previous"] is None
assert len(content["results"]) == 2
for item in content["results"]:
recording_ids.remove(item["id"])
# Get page 2
response = client.get(
"/api/v1.0/recordings/?page=2",
)
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] is None
assert content["previous"], "http://testserver/api/v1.0/recordings/"
assert len(content["results"]) == 1
recording_ids.remove(content["results"][0]["id"])
assert recording_ids == []
def test_api_recordings_list_authenticated_distinct():
"""A recording for a room with several related users should only be listed once."""
user = factories.UserFactory()
other_user = factories.UserFactory()
client = APIClient()
client.force_login(user)
recording = factories.RecordingFactory(users=[user, other_user])
response = client.get("/api/v1.0/recordings/")
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(recording.id)
def test_api_recordings_list_ordering_default():
"""Recordings should be ordered by descending "updated_at" by default"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.RecordingFactory.create_batch(5, users=[user])
response = client.get("/api/v1.0/recordings/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Check that results are sorted by descending "updated_at" as expected
for i in range(4):
assert operator.ge(results[i]["updated_at"], results[i + 1]["updated_at"])
@@ -0,0 +1,203 @@
"""
Test recordings API endpoints in the Meet core app: save recording.
"""
# pylint: disable=W0621,W0613
import uuid
from unittest import mock
import pytest
from rest_framework.test import APIClient
from ...factories import RecordingFactory
from ...models import Recording, RecordingStatusChoices
from ...recording.event.exceptions import (
InvalidBucketError,
InvalidFileTypeError,
ParsingEventDataError,
)
pytestmark = pytest.mark.django_db
@pytest.fixture
def recording_settings(settings):
"""Configure recording-related and storage event Django settings."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
settings.RECORDING_STORAGE_EVENT_ENABLE = True
return settings
@pytest.fixture
def mock_get_parser():
"""Mock 'get_parser' factory function."""
with mock.patch("core.api.viewsets.get_parser") as mock_parser:
yield mock_parser
def test_save_recording_anonymous(settings, client):
"""Anonymous users should not be allowed to save room recordings."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
RecordingFactory(status="active")
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
)
assert response.status_code == 401
assert Recording.objects.count() == 1
def test_save_recording_wrong_bearer(settings, client):
"""Requests with incorrect bearer token should be rejected when auth is required."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer wrongAuthToken",
)
assert response.status_code == 401
def test_save_recording_permission_needed(settings, client):
"""Recordings should not be saved when feature is disabled."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
settings.RECORDING_STORAGE_EVENT_ENABLE = False
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 403
def test_save_recording_parsing_error(recording_settings, mock_get_parser, client):
"""Test handling of parsing errors in recording event data."""
mock_parser = mock.Mock()
mock_parser.get_recording_id.side_effect = ParsingEventDataError("Error message")
mock_get_parser.return_value = mock_parser
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid request data: Error message"}
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
"""Test handling of invalid storage bucket errors in recording event data."""
mock_parser = mock.Mock()
mock_parser.get_recording_id.side_effect = InvalidBucketError("Error message")
mock_get_parser.return_value = mock_parser
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid bucket specified"}
def test_save_recording_filetype_error(recording_settings, mock_get_parser):
"""Test handling of unsupported file types in recording event data."""
mock_parser = mock.Mock()
mock_parser.get_recording_id.side_effect = InvalidFileTypeError(
"unsupported '.json'"
)
mock_get_parser.return_value = mock_parser
client = APIClient()
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Ignore this file type, unsupported '.json'"}
def test_save_recording_unknown_recording(recording_settings, mock_get_parser, client):
"""Test handling of events for non-existent recordings."""
RecordingFactory(status="active")
mock_parser = mock.Mock()
mock_parser.get_recording_id.return_value = uuid.uuid4()
mock_get_parser.return_value = mock_parser
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 404
assert response.json() == {"detail": "No recording found for this event."}
@pytest.mark.parametrize(
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
)
def test_save_recording_non_savable_recording(
recording_settings, mock_get_parser, client, status
):
"""Test that recordings in non-savable states cannot be saved."""
recording = RecordingFactory(status=status)
mock_parser = mock.Mock()
mock_parser.get_recording_id.return_value = recording.id
mock_get_parser.return_value = mock_parser
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 403
assert response.json() == {
"detail": f"Recording with ID {recording.id} cannot be saved because it is either,"
" in an error state or has already been saved."
}
@pytest.mark.parametrize("status", ["active", "stopped"])
def test_save_recording_success(recording_settings, mock_get_parser, client, status):
"""Test successful saving of recordings in valid states."""
recording = RecordingFactory(status=status)
mock_parser = mock.Mock()
mock_parser.get_recording_id.return_value = recording.id
mock_get_parser.return_value = mock_parser
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Event processed."}
recording.refresh_from_db()
assert recording.status == RecordingStatusChoices.SAVED
@@ -0,0 +1,171 @@
"""
Test worker service factories.
"""
# pylint: disable=W0212,W0621,W0613
from dataclasses import FrozenInstanceError
from unittest.mock import Mock
from django.test import override_settings
import pytest
from core.recording.worker.factories import (
WorkerService,
WorkerServiceConfig,
get_worker_service,
)
@pytest.fixture(autouse=True)
def clear_lru_cache():
"""Clear the lru_cache before and after each test"""
WorkerServiceConfig.from_settings.cache_clear()
yield
WorkerServiceConfig.from_settings.cache_clear()
@pytest.fixture
def test_settings():
"""Fixture to provide test Django 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",
"AWS_S3_REGION_NAME": "test-region",
"AWS_STORAGE_BUCKET_NAME": "test-bucket",
}
# Use override_settings to properly patch Django settings
with override_settings(**mocked_settings):
yield test_settings
@pytest.fixture
def default_config(test_settings):
"""Fixture to provide a WorkerServiceConfig instance"""
return WorkerServiceConfig.from_settings()
# Tests
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",
"secret": "test_secret",
"region": "test-region",
"bucket": "test-bucket",
"force_path_style": True,
}
def test_config_immutability(default_config):
"""Test that config instances are immutable after creation"""
with pytest.raises(FrozenInstanceError):
default_config.output_folder = "new/path"
@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",
AWS_S3_REGION_NAME="test-region",
AWS_STORAGE_BUCKET_NAME="test-bucket",
)
def test_config_caching():
"""Test that from_settings method caches its result"""
# Clear cache before testing caching behavior
WorkerServiceConfig.from_settings.cache_clear()
config1 = WorkerServiceConfig.from_settings()
config2 = WorkerServiceConfig.from_settings()
assert config1 is config2
class MockWorkerService(WorkerService):
"""Mock worker service for testing."""
def __init__(self, config):
self.config = config
@pytest.fixture
def mock_import_string(monkeypatch):
"""Fixture to mock import_string function."""
mock = Mock(return_value=MockWorkerService)
monkeypatch.setattr("core.recording.worker.factories.import_string", mock)
return mock
def test_factory_valid_mode(mock_import_string, settings, default_config):
"""Test getting worker service with valid mode."""
settings.RECORDING_WORKER_CLASSES = {
"test_mode": "path.to.MockWorkerService",
"another_mode": "path.to.AnotherWorkerService",
}
worker = get_worker_service("test_mode")
mock_import_string.assert_called_once_with("path.to.MockWorkerService")
assert isinstance(worker, MockWorkerService)
assert worker.config == default_config
def test_factory_invalid_mode(settings, mock_import_string, default_config):
"""Test getting worker service with invalid mode raises ValueError."""
settings.RECORDING_WORKER_CLASSES = {
"test_mode": "path.to.MockWorkerService",
"another_mode": "path.to.AnotherWorkerService",
}
worker = get_worker_service("test_mode")
mock_import_string.assert_called_once_with("path.to.MockWorkerService")
assert isinstance(worker, MockWorkerService)
with pytest.raises(ValueError) as exc_info:
get_worker_service("invalid_mode")
mock_import_string.assert_not_called()
assert "Recording mode 'invalid_mode' not found" in str(exc_info.value)
assert "Available modes: ['test_mode', 'another_mode']" in str(exc_info.value)
def test_factory_import_error(mock_import_string, settings):
"""Test handling of import errors."""
mock_import_string.side_effect = ImportError("Module not found")
settings.RECORDING_WORKER_CLASSES = {
"test_mode": "path.to.MockWorkerService",
"another_mode": "path.to.AnotherWorkerService",
}
with pytest.raises(ImportError) as exc_info:
get_worker_service("test_mode")
assert "Module not found" in str(exc_info.value)
def test_factory_empty_registry(settings):
"""Test behavior when worker registry is empty."""
settings.RECORDING_WORKER_CLASSES = {}
with pytest.raises(ValueError) as exc_info:
get_worker_service("any_mode")
assert "Available modes: []" in str(exc_info.value)
@@ -0,0 +1,161 @@
"""Test WorkerServiceMediator class."""
# pylint: disable=W0621,W0613
from unittest.mock import Mock
import pytest
from core.factories import RecordingFactory
from core.models import RecordingStatusChoices
from core.recording.worker.exceptions import (
RecordingStartError,
RecordingStopError,
WorkerConnectionError,
WorkerRequestError,
WorkerResponseError,
)
from core.recording.worker.factories import WorkerService
from core.recording.worker.mediator import WorkerServiceMediator
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_worker_service():
"""Fixture for mock worker service"""
return Mock(spec=WorkerService)
@pytest.fixture
def mediator(mock_worker_service):
"""Fixture for WorkerServiceMediator"""
return WorkerServiceMediator(mock_worker_service)
def test_start_recording_success(mediator, mock_worker_service):
"""Test successful recording start"""
# Setup
worker_id = "test-worker-123"
mock_worker_service.start.return_value = worker_id
mock_recording = RecordingFactory(
status=RecordingStatusChoices.INITIATED, worker_id=None
)
mediator.start(mock_recording)
# Verify worker service call
expected_room_name = str(mock_recording.room.id)
mock_worker_service.start.assert_called_once_with(
expected_room_name, mock_recording.id
)
# Verify recording updates
mock_recording.refresh_from_db()
assert mock_recording.worker_id == worker_id
assert mock_recording.status == RecordingStatusChoices.ACTIVE
@pytest.mark.parametrize(
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
)
def test_mediator_start_recording_worker_errors(
mediator, mock_worker_service, error_class
):
"""Test handling of various worker errors during start"""
# Setup
mock_worker_service.start.side_effect = error_class("Test error")
mock_recording = RecordingFactory(
status=RecordingStatusChoices.INITIATED, worker_id=None
)
# Execute and verify
with pytest.raises(RecordingStartError):
mediator.start(mock_recording)
# Verify recording updates
mock_recording.refresh_from_db()
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
assert mock_recording.worker_id is None
@pytest.mark.parametrize(
"status",
[
RecordingStatusChoices.ACTIVE,
RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.STOPPED,
RecordingStatusChoices.SAVED,
RecordingStatusChoices.ABORTED,
],
)
def test_mediator_start_recording_from_forbidden_status(
mediator, mock_worker_service, status
):
"""Test handling of various worker errors during start"""
# Setup
mock_recording = RecordingFactory(status=status)
# Execute and verify
with pytest.raises(RecordingStartError):
mediator.start(mock_recording)
# Verify recording was not updated
mock_recording.refresh_from_db()
assert mock_recording.status == status
def test_mediator_stop_recording_success(mediator, mock_worker_service):
"""Test successful recording stop"""
# Setup
mock_recording = RecordingFactory(
status=RecordingStatusChoices.ACTIVE, worker_id="test-worker-123"
)
mock_worker_service.stop.return_value = "STOPPED"
# Execute
mediator.stop(mock_recording)
# Verify worker service call
mock_worker_service.stop.assert_called_once_with(worker_id=mock_recording.worker_id)
# Verify recording updates
mock_recording.refresh_from_db()
assert mock_recording.status == RecordingStatusChoices.STOPPED
def test_mediator_stop_recording_aborted(mediator, mock_worker_service):
"""Test recording stop when worker returns ABORTED"""
# Setup
mock_recording = RecordingFactory(
status=RecordingStatusChoices.ACTIVE, worker_id="test-worker-123"
)
mock_worker_service.stop.return_value = "ABORTED"
# Execute
mediator.stop(mock_recording)
# Verify recording updates
mock_recording.refresh_from_db()
assert mock_recording.status == RecordingStatusChoices.ABORTED
@pytest.mark.parametrize("error_class", [WorkerConnectionError, WorkerResponseError])
def test_mediator_stop_recording_worker_errors(
mediator, mock_worker_service, error_class
):
"""Test handling of worker errors during stop"""
# Setup
mock_recording = RecordingFactory(
status=RecordingStatusChoices.ACTIVE, worker_id="test-worker-123"
)
mock_worker_service.stop.side_effect = error_class("Test error")
# Execute and verify
with pytest.raises(RecordingStopError):
mediator.stop(mock_recording)
# Verify recording updates
mock_recording.refresh_from_db()
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_STOP
@@ -0,0 +1,368 @@
"""
Test worker service classes.
"""
# pylint: disable=W0212,W0621,W0613,E1101
from unittest.mock import AsyncMock, Mock, patch
import aiohttp
import pytest
from core.recording.worker.exceptions import WorkerConnectionError, WorkerResponseError
from core.recording.worker.factories import WorkerServiceConfig
from core.recording.worker.services import (
AudioCompositeEgressService,
BaseEgressService,
VideoCompositeEgressService,
livekit_api,
)
@pytest.fixture
def config():
"""Fixture to provide a WorkerServiceConfig instance"""
return WorkerServiceConfig(
output_folder="/test/output",
server_configurations={
"host": "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",
"secret": "test_secret",
"region": "test-region",
"bucket": "test-bucket",
"force_path_style": True,
},
)
@pytest.fixture
def mock_s3_upload():
"""Fixture for mocked S3Upload"""
with patch("core.recording.worker.services.livekit_api.S3Upload") as mock:
yield mock
@pytest.fixture
def mock_egress_service():
"""Fixture for mocked EgressService"""
with patch("core.recording.worker.services.EgressService") as mock:
yield mock
@pytest.fixture
def service(config, mock_s3_upload):
"""Fixture for BaseEgressService instance"""
return BaseEgressService(config)
@pytest.fixture
def mock_client_session():
"""Fixture for mocked aiohttp.ClientSession"""
with patch("aiohttp.ClientSession") as mock:
mock.return_value.__aenter__ = AsyncMock()
mock.return_value.__aexit__ = AsyncMock()
yield mock
@pytest.fixture
def mock_tcp_connector():
"""Fixture for TCPConnector"""
with patch("aiohttp.TCPConnector") as mock_connector:
mock_connector_instance = Mock()
mock_connector.return_value = mock_connector_instance
yield mock_connector
@pytest.fixture
def video_service(config):
"""Fixture for VideoCompositeEgressService"""
service = VideoCompositeEgressService(config)
service._handle_request = Mock() # Mock the request handler
return service
@pytest.fixture
def audio_service(config):
"""Fixture for AudioCompositeEgressService"""
service = AudioCompositeEgressService(config)
service._handle_request = Mock() # Mock the request handler
return service
def test_base_egress_initialization(config, mock_s3_upload):
"""Test service initialization"""
service = BaseEgressService(config)
assert service._config == config
mock_s3_upload.assert_called_once_with(
endpoint="https://s3.test.com",
access_key="test_key",
secret="test_secret",
region="test-region",
bucket="test-bucket",
force_path_style=True,
)
@pytest.mark.parametrize(
"filename,extension,expected",
[
("test", "mp4", "/test/output/test.mp4"),
("recording123", "ogg", "/test/output/recording123.ogg"),
("live_stream", "m3u8", "/test/output/live_stream.m3u8"),
],
)
def test_base_egress_filepath_construction(service, filename, extension, expected):
"""Test filepath construction with various inputs"""
result = service._get_filepath(filename, extension)
assert result == expected
assert result.startswith(service._config.output_folder)
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",
[
(livekit_api.EgressStatus.EGRESS_ABORTED, "ABORTED"),
(livekit_api.EgressStatus.EGRESS_COMPLETE, "FAILED_TO_STOP"),
(livekit_api.EgressStatus.EGRESS_ENDING, "STOPPED"),
(livekit_api.EgressStatus.EGRESS_FAILED, "FAILED_TO_STOP"),
],
)
def test_base_egress_stop_with_status(service, response_status, expected_result):
"""Test stop method with different response statuses"""
# Mock _handle_request
mock_response = Mock(status=response_status)
service._handle_request = Mock(return_value=mock_response)
# Execute stop
result = service.stop("test_worker_id")
# Verify request and response handling
service._handle_request.assert_called_once_with(
livekit_api.StopEgressRequest(egress_id="test_worker_id"), "stop_egress"
)
assert result == expected_result
def test_base_egress_stop_missing_status(service):
"""Test stop method when response is missing status"""
# Mock _handle_request with missing status
mock_response = Mock(status=None)
service._handle_request = Mock(return_value=mock_response)
# Verify error handling
with pytest.raises(WorkerResponseError) as exc:
service.stop("test_worker_id")
assert "missing the recording status" in str(exc.value)
def test_base_egress_start_not_implemented(service):
"""Test that start method raises NotImplementedError"""
with pytest.raises(NotImplementedError) as exc:
service.start("test_room", "test_recording")
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"
def test_video_composite_egress_start_success(video_service):
"""Test successful start of video composite egress"""
# Setup mock response
egress_id = "test-egress-123"
video_service._handle_request.return_value = Mock(egress_id=egress_id)
# Test parameters
room_name = "test-room"
recording_id = "rec-123"
# Call start
result = video_service.start(room_name, recording_id)
# Verify result
assert result == egress_id
# Verify request construction
video_service._handle_request.assert_called_once()
request = video_service._handle_request.call_args[0][0]
method = video_service._handle_request.call_args[0][1]
# Verify request properties
assert isinstance(request, livekit_api.RoomCompositeEgressRequest)
assert request.room_name == room_name
assert len(request.file_outputs) == 1
assert not request.audio_only # Video service shouldn't set audio_only
# Verify file output configuration
file_output = request.file_outputs[0]
assert file_output.file_type == livekit_api.EncodedFileType.MP4
assert file_output.filepath == f"/test/output/{recording_id}.mp4"
assert file_output.s3.bucket == "test-bucket"
# Verify method name
assert method == "start_room_composite_egress"
def test_video_composite_egress_start_missing_egress_id(video_service):
"""Test handling of missing egress ID in response"""
# Setup mock response without egress_id
video_service._handle_request.return_value = Mock(egress_id=None)
with pytest.raises(WorkerResponseError) as exc_info:
video_service.start("test-room", "rec-123")
assert "Egress ID not found" in str(exc_info.value)
def test_audio_composite_egress_hrid(audio_service):
"""Test HRID is correct"""
assert audio_service.hrid == "audio-recording-composite-livekit-egress"
def test_audio_composite_egress_start_success(audio_service):
"""Test successful start of audio composite egress"""
# Setup mock response
egress_id = "test-egress-123"
audio_service._handle_request.return_value = Mock(egress_id=egress_id)
# Test parameters
room_name = "test-room"
recording_id = "rec-123"
# Call start
result = audio_service.start(room_name, recording_id)
# Verify result
assert result == egress_id
# Verify request construction
audio_service._handle_request.assert_called_once()
request = audio_service._handle_request.call_args[0][0]
method = audio_service._handle_request.call_args[0][1]
# Verify request properties
assert isinstance(request, livekit_api.RoomCompositeEgressRequest)
assert request.room_name == room_name
assert len(request.file_outputs) == 1
assert request.audio_only is True # Audio service should set audio_only
# Verify file output configuration
file_output = request.file_outputs[0]
assert file_output.file_type == livekit_api.EncodedFileType.OGG
assert file_output.filepath == f"/test/output/{recording_id}.ogg"
assert file_output.s3.bucket == "test-bucket"
# Verify method name
assert method == "start_room_composite_egress"
def test_audio_composite_egress_start_missing_egress_id(audio_service):
"""Test handling of missing egress ID in response"""
# Setup mock response without egress_id
audio_service._handle_request.return_value = Mock(egress_id=None)
with pytest.raises(WorkerResponseError) as exc_info:
audio_service.start("test-room", "rec-123")
assert "Egress ID not found" in str(exc_info.value)
@@ -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,17 +57,17 @@ 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)
id_no_dashes = str(room.id).replace("-", "")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
id_no_dashes = str(room.id)
client = APIClient()
response = client.get(f"/api/v1.0/rooms/{id_no_dashes: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,
}
@@ -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}".replace("-", "")
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()
@@ -220,11 +240,11 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
)
assert response.status_code == 200
expected_name = f"{room.id!s}".replace("-", "")
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,
}
@@ -301,6 +367,8 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
"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,
@@ -310,6 +378,8 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
"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,
@@ -318,11 +388,11 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
key=lambda x: x["id"],
)
expected_name = str(room.id).replace("-", "")
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,
@@ -332,7 +402,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")
@@ -374,6 +446,8 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
"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,
@@ -383,6 +457,8 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
"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,
@@ -390,11 +466,11 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
],
key=lambda x: x["id"],
)
expected_name = str(room.id).replace("-", "")
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",
@@ -405,4 +481,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
)
@@ -0,0 +1,200 @@
"""
Test rooms API endpoints in the Meet core app: start recording.
"""
# pylint: disable=W0621,W0613
from unittest import mock
import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...models import Recording
from ...recording.worker.exceptions import RecordingStartError
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_worker_service():
"""Mock worker service."""
return mock.Mock()
@pytest.fixture
def mock_worker_service_factory(mock_worker_service):
"""Mock worker service factory."""
with mock.patch(
"core.api.viewsets.get_worker_service",
return_value=mock_worker_service,
) as mock_worker_service_factory:
yield mock_worker_service_factory
@pytest.fixture
def mock_worker_manager(mock_worker_service):
"""Mock worker service mediator."""
with mock.patch("core.api.viewsets.WorkerServiceMediator") as mock_mediator_class:
mock_mediator = mock.Mock()
mock_mediator_class.return_value = mock_mediator
yield mock_mediator
def test_start_recording_anonymous():
"""Anonymous users should not be allowed to start room recordings."""
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 401
assert Recording.objects.count() == 0
def test_start_recording_non_owner_and_non_administrator():
"""Non-owner and Non-Administrator users should not be allowed to start room recordings."""
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 403
assert Recording.objects.count() == 0
def test_start_recording_recording_disabled(settings):
"""Should fail if recording is disabled for the room."""
settings.RECORDING_ENABLE = False
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}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 403
assert response.json() == {"detail": "Access denied, recording is disabled."}
assert Recording.objects.count() == 0
def test_start_recording_missing_mode(settings):
"""Should fail if recording mode is not provided."""
settings.RECORDING_ENABLE = True
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}/start-recording/", {})
assert response.status_code == 400
assert response.json() == {"detail": "Invalid request."}
assert Recording.objects.count() == 0
def test_start_recording_worker_error(
mock_worker_service_factory, mock_worker_manager, settings
):
"""Should handle worker service errors appropriately."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
# Make user the room owner
room.accesses.create(user=user, role="owner")
# Configure mock mediator to raise error
mock_start = mock.Mock()
mock_start.side_effect = RecordingStartError("Failed to connect to worker")
mock_worker_manager.start = mock_start
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
assert response.status_code == 500
assert response.json() == {
"error": f"Recording failed to start for room {room.slug}"
}
# Recording object should be created even if worker fails
assert Recording.objects.count() == 1
recording = Recording.objects.first()
assert recording.room == room
assert recording.mode == "screen_recording"
# Verify recording access details
assert recording.accesses.count() == 1
access = recording.accesses.first()
assert access.user == user
assert access.role == "owner"
def test_start_recording_success(
mock_worker_service_factory, mock_worker_manager, settings
):
"""Should successfully start recording when everything is configured correctly."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
# Make user the room owner
room.accesses.create(user=user, role="owner")
mock_start = mock.Mock()
mock_worker_manager.start = mock_start
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
assert response.status_code == 201
assert response.json() == {
"message": f"Recording successfully started for room {room.slug}"
}
# Verify the mediator was called with the recording
recording = Recording.objects.first()
mock_start.assert_called_once_with(recording)
assert recording.room == room
assert recording.mode == "screen_recording"
# Verify recording access details
assert recording.accesses.count() == 1
access = recording.accesses.first()
assert access.user == user
assert access.role == "owner"
@@ -0,0 +1,182 @@
"""
Test rooms API endpoints in the Meet core app: stop recording.
"""
# pylint: disable=W0621,W0613
from unittest import mock
import pytest
from rest_framework.test import APIClient
from ...factories import RecordingFactory, RoomFactory, UserFactory
from ...models import Recording, RecordingStatusChoices
from ...recording.worker.exceptions import RecordingStopError
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_worker_service():
"""Mock worker service."""
return mock.Mock()
@pytest.fixture
def mock_worker_service_factory(mock_worker_service):
"""Mock worker service factory."""
with mock.patch(
"core.api.viewsets.get_worker_service",
return_value=mock_worker_service,
) as mock_worker_service_factory:
yield mock_worker_service_factory
@pytest.fixture
def mock_worker_manager(mock_worker_service):
"""Mock worker service mediator."""
with mock.patch("core.api.viewsets.WorkerServiceMediator") as mock_mediator_class:
mock_mediator = mock.Mock()
mock_mediator_class.return_value = mock_mediator
yield mock_mediator
def test_stop_recording_anonymous():
"""Anonymous users should not be allowed to stop room recordings."""
room = RoomFactory()
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
client = APIClient()
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
assert response.status_code == 401
# Verify recording status hasn't changed
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
def test_stop_recording_non_owner_and_non_administrator():
"""Non-owner and Non-Administrator users should not be allowed to stop room recordings."""
room = RoomFactory()
user = UserFactory()
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
assert response.status_code == 403
# Verify recording status hasn't changed
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
def test_stop_recording_recording_disabled(settings):
"""Should fail if recording is disabled for the room."""
settings.RECORDING_ENABLE = False
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}/stop-recording/")
assert response.status_code == 403
assert response.json() == {"detail": "Access denied, recording is disabled."}
# Verify no recording exists
assert Recording.objects.count() == 0
def test_stop_recording_no_active_recording(settings):
"""Should fail when there is no active recording for the room."""
settings.RECORDING_ENABLE = True
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}/stop-recording/")
assert response.status_code == 404
assert response.json() == {"detail": "No active recording found for this room."}
def test_stop_recording_worker_error(
mock_worker_service_factory, mock_worker_manager, settings
):
"""Should handle worker service errors appropriately."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
recording = RecordingFactory(
room=room,
status=RecordingStatusChoices.ACTIVE,
mode="screen_recording",
)
# Make user the room owner
room.accesses.create(user=user, role="owner")
# Configure mock mediator to raise error
mock_stop = mock.Mock()
mock_stop.side_effect = RecordingStopError("Failed to connect to worker")
mock_worker_manager.stop = mock_stop
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
mock_stop.assert_called_once_with(recording)
assert response.status_code == 500
assert response.json() == {
"error": f"Recording failed to stop for room {room.slug}"
}
# Verify recording status hasn't changed
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
def test_stop_recording_success(
mock_worker_service_factory, mock_worker_manager, settings
):
"""Should successfully stop recording when everything is configured correctly."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
recording = RecordingFactory(
room=room,
status=RecordingStatusChoices.ACTIVE,
mode="screen_recording",
)
# Make user the room owner
room.accesses.create(user=user, role="owner")
mock_stop = mock.Mock()
mock_worker_manager.stop = mock_stop
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
mock_stop.assert_called_once_with(recording)
assert response.status_code == 200
assert response.json() == {"message": f"Recording stopped for room {room.slug}."}
# Verify the recording still exists
assert Recording.objects.count() == 1
@@ -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,952 @@
"""
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.services.lobby.LiveKitAPI")
def test_notify_participants_success_no_room(mock_livekit_api, lobby_service):
"""Test the notify_participants method when the LiveKit room doesn't exist yet."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock()
# Create a proper response object with an empty rooms list
class MockResponse:
"""LiveKit API response mock with empty rooms list."""
rooms = []
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_livekit_api.return_value = mock_api_instance
# Act
lobby_service.notify_participants(room.id)
# Assert
# Verify the API was initialized with correct configuration
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was not called since no room exists
mock_api_instance.room.send_data.assert_not_called()
# Verify the connection was properly closed
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.services.lobby.LiveKitAPI")
def test_notify_participants_success(mock_livekit_api, lobby_service):
"""Test successful participant notification."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock()
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_livekit_api.return_value = mock_api_instance
# Call the function
lobby_service.notify_participants(room.id)
# Verify the API was called correctly
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was called
mock_api_instance.room.send_data.assert_called_once()
send_data_request = mock_api_instance.room.send_data.call_args[0][0]
assert send_data_request.room == str(room.id)
assert (
json.loads(send_data_request.data.decode("utf-8"))["type"]
== settings.LOBBY_NOTIFICATION_TYPE
)
assert send_data_request.kind == 0 # RELIABLE mode in Livekit protocol
# Verify aclose was called
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.services.lobby.LiveKitAPI")
def test_notify_participants_error(mock_livekit_api, lobby_service):
"""Test participant notification with API error."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock(
side_effect=TwirpError(msg="test error", code=123)
)
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_livekit_api.return_value = mock_api_instance
# Call the function and expect an exception
with pytest.raises(
LobbyNotificationError, match="Failed to notify room participants"
):
lobby_service.notify_participants(room.id)
# Verify the API was called correctly
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify send_data was called
mock_api_instance.room.send_data.assert_called_once()
# Verify aclose was still called after the exception
mock_api_instance.aclose.assert_called_once()
def test_clear_room_cache(settings, lobby_service):
"""Test clearing room cache actually removes entries from cache."""
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}_*") == []
@@ -0,0 +1,183 @@
"""
Test marketing services.
"""
# pylint: disable=W0621,W0613
from unittest import mock
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
import brevo_python
import pytest
from core.services.marketing import (
BrevoMarketingService,
ContactCreationError,
ContactData,
get_marketing_service,
)
def test_init_missing_api_key(settings):
"""Test initialization with missing API key."""
settings.BREVO_API_KEY = None
with pytest.raises(ImproperlyConfigured, match="Brevo API key is required"):
BrevoMarketingService()
def test_create_contact_missing_list_ids(settings):
"""Test contact creation with missing list IDs."""
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = None
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
with pytest.raises(
ImproperlyConfigured, match="Default Brevo List IDs must be configured"
):
brevo_service.create_contact(valid_contact_data)
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_success(mock_contact_api):
"""Test successful contact creation."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
mock_api.create_contact.return_value = {"id": "test-id"}
response = brevo_service.create_contact(valid_contact_data)
assert response == {"id": "test-id"}
mock_api.create_contact.assert_called_once()
contact_arg = mock_api.create_contact.call_args[0][0]
assert contact_arg.email == "test@example.com"
assert contact_arg.attributes == {
**settings.BREVO_API_CONTACT_ATTRIBUTES,
**valid_contact_data.attributes,
}
assert set(contact_arg.list_ids) == {1, 2, 3, 4}
assert contact_arg.update_enabled is True
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_with_timeout(mock_contact_api):
"""Test contact creation with timeout."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
brevo_service.create_contact(valid_contact_data, timeout=30)
mock_api.create_contact.assert_called_once()
assert mock_api.create_contact.call_args[1]["_request_timeout"] == 30
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_api_error(mock_contact_api):
"""Test contact creation API error handling."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
mock_api.create_contact.side_effect = brevo_python.rest.ApiException()
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
brevo_service.create_contact(valid_contact_data)
@pytest.fixture
def clear_marketing_cache():
"""Clear marketing service cache between tests."""
get_marketing_service.cache_clear()
yield
get_marketing_service.cache_clear()
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.BrevoMarketingService"
service1 = get_marketing_service()
service2 = get_marketing_service()
assert service1 is service2
assert isinstance(service1, BrevoMarketingService)
def test_get_marketing_service_invalid_class(clear_marketing_cache):
"""Test handling of invalid service class."""
settings.MARKETING_SERVICE_CLASS = "invalid.service.path"
with pytest.raises(ImportError):
get_marketing_service()
@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.BrevoMarketingService"
get_marketing_service.cache_clear()
mock_service_cls = mock.Mock()
mock_service_instance = mock.Mock()
mock_service_cls.return_value = mock_service_instance
mock_import_string.return_value = mock_service_cls
service1 = get_marketing_service()
service2 = get_marketing_service()
mock_import_string.assert_called_once_with(settings.MARKETING_SERVICE_CLASS)
mock_service_cls.assert_called_once()
assert service1 is service2
assert service1 is mock_service_instance
-132
View File
@@ -1,132 +0,0 @@
"""
Test for the Analytics class.
"""
# pylint: disable=W0212
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.test.utils import override_settings
import pytest
from core.analytics import Analytics
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
@pytest.fixture(name="mock_june_analytics")
def _mock_june_analytics():
with patch("core.analytics.jAnalytics") as mock:
yield mock
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_init_enabled(mock_june_analytics):
"""Should enable analytics and set the write key correctly when ANALYTICS_KEY is set."""
analytics = Analytics()
assert analytics._enabled is True
assert mock_june_analytics.write_key == "test_key"
@override_settings(ANALYTICS_KEY=None)
def test_analytics_init_disabled():
"""Should disable analytics when ANALYTICS_KEY is not set."""
analytics = Analytics()
assert analytics._enabled is False
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_identify_user(mock_june_analytics):
"""Should identify a user with the correct traits when analytics is enabled."""
user = UserFactory(sub="12345", email="user@example.com")
analytics = Analytics()
analytics.identify(user)
mock_june_analytics.identify.assert_called_once_with(
user_id="12345", traits={"email": "***@example.com"}
)
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_identify_user_with_traits(mock_june_analytics):
"""Should identify a user with additional traits when analytics is enabled."""
user = UserFactory(sub="12345", email="user@example.com")
analytics = Analytics()
analytics.identify(user, traits={"email": "user@example.com", "foo": "foo"})
mock_june_analytics.identify.assert_called_once_with(
user_id="12345", traits={"email": "***@example.com", "foo": "foo"}
)
@override_settings(ANALYTICS_KEY=None)
def test_analytics_identify_not_enabled(mock_june_analytics):
"""Should not call identify when analytics is not enabled."""
user = UserFactory(sub="12345", email="user@example.com")
analytics = Analytics()
analytics.identify(user)
mock_june_analytics.identify.assert_not_called()
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_identify_no_user(mock_june_analytics):
"""Should not call identify when the user is None."""
analytics = Analytics()
analytics.identify(None)
mock_june_analytics.identify.assert_not_called()
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_identify_anonymous_user(mock_june_analytics):
"""Should not call identify when the user is anonymous."""
user = AnonymousUser()
analytics = Analytics()
analytics.identify(user)
mock_june_analytics.identify.assert_not_called()
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_track_event(mock_june_analytics):
"""Should track an event with the correct user and event details when analytics is enabled."""
user = UserFactory(sub="12345")
analytics = Analytics()
analytics.track(user, event="test_event", foo="foo")
mock_june_analytics.track.assert_called_once_with(
user_id="12345", event="test_event", foo="foo"
)
@override_settings(ANALYTICS_KEY=None)
def test_analytics_track_event_not_enabled(mock_june_analytics):
"""Should not call track when analytics is not enabled."""
user = UserFactory(sub="12345")
analytics = Analytics()
analytics.track(user, event="test_event", foo="foo")
mock_june_analytics.track.assert_not_called()
@override_settings(ANALYTICS_KEY="test_key")
@patch("uuid.uuid4", return_value="test_uuid4")
def test_analytics_track_event_no_user(mock_uuid4, mock_june_analytics):
"""Should track an event with a random anonymous user ID when the user is None."""
analytics = Analytics()
analytics.track(None, event="test_event", foo="foo")
mock_june_analytics.track.assert_called_once_with(
anonymous_id="test_uuid4", event="test_event", foo="foo"
)
mock_uuid4.assert_called_once()
@override_settings(ANALYTICS_KEY="test_key")
@patch("uuid.uuid4", return_value="test_uuid4")
def test_analytics_track_event_anonymous_user(mock_uuid4, mock_june_analytics):
"""Should track an event with a random anonymous user ID when the user is anonymous."""
user = AnonymousUser()
analytics = Analytics()
analytics.track(user, event="test_event", foo="foo")
mock_june_analytics.track.assert_called_once_with(
anonymous_id="test_uuid4", event="test_event", foo="foo"
)
mock_uuid4.assert_called_once()
@@ -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:
+29 -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()
@@ -66,7 +89,7 @@ def test_api_users_list_query_email():
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(nicole.id), str(frank.id)]
assert user_ids == [str(frank.id), str(nicole.id)]
def test_api_users_retrieve_me_anonymous():
@@ -96,6 +119,8 @@ def test_api_users_retrieve_me_authenticated():
assert response.json() == {
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"short_name": user.short_name,
}
@@ -0,0 +1,218 @@
"""
Unit tests for the Recording model
"""
from django.core.exceptions import ValidationError
import pytest
from core.factories import (
RecordingFactory,
RoomFactory,
UserFactory,
UserRecordingAccessFactory,
)
from core.models import Recording, RecordingStatusChoices
pytestmark = pytest.mark.django_db
def test_models_recording_str():
"""The str representation should be the recording ID."""
recording = RecordingFactory()
assert str(recording) == f"Recording {recording.id} (initiated)"
def test_models_recording_ordering():
"""Recordings should be returned ordered by created_at in descending order."""
RecordingFactory.create_batch(3)
recordings = Recording.objects.all()
assert recordings[0].created_at >= recordings[1].created_at
assert recordings[1].created_at >= recordings[2].created_at
def test_models_recording_room_relationship():
"""It should maintain proper relationship with room."""
room = RoomFactory()
recording = RecordingFactory(room=room)
assert recording.room == room
assert recording in room.recordings.all()
def test_models_recording_default_status():
"""A new recording should have INITIATED status by default."""
recording = RecordingFactory()
assert recording.status == RecordingStatusChoices.INITIATED
def test_models_recording_unique_initiated_or_active_per_room():
"""Only one initiated or active recording should be allowed per room."""
room = RoomFactory()
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
with pytest.raises(ValidationError):
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
with pytest.raises(ValidationError):
RecordingFactory(room=room, status=RecordingStatusChoices.INITIATED)
def test_models_recording_multiple_finished_allowed():
"""Multiple finished recordings should be allowed in the same room."""
room = RoomFactory()
RecordingFactory(room=room, status=RecordingStatusChoices.SAVED)
RecordingFactory(room=room, status=RecordingStatusChoices.SAVED)
assert room.recordings.count() == 2
# Test get_abilities method
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_models_recording_get_abilities_privileges_active(role):
"""Test abilities for active recording and privileged user."""
user = UserFactory()
access = UserRecordingAccessFactory(role=role, user=user)
access.recording.status = RecordingStatusChoices.ACTIVE
abilities = access.recording.get_abilities(user)
assert abilities == {
"destroy": False, # Not final status
"partial_update": False,
"retrieve": True, # Privileged users can always retrieve
"stop": True, # Not final status, so can stop
"update": False,
}
def test_models_recording_get_abilities_member_active():
"""Test abilities for a user who is unprivileged."""
user = UserFactory()
access = UserRecordingAccessFactory(role="member", user=user)
access.recording.status = RecordingStatusChoices.ACTIVE
abilities = access.recording.get_abilities(user)
assert abilities == {
"destroy": False,
"partial_update": False,
"retrieve": False,
"stop": False,
"update": False,
}
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_models_recording_get_abilities_privileges_final(role):
"""Test abilities for active recording and privileged user."""
user = UserFactory()
access = UserRecordingAccessFactory(role=role, user=user)
access.recording.status = RecordingStatusChoices.SAVED
abilities = access.recording.get_abilities(user)
assert abilities == {
"destroy": True,
"partial_update": False,
"retrieve": True, # Privileged users can always retrieve
"stop": False, # In final status, so can not stop
"update": False,
}
def test_models_recording_get_abilities_member_final():
"""Test abilities for a user who is unprivileged."""
user = UserFactory()
access = UserRecordingAccessFactory(role="member", user=user)
access.recording.status = RecordingStatusChoices.SAVED
abilities = access.recording.get_abilities(user)
assert abilities == {
"destroy": False,
"partial_update": False,
"retrieve": False,
"stop": False,
"update": False,
}
# Test is_savable method
def test_models_recording_is_savable_normal():
"""Test is_savable for normal recording status."""
recording = RecordingFactory(status=RecordingStatusChoices.ACTIVE)
assert recording.is_savable() is True
@pytest.mark.parametrize(
"status",
[
RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.ABORTED,
],
)
def test_models_recording_is_savable_error(status):
"""Test is_savable for error status."""
recording = RecordingFactory(status=status)
assert recording.is_savable() is False
def test_models_recording_is_savable_already_saved():
"""Test is_savable for already saved recording."""
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
assert recording.is_savable() is False
def test_models_recording_is_savable_only_initiated():
"""Test is_savable for only initiated recording."""
recording = RecordingFactory(status=RecordingStatusChoices.INITIATED)
assert recording.is_savable() is False
# Test few corner cases
def test_models_recording_worker_id_optional():
"""Worker ID should be optional."""
recording = RecordingFactory(worker_id=None)
assert recording.worker_id is None
recording = RecordingFactory(worker_id="worker-123")
assert recording.worker_id == "worker-123"
def test_models_recording_invalid_status():
"""Test that setting an invalid status raises an error."""
recording = RecordingFactory()
recording.status = "INVALID_STATUS"
with pytest.raises(ValidationError):
recording.save()
def test_models_recording_room_deletion():
"""Test that deleting a room cascades to its recordings."""
room = RoomFactory()
recording = RecordingFactory(room=room)
room.delete()
assert not Recording.objects.filter(id=recording.id).exists()
def test_models_recording_worker_id_very_long():
"""Test worker_id with maximum length."""
long_id = "w" * 255
recording = RecordingFactory(worker_id=long_id)
assert recording.worker_id == long_id
too_long_id = "w" * 256
with pytest.raises(ValidationError):
RecordingFactory(worker_id=too_long_id)

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