mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-19 19:21:47 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7f9522943 |
@@ -22,9 +22,6 @@
|
||||
# See http://jorisroovers.github.io/gitlint/user_defined_rules for details
|
||||
extra-path=gitlint/
|
||||
|
||||
# Use "search" semantics for regexes (gitlint's future default)
|
||||
regex-style-search=True
|
||||
|
||||
# [title-max-length]
|
||||
# line-length=80
|
||||
|
||||
|
||||
@@ -10,19 +10,7 @@ and this project adheres to
|
||||
|
||||
### Added
|
||||
|
||||
- ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
|
||||
- ✨(backend) add room soft-deletion to the external API
|
||||
- ✨(all) allow any authenticated user to start a recording
|
||||
- ✨(backend) authenticate external API calls with Menshen exchanged tokens
|
||||
|
||||
## [1.35.0] - 2026-10-13
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) let users pick the recording encoding
|
||||
- ✨(backend) introduce a token exchange endpoint for iframe embeds
|
||||
- ⚡️(backend) send recording notification emails from a Celery task
|
||||
- ✨(backend) allow static feature flags without an analytics backend #1809
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -30,8 +18,6 @@ and this project adheres to
|
||||
- ⚡️(backend) reduce domain queries on the application token endpoint
|
||||
- ♻️️️(backend) use a dedicated auth scheme for LiveKit token auth
|
||||
- ♻️(all) stop relying on cookies for the lobby flow
|
||||
- ♿️(frontend) show a visible focus outline on menu items #1797
|
||||
- ⬆️(backend) upgrade pylint to 4.1.3
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -39,9 +25,6 @@ and this project adheres to
|
||||
- 🔒️(frontend) upgrade tiff to fix CVE-2026-4775
|
||||
- 🔒️(backend) bind accepted lobby entries to the current username
|
||||
- 🔒️(backend) sign lobby participant id
|
||||
- 🐛(frontend) omit the default speaker id sent to LiveKit
|
||||
- 🐛(frontend) play the speaker test when no output is picked
|
||||
- 🐛(frontend) keep the admin panel in place when changing a setting #1804
|
||||
|
||||
## [1.34.0] - 2026-10-07
|
||||
|
||||
|
||||
-15
@@ -16,21 +16,6 @@ the following command inside your docker container:
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Purging deleted rooms
|
||||
|
||||
Deleting a room now only soft deletes it: it is hidden but keeps its slug and PIN code. The `purge_inactive_rooms` command also permanently deletes soft-deleted rooms. See [the room purge documentation](docs/features/room-purge.md).
|
||||
|
||||
- Inactive soft-deleted rooms are purged along with the other inactive rooms: if `ROOM_INACTIVITY_DELETION_DAYS` is already set, nothing else is needed.
|
||||
- The new `ROOM_DELETED_RETENTION_DAYS` setting purges soft-deleted rooms that many days after their deletion, whether they are inactive or not. It is unset by default.
|
||||
- As for inactive rooms, a deleted room holding a saved recording its users may still access is kept until that recording expires.
|
||||
|
||||
### Recording permission
|
||||
|
||||
We've introduced the ability for any authenticated participant to start a recording or transcription, but this behavior remains configurable.
|
||||
You can keep recording and transcription restricted to room admins and owners by setting `RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED` to `false`.
|
||||
|
||||
## [v1.34.0]
|
||||
|
||||
### Marketing / Brevo integration now uses `django-lasuite`
|
||||
|
||||
The in-house marketing service (`core.services.marketing`) has been removed and
|
||||
|
||||
+13
-13
@@ -5,17 +5,17 @@ services:
|
||||
env_file:
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
- "127.0.0.1:15432:5432"
|
||||
- "15432:5432"
|
||||
|
||||
redis:
|
||||
image: redis:5
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
- "6379:6379"
|
||||
|
||||
mailcatcher:
|
||||
image: sj26/mailcatcher:latest
|
||||
ports:
|
||||
- "127.0.0.1:1081:1080"
|
||||
- "1081:1080"
|
||||
|
||||
garage:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -71,7 +71,7 @@ services:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
- "127.0.0.1:8071:8000"
|
||||
- "8071:8000"
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
@@ -137,7 +137,7 @@ services:
|
||||
nginx:
|
||||
image: nginx:1.25
|
||||
ports:
|
||||
- "127.0.0.1:8083:8083"
|
||||
- "8083:8083"
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
@@ -159,7 +159,7 @@ services:
|
||||
VITE_APP_TITLE: "LaSuite Meet"
|
||||
image: meet:frontend-development
|
||||
ports:
|
||||
- "127.0.0.1:3000:8080"
|
||||
- "3000:8080"
|
||||
|
||||
dockerize:
|
||||
image: jwilder/dockerize
|
||||
@@ -185,7 +185,7 @@ services:
|
||||
kc_postgresql:
|
||||
image: postgres:14.3
|
||||
ports:
|
||||
- "127.0.0.1:5433:5432"
|
||||
- "5433:5432"
|
||||
env_file:
|
||||
- env.d/development/kc_postgresql
|
||||
|
||||
@@ -213,7 +213,7 @@ services:
|
||||
KC_DB_SCHEMA: public
|
||||
PROXY_ADDRESS_FORWARDING: 'true'
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- kc_postgresql
|
||||
|
||||
@@ -222,10 +222,10 @@ services:
|
||||
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
|
||||
ports:
|
||||
- "7880:7880"
|
||||
- "127.0.0.1:7881:7881"
|
||||
- "127.0.0.1:7882:7882/udp"
|
||||
- "127.0.0.1:3478:3478/udp"
|
||||
- "127.0.0.1:30000-30100:30000-30100/udp"
|
||||
- "7881:7881"
|
||||
- "7882:7882/udp"
|
||||
- "3478:3478/udp"
|
||||
- "30000-30100:30000-30100/udp"
|
||||
volumes:
|
||||
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
|
||||
depends_on:
|
||||
@@ -283,7 +283,7 @@ services:
|
||||
env_file:
|
||||
- env.d/development/summary
|
||||
ports:
|
||||
- "127.0.0.1:8001:8000"
|
||||
- "8001:8000"
|
||||
volumes:
|
||||
- ./src/summary:/app
|
||||
depends_on:
|
||||
|
||||
@@ -45,4 +45,4 @@ services:
|
||||
# The local proxy listens on 8080 and collides with Keycloak's published admin port.
|
||||
keycloak:
|
||||
ports: !override
|
||||
- "127.0.0.1:8081:8080"
|
||||
- "8081:8080"
|
||||
|
||||
@@ -1,3 +1,35 @@
|
||||
:root {
|
||||
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
|
||||
--avatar-cap-height: 0.7;
|
||||
}
|
||||
|
||||
.Header-beforeLogo {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.Header-beforeLogo::before {
|
||||
content: '';
|
||||
display: block;
|
||||
background-image: url(/assets/marianne.svg);
|
||||
background-position: 0 -0.046875rem;
|
||||
background-size: 2.0625rem 0.84375rem;
|
||||
height: 0.75rem;
|
||||
margin-bottom: 0.1rem;
|
||||
width: 2.0625rem;
|
||||
}
|
||||
|
||||
.Header-beforeLogo::after {
|
||||
content: '';
|
||||
display: block;
|
||||
background-image: url(/assets/gouvernement.svg), url(/assets/devise.svg);
|
||||
background-repeat: no-repeat, no-repeat;
|
||||
background-size: 108.8px 10px, 40px 29px;
|
||||
background-position: 0 3px, 0 18.9px;
|
||||
width: 108.8px;
|
||||
height: 48px;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Marianne';
|
||||
src: url('/assets/fonts/Marianne-Regular-subset.woff2') format('woff2');
|
||||
@@ -37,196 +69,3 @@
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.Header-beforeLogo {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.Header-beforeLogo::before {
|
||||
content: '';
|
||||
display: block;
|
||||
background-image: url(/assets/marianne.svg);
|
||||
background-position: 0 -0.046875rem;
|
||||
background-size: 2.0625rem 0.84375rem;
|
||||
height: 0.75rem;
|
||||
margin-bottom: 0.1rem;
|
||||
width: 2.0625rem;
|
||||
}
|
||||
|
||||
.Header-beforeLogo::after {
|
||||
content: '';
|
||||
display: block;
|
||||
background-image: url(/assets/gouvernement.svg), url(/assets/devise.svg);
|
||||
background-repeat: no-repeat, no-repeat;
|
||||
background-size: 108.8px 10px, 40px 29px;
|
||||
background-position: 0 3px, 0 18.9px;
|
||||
width: 108.8px;
|
||||
height: 48px;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
:root {
|
||||
--fonts-sans: Marianne, "Mona Sans VF", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans Backtick Fix", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
--global-font-body: var(--fonts-sans);
|
||||
|
||||
--avatar-cap-height: 0.7;
|
||||
|
||||
/* use hand-picked variables from UI-kit and apply them to meet's variables */
|
||||
--c--globals--colors--brand-050: #EDF0FF;
|
||||
--c--globals--colors--brand-100: #DAE2FF;
|
||||
--c--globals--colors--brand-250: #A2B6FF;
|
||||
--c--globals--colors--brand-550: #3E5DE7;
|
||||
--c--globals--colors--brand-600: #304DDF;
|
||||
--c--globals--colors--brand-650: #2845C1;
|
||||
--c--globals--colors--brand-700: #223E9E;
|
||||
--c--globals--colors--gray-050: #F0F0F3;
|
||||
--c--globals--colors--gray-100: #DFE2EA;
|
||||
--c--globals--colors--gray-150: #CFD5DE;
|
||||
--c--globals--colors--gray-250: #B2B9C7;
|
||||
--c--globals--colors--gray-300: #A4ABBC;
|
||||
--c--globals--colors--gray-550: #626A80;
|
||||
--c--globals--colors--gray-600: #555E74;
|
||||
--c--globals--colors--gray-650: #4A5267;
|
||||
--c--globals--colors--gray-700: #3F4759;
|
||||
--c--globals--colors--gray-750: #363B4C;
|
||||
--c--globals--colors--gray-800: #2B303D;
|
||||
--c--globals--colors--gray-850: #222631;
|
||||
--c--globals--colors--gray-900: #181B24;
|
||||
--c--globals--colors--white-050: #F6F8F90D;
|
||||
--c--globals--colors--white-950: #F6F8F9F2;
|
||||
--c--globals--colors--black-050: #181B240D;
|
||||
--c--globals--colors--black-850: #181B24D9;
|
||||
--c--globals--colors--error-550: #CF202D;
|
||||
--c--globals--colors--error-650: #9D2227;
|
||||
|
||||
--colors-primary-800: var(--c--contextuals--background--semantic--brand--primary);
|
||||
--colors-primary: var(--c--contextuals--background--semantic--brand--primary);
|
||||
--colors-primary-action: var(--c--contextuals--background--semantic--brand--primary-hover);
|
||||
--colors-default-subtle-text: var(--c--contextuals--content--semantic--neutral--secondary);
|
||||
--colors-default-text: var(--c--contextuals--content--semantic--neutral--primary);
|
||||
--colors-primary-subtle: var(--c--contextuals--background--semantic--brand--secondary);
|
||||
--colors-primary-subtle-text: var(--c--contextuals--content--semantic--brand--secondary);
|
||||
--colors-greyscale-250: var(--c--globals--colors--gray-100);
|
||||
/* for the room's page background color */
|
||||
--colors-primary-dark-50: var(--c--globals--colors--gray-900);
|
||||
|
||||
/* light theme specific UI-kit semantic values */
|
||||
--c--contextuals--background--semantic--brand--primary: var(--c--globals--colors--brand-550);
|
||||
--c--contextuals--background--semantic--brand--primary-hover: var(--c--globals--colors--brand-650);
|
||||
--c--contextuals--content--semantic--brand--on-brand: var(--c--globals--colors--brand-050);
|
||||
--c--contextuals--content--semantic--neutral--secondary: var(--c--globals--colors--gray-600);
|
||||
--c--contextuals--content--semantic--neutral--primary: var(--c--globals--colors--gray-850);
|
||||
--c--contextuals--background--semantic--brand--secondary: var(--c--globals--colors--brand-100);
|
||||
--c--contextuals--content--semantic--brand--secondary: var(--c--globals--colors--brand-600);
|
||||
--c--contextuals--content--semantic--brand--tertiary: var(--c--globals--colors--brand-550);
|
||||
--c--contextuals--background--semantic--contextual--primary: var(--c--globals--colors--black-050);
|
||||
--c--contextuals--content--semantic--contextual--primary: var(--c--globals--colors--white-950);
|
||||
--c--contextuals--background--semantic--error--primary: var(--c--globals--colors--error-550);
|
||||
--c--contextuals--background--semantic--error--primary-hover: var(--c--globals--colors--error-650);
|
||||
--c--contextuals--background--semantic--neutral--tertiary: var(--c--globals--colors--gray-050);
|
||||
--c--contextuals--border--semantic--neutral--tertiary: var(--c--globals--colors--gray-150);
|
||||
--c--contextuals--content--semantic--neutral--tertiary: var(--c--globals--colors--gray-550);
|
||||
|
||||
/* try to match the UI-kit's control border color, but keep a 3:1 contrast ratio with white to pass WCAG rules on input borders */
|
||||
--colors-control-border: #8996A9;
|
||||
}
|
||||
|
||||
/* dark theme matching (room page) */
|
||||
.Box--variant-dark,
|
||||
.Tooltip,
|
||||
#control-bar {
|
||||
--c--contextuals--background--semantic--brand--primary: var(--c--globals--colors--brand-550);
|
||||
--c--contextuals--background--semantic--brand--primary-hover: var(--c--globals--colors--brand-650);
|
||||
--c--contextuals--content--semantic--brand--on-brand: var(--c--globals--colors--brand-050);
|
||||
--c--contextuals--content--semantic--neutral--secondary: var(--c--globals--colors--gray-100);
|
||||
--c--contextuals--content--semantic--neutral--primary: var(--c--globals--colors--gray-050);
|
||||
--c--contextuals--background--semantic--brand--secondary: var(--c--globals--colors--brand-700);
|
||||
--c--contextuals--content--semantic--brand--secondary: var(--c--globals--colors--brand-100);
|
||||
--c--contextuals--content--semantic--brand--tertiary: var(--c--globals--colors--brand-250);
|
||||
--c--contextuals--background--semantic--contextual--primary: var(--c--globals--colors--white-050);
|
||||
--c--contextuals--content--semantic--contextual--primary: var(--c--globals--colors--black-850);
|
||||
--c--contextuals--background--semantic--error--primary: var(--c--globals--colors--error-550);
|
||||
--c--contextuals--background--semantic--error--primary-hover: var(--c--globals--colors--error-650);
|
||||
--c--contextuals--background--semantic--neutral--secondary: var(--c--globals--colors--gray-700);
|
||||
--c--contextuals--background--semantic--neutral--secondary-hover: var(--c--globals--colors--gray-750);
|
||||
--c--contextuals--content--semantic--neutral--on-neutral: var(--c--globals--colors--gray-050);
|
||||
--c--contextuals--background--surface--primary: var(--c--globals--colors--gray-800);
|
||||
--c--contextuals--background--semantic--neutral--tertiary: var(--c--globals--colors--gray-750);
|
||||
--c--contextuals--border--semantic--neutral--tertiary: var(--c--globals--colors--gray-700);
|
||||
--c--contextuals--content--semantic--neutral--tertiary: var(--c--globals--colors--gray-250);
|
||||
}
|
||||
|
||||
.Button--variant-primary {
|
||||
background-color: var(--c--contextuals--background--semantic--brand--primary);
|
||||
color: var(--c--contextuals--content--semantic--brand--on-brand);
|
||||
}
|
||||
.Button--variant-primary:is([data-hovered], [data-pressed]) {
|
||||
background-color: var(--c--contextuals--background--semantic--brand--primary-hover);
|
||||
}
|
||||
|
||||
.Button--variant-secondary {
|
||||
border-color: #CFD5DE;
|
||||
}
|
||||
|
||||
.Button--variant-secondaryText {
|
||||
background: transparent;
|
||||
color: var(--c--contextuals--content--semantic--brand--tertiary);
|
||||
}
|
||||
|
||||
.Button--variant-secondaryText:is([data-hovered], [data-pressed]) {
|
||||
background-color: var(--c--contextuals--background--semantic--contextual--primary);
|
||||
}
|
||||
|
||||
.Button--variant-primaryDark {
|
||||
background-color: var(--c--contextuals--background--semantic--neutral--secondary);
|
||||
color: var(--c--contextuals--content--semantic--neutral--secondary);
|
||||
}
|
||||
.Button--variant-primaryDark:is([data-hovered], [data-pressed]) {
|
||||
background-color: var(--c--contextuals--background--semantic--neutral--secondary-hover);
|
||||
}
|
||||
|
||||
.Button--variant-danger {
|
||||
background-color: var(--c--contextuals--background--semantic--error--primary);
|
||||
color: var(--c--contextuals--content--semantic--brand--on-brand);
|
||||
}
|
||||
.Button--variant-danger:is([data-hovered], [data-pressed]) {
|
||||
background-color: var(--c--contextuals--background--semantic--error--primary-hover);
|
||||
}
|
||||
|
||||
.Button--variant-primaryTextDark {
|
||||
background: transparent;
|
||||
color: var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||
}
|
||||
|
||||
.Button--variant-primaryTextDark:is([data-hovered], [data-pressed]) {
|
||||
background-color: var(--c--contextuals--background--semantic--contextual--primary);
|
||||
}
|
||||
|
||||
.Box--variant-dark.Box--type-popover {
|
||||
background-color: var(--c--contextuals--background--surface--primary);
|
||||
}
|
||||
|
||||
.Tooltip {
|
||||
background: var(--c--contextuals--background--semantic--neutral--tertiary);
|
||||
border-color: var(--c--contextuals--border--semantic--neutral--tertiary);
|
||||
color: var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||
}
|
||||
|
||||
/* switch component */
|
||||
.indicator,
|
||||
[data-selected="true"] .indicator::before {
|
||||
background-color: var(--c--contextuals--content--semantic--contextual--primary);
|
||||
}
|
||||
.indicator .cross {
|
||||
color: var(--c--contextuals--content--semantic--contextual--primary);
|
||||
}
|
||||
label:not([data-selected="true"]) .indicator::before,
|
||||
[data-selected="true"] .indicator {
|
||||
background: var(--colors-primary-800);
|
||||
}
|
||||
/* end switch component */
|
||||
|
||||
/* prevent impacting the toolbar buttons (primaryDark variant) */
|
||||
.Button--size-default:is(.Button--variant-primary, .Button--variant-secondary) {
|
||||
padding-block: 0.4375rem;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
La Suite Meet offers a room recording feature that is currently in beta, with ongoing improvements planned.
|
||||
|
||||
The feature allows users to record their room sessions. When a recording is complete, the user who started it and the room's owners and administrators receive a notification with a link to download the recorded file. The transcript summary is sent to the user who started the recording. Recordings are automatically deleted after `RECORDING_EXPIRATION_DAYS`.
|
||||
The feature allows users to record their room sessions. When a recording is complete, the room owner receives a notification with a link to download the recorded file. Recordings are automatically deleted after `RECORDING_EXPIRATION_DAYS`.
|
||||
|
||||
It uses LiveKit Egress to record room sessions. For reference, see the [LiveKit Egress repository](https://github.com/livekit/egress) and the [official documentation](https://docs.livekit.io/home/egress/overview/).
|
||||
|
||||
@@ -89,7 +89,6 @@ sequenceDiagram
|
||||
| Option | Type | Default | Description |
|
||||
| --------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **RECORDING_ENABLE** | Boolean | `False` | Enable or disable the room recording feature. |
|
||||
| **RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED**| Boolean | `True` | When enabled, any authenticated participant currently connected to a room with the `trusted` or `public` access level can start and stop recordings. When disabled, only room admins and owners can. On `restricted` rooms, only admins and owners can record. |
|
||||
| **RECORDING_OUTPUT_FOLDER** | String | `"recordings"` | Folder/prefix where recordings are stored in the object storage. |
|
||||
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
|
||||
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Room purge
|
||||
|
||||
Rooms pile up over time and most of them are only used once. The `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for a configurable number of days, and the rooms deleted by their owner for longer than a configurable retention period. It is disabled by default.
|
||||
Rooms pile up over time and most of them are only used once. The `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for a configurable number of days. It is disabled by default.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -12,21 +12,16 @@ A room is inactive when:
|
||||
|
||||
Rooms that existed before this feature was deployed are considered started on the day of the release, so none of them can be purged before a full inactivity period has elapsed.
|
||||
|
||||
Deleting a room through the API only soft deletes it: the room is hidden, its link tells visitors the meeting was deleted, and it keeps its slug and PIN code. A soft-deleted room is purged:
|
||||
|
||||
- when it is inactive, as described above: a deleted room is never considered started again, so its inactivity period keeps running, or
|
||||
- when it was deleted more than `ROOM_DELETED_RETENTION_DAYS` days ago.
|
||||
|
||||
The command is meant to run once a day. The Helm chart schedules it in `backend.cronjobs` (`purge-inactive-rooms`, 01:00); it does nothing until `ROOM_INACTIVITY_DELETION_DAYS` or `ROOM_DELETED_RETENTION_DAYS` is set.
|
||||
The command is meant to run once a day. The Helm chart schedules it in `backend.cronjobs` (`purge-inactive-rooms`, 01:00); it does nothing until `ROOM_INACTIVITY_DELETION_DAYS` is set.
|
||||
|
||||
```bash
|
||||
python manage.py purge_inactive_rooms # delete the inactive and deleted rooms
|
||||
python manage.py purge_inactive_rooms # delete the inactive rooms
|
||||
python manage.py purge_inactive_rooms --dry-run # only list the rooms that would be deleted
|
||||
```
|
||||
|
||||
## Rooms that are kept
|
||||
|
||||
A recording can only be reached through its room. A room is kept, past its inactivity or retention period, as long as it holds a saved recording its users may still access:
|
||||
A recording can only be reached through its room. An inactive room is kept as long as it holds a saved recording its users may still access:
|
||||
|
||||
- with `RECORDING_EXPIRATION_DAYS` set, a saved recording created less than that many days ago,
|
||||
- with `RECORDING_EXPIRATION_DAYS` unset, any saved recording.
|
||||
@@ -38,8 +33,7 @@ The room is deleted from the database, along with its accesses, its telephony PI
|
||||
The recording **files in the bucket are left untouched**: the backend never deletes anything from the storage, it only drops the database entries pointing at it. Removing the files is the job of the bucket lifecycle policy, which should match `RECORDING_EXPIRATION_DAYS` (see the [recording documentation](recording.md)). When the two do not match, the purge leaves objects behind: they become unreachable, since serving a recording requires its database entry, but they keep costing storage.
|
||||
|
||||
⚠️ When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again
|
||||
and can be reused when a meeting is created from that same URL. This also applies to a soft-deleted room: once purged, its link
|
||||
no longer tells visitors the meeting was deleted, and its PIN code can be given to another room.
|
||||
and can be reused when a meeting is created from that same URL.
|
||||
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
|
||||
|
||||
|
||||
@@ -113,33 +113,12 @@ DJANGO_EMAIL_FROM=<your email address>
|
||||
|
||||
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
|
||||
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
||||
#DJANGO_EMAIL_TIMEOUT=30 # Seconds before a stalled SMTP connection times out.
|
||||
|
||||
|
||||
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
|
||||
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://meet.yourdomain.tld/assets/logo-suite-numerique.png"
|
||||
```
|
||||
|
||||
### Feature flags without an analytics backend
|
||||
|
||||
Some features, such as automatic meeting summaries (`summary-enabled`), are gated by a feature flag read from the analytics backend. You do not need an analytics backend to enable them: leave `ANALYTICS_BACKEND` unset and declare the flags in `env.d/common`. No data is sent anywhere.
|
||||
|
||||
```env
|
||||
ANALYTICS_BACKEND_SETTINGS={"feature_flags": {"summary-enabled": True}}
|
||||
```
|
||||
|
||||
You can also set it directly in `compose.yaml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
environment:
|
||||
ANALYTICS_BACKEND_SETTINGS: '{"feature_flags": {"summary-enabled": True}}'
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The value is a Python dictionary, not JSON: write `True`, not `true`. Set it on every container running the backend image.
|
||||
|
||||
## Step 3: Configure your firewall
|
||||
|
||||
If you are using a firewall as it is usually recommended in a production environment you will need to allow the webservice traffic on ports 80 and 443 but also to allow UDP traffic for the WebRTC service.
|
||||
|
||||
@@ -357,7 +357,6 @@ These are the environmental options available on meet backend.
|
||||
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
|
||||
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
|
||||
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
|
||||
| DJANGO_EMAIL_TIMEOUT | Seconds before a stalled SMTP connection times out | 30 |
|
||||
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
|
||||
| EMAIL_BRAND_NAME | Email branding name | |
|
||||
| EMAIL_SUPPORT_EMAIL | Support email address | |
|
||||
@@ -408,9 +407,7 @@ These are the environmental options available on meet backend.
|
||||
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
|
||||
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
|
||||
| ROOM_INACTIVITY_DELETION_DAYS | Days without being started after which a room is purged. Unset to never purge | |
|
||||
| ROOM_DELETED_RETENTION_DAYS | Days after its deletion after which a room is purged. Unset to purge deleted rooms only once inactive | |
|
||||
| RECORDING_ENABLE | Record meeting option | false |
|
||||
| RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED | Let any authenticated participant connected to a trusted or public room start/stop recordings. Set to false to reserve recording to room admins/owners | true |
|
||||
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
|
||||
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
||||
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
|
||||
@@ -418,8 +415,6 @@ These are the environmental options available on meet backend.
|
||||
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
|
||||
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
|
||||
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
|
||||
| ANALYTICS_BACKEND | Analytics backend class, e.g. `core.analytics.posthog.PostHogAnalytics`. Nothing is sent when unset | |
|
||||
| ANALYTICS_BACKEND_SETTINGS | Analytics backend settings. Without a backend, `{"feature_flags": {"summary-enabled": True}}` sets feature flags statically | {} |
|
||||
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
|
||||
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
|
||||
| BREVO_API_KEY | Brevo API key for marketing emails | |
|
||||
|
||||
@@ -24,6 +24,12 @@
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["redis"]
|
||||
},
|
||||
{
|
||||
"groupName": "allowed pylint versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["pylint"],
|
||||
"allowedVersions": "<4.0.0"
|
||||
},
|
||||
{
|
||||
"groupName": "allowed django versions",
|
||||
"matchManagers": ["pep621"],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.35.0"
|
||||
version = "1.34.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.7.0",
|
||||
|
||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
||||
|
||||
[[package]]
|
||||
name = "agents"
|
||||
version = "1.35.0"
|
||||
version = "1.34.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "boto3" },
|
||||
|
||||
@@ -31,6 +31,10 @@ persistent=yes
|
||||
# Specify a configuration file.
|
||||
#rcfile=
|
||||
|
||||
# When enabled, pylint would attempt to guess common misconfiguration and emit
|
||||
# user-friendly hints instead of false-positive error messages
|
||||
suggestion-mode=yes
|
||||
|
||||
# Allow loading of arbitrary C extensions. Extensions are imported into the
|
||||
# active Python interpreter and may run arbitrary code.
|
||||
unsafe-load-any-extension=no
|
||||
|
||||
@@ -286,16 +286,9 @@ class RoomAdmin(admin.ModelAdmin):
|
||||
"get_owner",
|
||||
"created_at",
|
||||
"last_started_at",
|
||||
"deleted_at",
|
||||
]
|
||||
list_filter = ["access_level", "created_at", "last_started_at", "deleted_at"]
|
||||
readonly_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"last_started_at",
|
||||
"deleted_at",
|
||||
]
|
||||
list_filter = ["access_level", "created_at", "last_started_at"]
|
||||
readonly_fields = ["id", "created_at", "updated_at", "last_started_at"]
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
|
||||
|
||||
@@ -39,7 +39,7 @@ def get_analytics() -> AnalyticsBackend:
|
||||
options = getattr(settings, "ANALYTICS_BACKEND_SETTINGS", {}) or {}
|
||||
|
||||
if not dotted_path:
|
||||
return NoOpAnalytics(**options)
|
||||
return NoOpAnalytics()
|
||||
|
||||
backend_class = import_string(dotted_path)
|
||||
return backend_class(**options)
|
||||
|
||||
@@ -53,23 +53,7 @@ class AnalyticsBackend(ABC):
|
||||
|
||||
|
||||
class NoOpAnalytics(AnalyticsBackend):
|
||||
"""
|
||||
Default backend: discards events and serves static feature flags.
|
||||
|
||||
Feature flags can be declared without any analytics provider, e.g.:
|
||||
|
||||
ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
feature_flags: Mapping[str, bool | str | None] | None = None,
|
||||
**_kwargs: Any,
|
||||
) -> None:
|
||||
# Other settings are ignored, so that settings meant for another
|
||||
# backend never break an instance running without one.
|
||||
self._feature_flags = dict(feature_flags or {})
|
||||
"""Default backend: silently discards everything."""
|
||||
|
||||
def identify(self, user: User, properties=None) -> None:
|
||||
"""No-op: discards identify calls."""
|
||||
@@ -79,10 +63,3 @@ class NoOpAnalytics(AnalyticsBackend):
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""No-op: nothing to flush."""
|
||||
|
||||
def get_user_feature_flags(
|
||||
self,
|
||||
user: User,
|
||||
) -> Mapping[UserFeatureFlag, bool | str | None]:
|
||||
"""Return the feature flags declared in settings, for any user."""
|
||||
return self._feature_flags
|
||||
|
||||
@@ -9,7 +9,6 @@ class AnalyticsEvent(StrEnum):
|
||||
# Rooms
|
||||
ROOM_CREATED = "room_created"
|
||||
ROOM_UPDATED = "room_updated"
|
||||
ROOM_DELETED = "room_deleted"
|
||||
|
||||
# Roomkit (meeting-room SIP devices)
|
||||
ROOMKIT_JOINED = "roomkit_joined"
|
||||
|
||||
@@ -44,20 +44,6 @@ def get_frontend_configuration(request):
|
||||
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
|
||||
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||
"max_duration": settings.RECORDING_MAX_DURATION,
|
||||
"encoding": {
|
||||
"is_custom_enabled": settings.RECORDING_CUSTOM_ENCODING_ENABLED,
|
||||
"available_resolutions": list(
|
||||
settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS.keys()
|
||||
),
|
||||
"available_profiles": list(
|
||||
settings.RECORDING_ENCODING_AVAILABLE_PROFILES.keys()
|
||||
),
|
||||
"default_resolution": settings.RECORDING_ENCODING_DEFAULT_RESOLUTION,
|
||||
"default_profile": settings.RECORDING_ENCODING_DEFAULT_PROFILE,
|
||||
},
|
||||
"authenticated_participants_enabled": (
|
||||
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED
|
||||
),
|
||||
},
|
||||
"background_image": {
|
||||
"upload_is_enabled": settings.FILE_UPLOAD_ENABLED,
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"""Exceptions and guards shared by the API endpoints."""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import exceptions, status
|
||||
|
||||
from core import models
|
||||
|
||||
|
||||
class RoomSoftDeleted(exceptions.APIException):
|
||||
"""Raised when the requested room has been soft deleted."""
|
||||
|
||||
status_code = status.HTTP_410_GONE
|
||||
default_detail = _("This room has been deleted.")
|
||||
default_code = "room_deleted"
|
||||
|
||||
|
||||
def ensure_room_not_deleted(resource):
|
||||
"""Raise a 410 Gone if the resource is a soft-deleted room.
|
||||
|
||||
Accepts a room or its parent resource, as referenced by accesses. Call it
|
||||
after permissions are checked, to avoid revealing room.
|
||||
"""
|
||||
if isinstance(resource, models.Room):
|
||||
room = resource
|
||||
else:
|
||||
room = getattr(resource, "room", None)
|
||||
|
||||
if room is not None and room.is_deleted:
|
||||
raise RoomSoftDeleted()
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Row locks guarding the API write paths."""
|
||||
|
||||
import contextlib
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
from core import models
|
||||
from core.api.exceptions import ensure_room_not_deleted
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def room_locked_for_update(room):
|
||||
"""Lock a room's row for the duration of a write."""
|
||||
with transaction.atomic():
|
||||
locked_room = models.Room.all_objects.select_for_update().get(pk=room.pk)
|
||||
ensure_room_not_deleted(locked_room)
|
||||
yield locked_room
|
||||
@@ -187,29 +187,29 @@ class IsPresentInMeeting(permissions.BasePermission):
|
||||
return False
|
||||
|
||||
|
||||
class ParticipantCapability(permissions.BasePermission):
|
||||
"""Base permission for in-meeting capabilities opened up to participants.
|
||||
class CanManageLobby(permissions.BasePermission):
|
||||
"""Grant lobby management (list/accept/deny waiting participants).
|
||||
|
||||
- Room admins/owners always hold the capability.
|
||||
- When the room access level is TRUSTED or PUBLIC, any
|
||||
session-authenticated user who is currently connected to the meeting
|
||||
holds it too.
|
||||
- Room admins/owners can always manage the lobby.
|
||||
- When the room access level is TRUSTED, any authenticated user who is
|
||||
currently connected to the meeting can manage the lobby. Presence is
|
||||
verified cache-first (Redis), falling back to the LiveKit API.
|
||||
|
||||
Access level is always read fresh from the DB; only presence is cached,
|
||||
so changing the room access level takes effect immediately.
|
||||
so changing the room to RESTRICTED takes effect immediately.
|
||||
"""
|
||||
|
||||
message = "You are not allowed to perform this action."
|
||||
enabled_setting = None
|
||||
message = "You are not allowed to manage this room's lobby."
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check privileges first, then the participant presence path."""
|
||||
# pylint: disable=too-many-return-statements
|
||||
def has_object_permission(self, request, view, obj): # noqa: PLR0911
|
||||
"""Check privileges first, then the trusted-room presence path."""
|
||||
user = request.user
|
||||
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Product choice: these capabilities are reserved for session-authenticated
|
||||
# Product choice: lobby management is reserved for session-authenticated
|
||||
# users with a real account, not holders of a LiveKit room token.
|
||||
if request.auth and hasattr(request.auth, "video"):
|
||||
return False
|
||||
@@ -217,31 +217,16 @@ class ParticipantCapability(permissions.BasePermission):
|
||||
if obj.is_administrator_or_owner(user):
|
||||
return True
|
||||
|
||||
is_open_room = obj.access_level in (
|
||||
RoomAccessLevel.TRUSTED,
|
||||
RoomAccessLevel.PUBLIC,
|
||||
)
|
||||
is_enabled = not self.enabled_setting or getattr(settings, self.enabled_setting)
|
||||
if not (is_open_room and is_enabled):
|
||||
if obj.access_level != RoomAccessLevel.TRUSTED:
|
||||
return False
|
||||
|
||||
self.message = "You must be connected to the meeting to manage its lobby."
|
||||
|
||||
try:
|
||||
return ParticipantsManagement().check_if_in_meeting_cached(
|
||||
room_name=str(obj.pk), identity=str(user.sub)
|
||||
)
|
||||
except (ParticipantNotFoundException, ParticipantsManagementException):
|
||||
except ParticipantNotFoundException:
|
||||
return False
|
||||
except ParticipantsManagementException:
|
||||
return False
|
||||
|
||||
|
||||
class CanManageLobby(ParticipantCapability):
|
||||
"""Grant lobby management (list/accept/deny waiting participants).
|
||||
|
||||
Public rooms have no lobby: the endpoints short-circuit there, so opening
|
||||
them to participants exposes nothing.
|
||||
"""
|
||||
|
||||
|
||||
class CanManageRecording(ParticipantCapability):
|
||||
"""Grant starting/stopping a room recording (screen recording or transcript)."""
|
||||
|
||||
enabled_setting = "RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED"
|
||||
|
||||
@@ -26,7 +26,6 @@ from rest_framework.exceptions import PermissionDenied
|
||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
|
||||
from core import models, utils
|
||||
from core.api.exceptions import ensure_room_not_deleted
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -115,7 +114,6 @@ class ResourceAccessSerializerMixin:
|
||||
_("You must be administrator or owner of a room to add accesses to it.")
|
||||
)
|
||||
|
||||
ensure_room_not_deleted(resource)
|
||||
return resource
|
||||
|
||||
|
||||
|
||||
@@ -42,9 +42,7 @@ from rest_framework.settings import api_settings
|
||||
|
||||
from core import analytics, enums, models, utils
|
||||
from core.api import throttling
|
||||
from core.api.exceptions import ensure_room_not_deleted
|
||||
from core.api.filters import ListFileFilter
|
||||
from core.api.locks import room_locked_for_update
|
||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||
from core.recording.enums import FileExtension
|
||||
from core.recording.event.authentication import RecordingProcessWebhookAuthentication
|
||||
@@ -79,7 +77,7 @@ from core.services.participants_management import (
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
from core.services.room_creation import RoomCreation
|
||||
from core.services.room_management import RoomManagement, RoomManagementException
|
||||
from core.services.room_management import RoomManagement
|
||||
from core.services.room_roles import (
|
||||
RoomRoleError,
|
||||
RoomRoleService,
|
||||
@@ -263,7 +261,7 @@ class UserViewSet(
|
||||
return drf_response.Response(data)
|
||||
|
||||
|
||||
class RoomViewSet( # pylint: disable=too-many-public-methods
|
||||
class RoomViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
@@ -289,9 +287,10 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
|
||||
filter_kwargs = {"pk": self.kwargs["pk"]}
|
||||
except ValueError:
|
||||
filter_kwargs = {"slug": slugify(self.kwargs["pk"])}
|
||||
obj = get_object_or_404(models.Room.all_objects, **filter_kwargs)
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
obj = get_object_or_404(queryset, **filter_kwargs)
|
||||
# May raise a permission denied
|
||||
self.check_object_permissions(self.request, obj)
|
||||
ensure_room_not_deleted(obj)
|
||||
return obj
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
@@ -343,27 +342,6 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return drf_response.Response(serializer.data)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Soft delete the room and close its LiveKit room.
|
||||
|
||||
The room and its recordings are kept in database for traceability.
|
||||
"""
|
||||
try:
|
||||
RoomManagement.soft_delete(instance)
|
||||
except RoomManagementException as e:
|
||||
raise drf_exceptions.APIException(
|
||||
"Could not delete the room, please try again."
|
||||
) from e
|
||||
|
||||
analytics.capture(
|
||||
self.request.user,
|
||||
analytics.AnalyticsEvent.ROOM_DELETED,
|
||||
{
|
||||
"room_id": str(instance.pk),
|
||||
"access_level": instance.access_level,
|
||||
},
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as owner of the newly created room.
|
||||
|
||||
@@ -406,20 +384,12 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Persist the room update under a row lock, then sync metadata to LiveKit.
|
||||
"""Persist the room update, then sync metadata to LiveKit."""
|
||||
|
||||
The lock is what keeps a concurrent soft delete from being undone: the
|
||||
full-row write of `save()` would otherwise carry back the `deleted_at`
|
||||
read before the deletion committed.
|
||||
"""
|
||||
old_configuration = serializer.instance.configuration
|
||||
old_access_level = serializer.instance.access_level
|
||||
|
||||
with room_locked_for_update(serializer.instance) as locked_room:
|
||||
serializer.instance = locked_room
|
||||
|
||||
old_configuration = locked_room.configuration
|
||||
old_access_level = locked_room.access_level
|
||||
|
||||
room = serializer.save()
|
||||
room = serializer.save()
|
||||
|
||||
if (
|
||||
room.configuration == old_configuration
|
||||
@@ -434,7 +404,7 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
|
||||
methods=["post"],
|
||||
url_path="start-recording",
|
||||
permission_classes=[
|
||||
permissions.CanManageRecording,
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
],
|
||||
)
|
||||
@FeatureFlag.require("recording")
|
||||
@@ -467,7 +437,11 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
|
||||
mode=mode,
|
||||
options=options_data,
|
||||
)
|
||||
recording.grant_initial_accesses(starter=self.request.user)
|
||||
models.RecordingAccess.objects.create(
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording=recording,
|
||||
)
|
||||
|
||||
except (DjangoValidationError, IntegrityError):
|
||||
# DjangoValidationError covers the Python-level check (full_clean);
|
||||
@@ -511,7 +485,7 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
|
||||
methods=["post"],
|
||||
url_path="stop-recording",
|
||||
permission_classes=[
|
||||
permissions.CanManageRecording,
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
],
|
||||
)
|
||||
@FeatureFlag.require("recording")
|
||||
@@ -1065,13 +1039,6 @@ class ResourceAccessViewSet(
|
||||
|
||||
return queryset
|
||||
|
||||
def get_object(self):
|
||||
"""Accesses to a soft-deleted room can be read but no longer modified."""
|
||||
access = super().get_object()
|
||||
if self.request.method not in drf_permissions.SAFE_METHODS:
|
||||
ensure_room_not_deleted(access.resource)
|
||||
return access
|
||||
|
||||
|
||||
class RecordingViewSet(
|
||||
mixins.DestroyModelMixin,
|
||||
|
||||
@@ -82,6 +82,9 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
"User info contained an invalid sub claim"
|
||||
) from err
|
||||
|
||||
if len(sub) > 255:
|
||||
raise SuspiciousOperation("User info contained an invalid sub claim")
|
||||
|
||||
try:
|
||||
return User.objects.get(sub=sub)
|
||||
except User.DoesNotExist:
|
||||
|
||||
@@ -4,113 +4,21 @@
|
||||
# ruff: noqa: PLR0913
|
||||
|
||||
import logging
|
||||
from dataclasses import asdict
|
||||
from functools import lru_cache
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
import requests
|
||||
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
|
||||
from menshen_client import Configuration, IntrospectionRequest, TokenExchangeClient
|
||||
from menshen_client.exceptions import ResponseParsingError
|
||||
from rest_framework import authentication, exceptions
|
||||
|
||||
from core.models import Application, ApplicationScope
|
||||
from core.models import Application
|
||||
from core.services import jwt_token
|
||||
from core.validators import sub_validator
|
||||
|
||||
User = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_token(request, token_type):
|
||||
"""Extract the token from the Authorization header.
|
||||
|
||||
Args:
|
||||
request: The incoming request
|
||||
token_type: Expected Authorization scheme (e.g. Bearer), matched
|
||||
case-insensitively
|
||||
|
||||
Returns:
|
||||
Token string, or None if the request carries no token of this type
|
||||
|
||||
Raises:
|
||||
AuthenticationFailed: If the Authorization header is malformed
|
||||
"""
|
||||
|
||||
auth_header = authentication.get_authorization_header(request).split()
|
||||
|
||||
if not auth_header or auth_header[0].lower() != token_type.lower().encode():
|
||||
return None
|
||||
|
||||
if len(auth_header) != 2:
|
||||
logger.warning("Invalid token header format")
|
||||
raise exceptions.AuthenticationFailed("Invalid token header.")
|
||||
|
||||
try:
|
||||
return auth_header[1].decode("utf-8")
|
||||
except UnicodeError as e:
|
||||
logger.warning("Token decode error: %s", e)
|
||||
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e
|
||||
|
||||
|
||||
def get_or_create_user_by_sub(sub, email=None):
|
||||
"""Retrieve the user matching a token subject, creating it if allowed.
|
||||
|
||||
Shared by the resource server backends, so that tokens introspected with the
|
||||
OIDC provider or with Menshen resolve users the same way.
|
||||
|
||||
Args:
|
||||
sub: Subject identifier from the introspected token
|
||||
email: Email to store on a newly created user
|
||||
|
||||
Returns:
|
||||
User instance, or None if unknown and OIDC_CREATE_USER is unset
|
||||
|
||||
Raises:
|
||||
SuspiciousOperation: If the sub is invalid, the email of a user to create
|
||||
is already used, or the user account is disabled
|
||||
"""
|
||||
if sub is not None:
|
||||
sub = str(sub)
|
||||
|
||||
try:
|
||||
sub_validator(sub)
|
||||
except ValidationError as err:
|
||||
raise SuspiciousOperation("User info contained an invalid sub claim") from err
|
||||
|
||||
try:
|
||||
user = User.objects.get(sub=sub)
|
||||
except User.DoesNotExist:
|
||||
if not settings.OIDC_CREATE_USER:
|
||||
return None
|
||||
|
||||
# Users are never identified by email here. But a second account with the
|
||||
# same email (e.g. provisionnal account) would break the lookups by email:
|
||||
# refuse it.
|
||||
if (
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
|
||||
and email
|
||||
and User.objects.filter(email__iexact=email).exists()
|
||||
):
|
||||
logger.warning("Email already used, not creating user: %s", sub)
|
||||
raise SuspiciousOperation(
|
||||
"Email already used by another account."
|
||||
) from None
|
||||
|
||||
user = User(sub=sub, email=email)
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
|
||||
if not user.is_active:
|
||||
logger.warning("Inactive user attempted authentication: %s", user.pk)
|
||||
raise SuspiciousOperation("User account is disabled.")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class BaseJWTAuthentication(authentication.BaseAuthentication):
|
||||
"""Base JWT authentication class."""
|
||||
|
||||
@@ -164,12 +72,25 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
|
||||
if not self.is_enabled:
|
||||
return None
|
||||
|
||||
token = get_token(request, self._token_type)
|
||||
auth_header = authentication.get_authorization_header(request).split()
|
||||
|
||||
if token is None:
|
||||
if (
|
||||
not auth_header
|
||||
or auth_header[0].lower() != self._token_type.lower().encode()
|
||||
):
|
||||
# Defer to next authentication backend
|
||||
return None
|
||||
|
||||
if len(auth_header) != 2:
|
||||
logger.warning("Invalid token header format")
|
||||
raise exceptions.AuthenticationFailed("Invalid token header.")
|
||||
|
||||
try:
|
||||
token = auth_header[1].decode("utf-8")
|
||||
except UnicodeError as e:
|
||||
logger.warning("Token decode error: %s", e)
|
||||
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e
|
||||
|
||||
return self.authenticate_credentials(token)
|
||||
|
||||
def decode_jwt(self, token):
|
||||
@@ -335,113 +256,6 @@ class AddonsJWTAuthentication(BaseJWTAuthentication):
|
||||
)
|
||||
|
||||
|
||||
# Menshen grants scopes following La Suite's "service:resource:action" convention.
|
||||
# Map them to the scopes expected by the external API permissions.
|
||||
MENSHEN_SCOPES_MAPPING = {
|
||||
"meet:room:create": ApplicationScope.ROOMS_CREATE,
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(1)
|
||||
def _get_token_exchange_client():
|
||||
return TokenExchangeClient(
|
||||
config=Configuration(
|
||||
settings.MENSHEN_CLIENT_ID,
|
||||
settings.MENSHEN_CLIENT_SECRET,
|
||||
settings.MENSHEN_SERVER_URL,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class MenshenAuthentication(authentication.BaseAuthentication):
|
||||
"""Authentication for tokens exchanged through Menshen.
|
||||
|
||||
Menshen is La Suite's OAuth 2.0 token exchange server (RFC 8693): another service
|
||||
exchanges its user's access token for a token targeting Meet, then calls the external
|
||||
API on behalf of that user. Tokens are validated by introspection (RFC 7662). Menshen
|
||||
only reports a token as active when Meet is among its audiences.
|
||||
"""
|
||||
|
||||
def authenticate(self, request):
|
||||
"""Introspect the bearer token with Menshen.
|
||||
|
||||
Returns:
|
||||
Tuple of (user, payload) if the token is active, None otherwise
|
||||
"""
|
||||
|
||||
if not settings.MENSHEN_ENABLED:
|
||||
return None
|
||||
|
||||
token = get_token(request, "bearer")
|
||||
|
||||
if token is None:
|
||||
return None
|
||||
|
||||
client = _get_token_exchange_client()
|
||||
|
||||
try:
|
||||
response = client.introspect(IntrospectionRequest(token=token))
|
||||
except (requests.RequestException, ResponseParsingError) as e:
|
||||
logger.warning("Menshen introspection failed: %s", e)
|
||||
return None
|
||||
|
||||
# Permission classes expect a dict payload in request.auth
|
||||
payload = asdict(response)
|
||||
|
||||
if not payload.get("active"):
|
||||
# Not a Menshen token, or an expired or revoked one: defer to next
|
||||
# authentication backend
|
||||
return None
|
||||
|
||||
user = self.get_or_create_user(payload)
|
||||
|
||||
scopes = payload.get("scope") or ""
|
||||
payload["scope"] = [
|
||||
MENSHEN_SCOPES_MAPPING[scope]
|
||||
for scope in scopes.split()
|
||||
if scope in MENSHEN_SCOPES_MAPPING
|
||||
]
|
||||
|
||||
return (user, payload)
|
||||
|
||||
def get_or_create_user(self, payload):
|
||||
"""Retrieve or create the user from the introspection response.
|
||||
|
||||
Menshen forwards the `sub` and `email` of the subject token, as introspected
|
||||
with the OIDC provider.
|
||||
|
||||
Args:
|
||||
payload: Introspection response dict
|
||||
|
||||
Returns:
|
||||
User instance
|
||||
|
||||
Raises:
|
||||
AuthenticationFailed: If user not found or inactive
|
||||
"""
|
||||
|
||||
sub = payload.get("sub")
|
||||
|
||||
if not sub:
|
||||
logger.warning("Missing 'sub' in Menshen introspection response")
|
||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||
|
||||
try:
|
||||
user = get_or_create_user_by_sub(sub, email=payload.get("email"))
|
||||
except SuspiciousOperation as e:
|
||||
raise exceptions.AuthenticationFailed(str(e)) from e
|
||||
|
||||
if user is None:
|
||||
logger.warning("User not found: %s", sub)
|
||||
raise exceptions.AuthenticationFailed("User not found.")
|
||||
|
||||
return user
|
||||
|
||||
def authenticate_header(self, request):
|
||||
"""Return authentication scheme for WWW-Authenticate header."""
|
||||
return "bearer"
|
||||
|
||||
|
||||
class ResourceServerBackend(LaSuiteBackend):
|
||||
"""OIDC Resource Server backend for user creation and retrieval."""
|
||||
|
||||
@@ -471,4 +285,28 @@ class ResourceServerBackend(LaSuiteBackend):
|
||||
logger.debug(message)
|
||||
raise SuspiciousOperation(message)
|
||||
|
||||
return get_or_create_user_by_sub(sub)
|
||||
user = self.get_user(access_token, id_token, payload)
|
||||
|
||||
if user is None and settings.OIDC_CREATE_USER:
|
||||
user = self.create_user(sub)
|
||||
|
||||
if user is not None and not user.is_active:
|
||||
logger.warning("Inactive user attempted authentication: %s", user.pk)
|
||||
raise SuspiciousOperation("User account is disabled.")
|
||||
|
||||
return user
|
||||
|
||||
def create_user(self, sub):
|
||||
"""Create new user from subject claim.
|
||||
|
||||
Args:
|
||||
sub: Subject identifier from token
|
||||
|
||||
Returns:
|
||||
Newly created User instance
|
||||
"""
|
||||
user = self.UserModel(sub=sub)
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
|
||||
return user
|
||||
|
||||
@@ -21,14 +21,11 @@ from rest_framework import (
|
||||
from rest_framework import (
|
||||
status as drf_status,
|
||||
)
|
||||
from rest_framework.generics import get_object_or_404
|
||||
|
||||
from core import analytics, api, models
|
||||
from core.api.exceptions import ensure_room_not_deleted
|
||||
from core.api.feature_flag import FeatureFlag
|
||||
from core.api.locks import room_locked_for_update
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
from core.services.room_management import RoomManagement, RoomManagementException
|
||||
from core.services.room_management import RoomManagement
|
||||
from core.services.transit_code import TransitCodeService
|
||||
|
||||
from ..services.provisional_user_service import (
|
||||
@@ -145,7 +142,6 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
|
||||
class RoomViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
@@ -163,18 +159,13 @@ class RoomViewSet(
|
||||
- create: Create a new room owned by the user (requires 'rooms:create' scope)
|
||||
- partial_update: Update a room's access level and configuration, for
|
||||
administrators and owners only (requires 'rooms:update' scope)
|
||||
- destroy: Soft delete a room and close its LiveKit room, for owners only
|
||||
(requires 'rooms:delete' scope)
|
||||
|
||||
Detail operations on a soft-deleted room answer 410 Gone.
|
||||
"""
|
||||
|
||||
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
|
||||
http_method_names = ["get", "post", "patch", "head", "options"]
|
||||
|
||||
authentication_classes = [
|
||||
authentication.ApplicationJWTAuthentication,
|
||||
authentication.AddonsJWTAuthentication,
|
||||
authentication.MenshenAuthentication,
|
||||
ResourceServerAuthentication,
|
||||
]
|
||||
permission_classes = [
|
||||
@@ -185,17 +176,6 @@ class RoomViewSet(
|
||||
queryset = models.Room.objects.all()
|
||||
serializer_class = serializers.RoomSerializer
|
||||
|
||||
def get_object(self):
|
||||
"""Get the room, answer 410 if it has been deleted.
|
||||
|
||||
Permissions are checked first so a deleted room is only revealed to
|
||||
users who would have been granted access to it.
|
||||
"""
|
||||
room = get_object_or_404(models.Room.all_objects, pk=self.kwargs["pk"])
|
||||
self.check_object_permissions(self.request, room)
|
||||
ensure_room_not_deleted(room)
|
||||
return room
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Limit listed rooms to the ones related to the authenticated user."""
|
||||
|
||||
@@ -259,28 +239,15 @@ class RoomViewSet(
|
||||
|
||||
self._track_room_event(room, analytics.AnalyticsEvent.ROOM_CREATED)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Soft delete the room, close its LiveKit room, then log and track it."""
|
||||
try:
|
||||
RoomManagement.soft_delete(instance)
|
||||
except RoomManagementException as e:
|
||||
raise drf_exceptions.APIException(
|
||||
"Could not delete the room, please try again."
|
||||
) from e
|
||||
self._track_room_event(instance, analytics.AnalyticsEvent.ROOM_DELETED)
|
||||
|
||||
def perform_update(self, serializer: serializers.RoomSerializer):
|
||||
"""Persist the room update under a row lock, sync it to LiveKit, then track it."""
|
||||
"""Persist the room update, sync it to LiveKit, then log and track it."""
|
||||
|
||||
with room_locked_for_update(serializer.instance) as locked_room:
|
||||
serializer.instance = locked_room
|
||||
previous_values = {
|
||||
"access_level": serializer.instance.access_level,
|
||||
"configuration": copy.deepcopy(serializer.instance.configuration),
|
||||
}
|
||||
|
||||
previous_values = {
|
||||
"access_level": locked_room.access_level,
|
||||
"configuration": copy.deepcopy(locked_room.configuration),
|
||||
}
|
||||
|
||||
room = serializer.save()
|
||||
room = serializer.save()
|
||||
|
||||
# Report the fields that actually changed, not the ones that were submitted.
|
||||
updated_fields = sorted(
|
||||
|
||||
@@ -50,4 +50,4 @@ class SecretField(models.CharField):
|
||||
setattr(model_instance, self.attname, hashed_secret)
|
||||
return hashed_secret
|
||||
|
||||
return super().pre_save(model_instance, add) # pylint: disable=no-member
|
||||
return super().pre_save(model_instance, add)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Purge inactive and soft-deleted rooms."""
|
||||
"""Purge inactive rooms."""
|
||||
|
||||
import operator
|
||||
from datetime import timedelta
|
||||
from functools import reduce
|
||||
from itertools import batched
|
||||
from logging import getLogger
|
||||
|
||||
@@ -24,12 +22,10 @@ class Command(BaseCommand):
|
||||
- rooms which were last started before that period
|
||||
- rooms never started and created before that period
|
||||
|
||||
Also delete rooms soft deleted more than ROOM_DELETED_RETENTION_DAYS days ago.
|
||||
|
||||
Rooms holding a saved recording that has not expired are kept.
|
||||
"""
|
||||
|
||||
help = "Purge inactive and soft-deleted rooms"
|
||||
help = "Purge inactive rooms"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
@@ -39,68 +35,53 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Browse purgeable rooms and delete them chunk by chunk."""
|
||||
"""Browse inactive rooms and delete them chunk by chunk."""
|
||||
|
||||
if not (
|
||||
settings.ROOM_INACTIVITY_DELETION_DAYS
|
||||
or settings.ROOM_DELETED_RETENTION_DAYS
|
||||
):
|
||||
if not settings.ROOM_INACTIVITY_DELETION_DAYS:
|
||||
self.stdout.write(
|
||||
"Purging rooms is disabled (neither ROOM_INACTIVITY_DELETION_DAYS "
|
||||
"nor ROOM_DELETED_RETENTION_DAYS is set)."
|
||||
"Purging inactive rooms is disabled "
|
||||
"(ROOM_INACTIVITY_DELETION_DAYS is not set)."
|
||||
)
|
||||
return
|
||||
|
||||
now = timezone.now()
|
||||
purgeable_rooms = self.get_purgeable_rooms(now)
|
||||
inactive_rooms = self.get_inactive_rooms(now)
|
||||
|
||||
purgeable_count = purgeable_rooms.count()
|
||||
if not purgeable_count:
|
||||
self.stdout.write("No room to purge.")
|
||||
inactive_count = inactive_rooms.count()
|
||||
if not inactive_count:
|
||||
self.stdout.write("No inactive room to purge.")
|
||||
return
|
||||
|
||||
if options["dry_run"]:
|
||||
self.stdout.write(f"[dry-run] {purgeable_count} room(s) would be purged:")
|
||||
rooms = purgeable_rooms.values_list("name", "deleted_at")
|
||||
for name, deleted_at in rooms.iterator(chunk_size=CHUNK_SIZE):
|
||||
self.stdout.write(f"- {name}{' (deleted)' if deleted_at else ''}")
|
||||
self.stdout.write(
|
||||
f"[dry-run] {inactive_count} inactive room(s) would be purged:"
|
||||
)
|
||||
names = inactive_rooms.values_list("name", flat=True)
|
||||
for name in names.iterator(chunk_size=CHUNK_SIZE):
|
||||
self.stdout.write(f"- {name}")
|
||||
return
|
||||
|
||||
purged_count = 0
|
||||
rooms = purgeable_rooms.values_list("pk", "slug", "deleted_at").iterator(
|
||||
chunk_size=CHUNK_SIZE
|
||||
)
|
||||
rooms = inactive_rooms.values_list("pk", "slug").iterator(chunk_size=CHUNK_SIZE)
|
||||
for chunk in batched(rooms, CHUNK_SIZE, strict=False):
|
||||
for room_id, slug, deleted_at in chunk:
|
||||
state = "deleted" if deleted_at else "inactive"
|
||||
logger.info("Purging %s room %s (%s)", state, room_id, slug)
|
||||
for room_id, slug in chunk:
|
||||
logger.info("Purging inactive room %s (%s)", room_id, slug)
|
||||
|
||||
_, deleted_by_model = purgeable_rooms.filter(
|
||||
pk__in=[room_id for room_id, _, _ in chunk]
|
||||
_, deleted_by_model = inactive_rooms.filter(
|
||||
pk__in=[room_id for room_id, _ in chunk]
|
||||
).delete()
|
||||
purged_count += deleted_by_model.get("core.Room", 0)
|
||||
|
||||
self.stdout.write(f"Purged {purged_count} room(s).")
|
||||
self.stdout.write(f"Purged {purged_count} inactive room(s).")
|
||||
|
||||
@staticmethod
|
||||
def get_purgeable_rooms(now):
|
||||
"""Return the inactive or long-deleted rooms that no recording protects.
|
||||
def get_inactive_rooms(now):
|
||||
"""Return the rooms inactive for too long that no recording protects."""
|
||||
|
||||
Soft-deleted rooms are hidden by `Room.objects`, hence `Room.all_objects`.
|
||||
"""
|
||||
|
||||
conditions = []
|
||||
if settings.ROOM_INACTIVITY_DELETION_DAYS:
|
||||
threshold = now - timedelta(days=settings.ROOM_INACTIVITY_DELETION_DAYS)
|
||||
conditions.append(
|
||||
Q(last_started_at__lt=threshold)
|
||||
| Q(last_started_at__isnull=True, created_at__lt=threshold)
|
||||
)
|
||||
if settings.ROOM_DELETED_RETENTION_DAYS:
|
||||
deleted_before = now - timedelta(days=settings.ROOM_DELETED_RETENTION_DAYS)
|
||||
conditions.append(Q(deleted_at__lt=deleted_before))
|
||||
if not conditions:
|
||||
return Room.all_objects.none()
|
||||
threshold = now - timedelta(days=settings.ROOM_INACTIVITY_DELETION_DAYS)
|
||||
is_inactive = Q(last_started_at__lt=threshold) | Q(
|
||||
last_started_at__isnull=True, created_at__lt=threshold
|
||||
)
|
||||
|
||||
protected_recordings = Recording.objects.filter(
|
||||
room=OuterRef("pk"), status__in=RecordingStatusChoices.saved_statuses()
|
||||
@@ -110,6 +91,4 @@ class Command(BaseCommand):
|
||||
created_at__gte=now - timedelta(days=settings.RECORDING_EXPIRATION_DAYS)
|
||||
)
|
||||
|
||||
return Room.all_objects.filter(
|
||||
reduce(operator.or_, conditions), ~Exists(protected_recordings)
|
||||
)
|
||||
return Room.objects.filter(is_inactive, ~Exists(protected_recordings))
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.2.14 on 2026-10-07 18:45
|
||||
|
||||
from django.contrib.postgres.operations import AddIndexConcurrently
|
||||
from django.db import migrations, models
|
||||
from django.db.models.functions import Upper
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
('core', '0026_alter_application_scopes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
AddIndexConcurrently(
|
||||
model_name="user",
|
||||
index=models.Index(
|
||||
Upper("email"),
|
||||
name="user_email_upper_idx",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,29 +0,0 @@
|
||||
# Generated by Django 5.2.14 on 2026-09-16 10:00
|
||||
|
||||
import django.db.models.manager
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0027_user_email_upper_idx'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='room',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='room',
|
||||
options={'default_manager_name': 'all_objects', 'ordering': ('name',), 'verbose_name': 'Room', 'verbose_name_plural': 'Rooms'},
|
||||
),
|
||||
migrations.AlterModelManagers(
|
||||
name='room',
|
||||
managers=[
|
||||
('all_objects', django.db.models.manager.Manager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -246,9 +246,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
name="unique_email_when_sub_is_null",
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(models.functions.Upper("email"), name="user_email_upper_idx"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.email or self.admin_email or str(self.id)
|
||||
@@ -410,33 +407,6 @@ class ResourceAccess(BaseModel):
|
||||
return super().delete(*args, **kwargs)
|
||||
|
||||
|
||||
class RoomQuerySet(models.QuerySet):
|
||||
"""QuerySet exposing the room lifecycle filters."""
|
||||
|
||||
def active(self):
|
||||
"""Rooms that have not been soft deleted."""
|
||||
return self.filter(deleted_at__isnull=True)
|
||||
|
||||
def deleted(self):
|
||||
"""Rooms that have been soft deleted."""
|
||||
return self.filter(deleted_at__isnull=False)
|
||||
|
||||
|
||||
class RoomManager(models.Manager.from_queryset(RoomQuerySet)):
|
||||
"""Manager hiding soft-deleted rooms, exposed as ``Room.objects``.
|
||||
|
||||
It is deliberately not the model's default manager: Django relies on
|
||||
``_default_manager`` for unique validation, which must see deleted rooms as
|
||||
they still guard their slug and pin code. Other object relations (e.g.
|
||||
``recording.room``) go through the base manager and still resolve deleted
|
||||
rooms, which keeps recordings and their notifications working.
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
"""Exclude soft-deleted rooms."""
|
||||
return super().get_queryset().active()
|
||||
|
||||
|
||||
class Room(Resource):
|
||||
"""Model for one room"""
|
||||
|
||||
@@ -460,7 +430,6 @@ class Room(Resource):
|
||||
verbose_name=_("Visio room configuration"),
|
||||
help_text=_("Values for Visio parameters to configure the room."),
|
||||
)
|
||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||
pin_code = models.CharField(
|
||||
max_length=None,
|
||||
unique=True,
|
||||
@@ -477,13 +446,8 @@ class Room(Resource):
|
||||
editable=False,
|
||||
)
|
||||
|
||||
# Managers
|
||||
objects = RoomManager()
|
||||
all_objects = models.Manager.from_queryset(RoomQuerySet)()
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_room"
|
||||
default_manager_name = "all_objects"
|
||||
ordering = ("name",)
|
||||
verbose_name = _("Room")
|
||||
verbose_name_plural = _("Rooms")
|
||||
@@ -506,28 +470,6 @@ class Room(Resource):
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def is_deleted(self):
|
||||
"""Whether the room has been soft deleted."""
|
||||
return self.deleted_at is not None
|
||||
|
||||
def soft_delete(self):
|
||||
"""Soft delete the room.
|
||||
|
||||
The room is hidden from the default manager making it impossible to
|
||||
list, join or update.
|
||||
"""
|
||||
deleted_at = timezone.now()
|
||||
|
||||
updated = Room.all_objects.filter(pk=self.pk, deleted_at__isnull=True).update(
|
||||
deleted_at=deleted_at
|
||||
)
|
||||
|
||||
if not updated:
|
||||
raise RuntimeError("This room is already deleted.")
|
||||
|
||||
self.deleted_at = deleted_at
|
||||
|
||||
def clean_fields(self, exclude=None):
|
||||
"""
|
||||
Automatically generate the slug from the name and make sure it does not look like a UUID.
|
||||
@@ -563,7 +505,7 @@ class Room(Resource):
|
||||
|
||||
for _ in range(settings.ROOM_TELEPHONY_PIN_MAX_RETRIES):
|
||||
pin_code = str(secrets.randbelow(max_value)).zfill(length)
|
||||
if not Room.all_objects.filter(pin_code=pin_code).exists():
|
||||
if not Room.objects.filter(pin_code=pin_code).exists():
|
||||
return pin_code
|
||||
|
||||
# Log a warning as a temporary measure until backend observability is implemented.
|
||||
@@ -743,38 +685,11 @@ class Recording(BaseModel):
|
||||
return {
|
||||
"destroy": is_owner_or_admin and is_final_status,
|
||||
"partial_update": False,
|
||||
"retrieve": bool(roles),
|
||||
"retrieve": is_owner_or_admin,
|
||||
"stop": is_owner_or_admin and not is_final_status,
|
||||
"update": False,
|
||||
}
|
||||
|
||||
def grant_initial_accesses(self, starter):
|
||||
"""Grant the starter and the room's owners/administrators access to the recording.
|
||||
|
||||
The starter's access is created first, so it is the oldest one: the
|
||||
transcript summary, which goes to a single user, is sent to them.
|
||||
|
||||
Room roles are copied when the recording starts: later changes to the
|
||||
room's accesses do not affect existing recordings.
|
||||
"""
|
||||
room_privileged_user_ids = set(
|
||||
self.room.accesses.filter(
|
||||
role__in=[RoleChoices.OWNER, RoleChoices.ADMIN]
|
||||
).values_list("user_id", flat=True)
|
||||
)
|
||||
starter_is_privileged = starter.id in room_privileged_user_ids
|
||||
room_privileged_user_ids.discard(starter.id)
|
||||
|
||||
RecordingAccess.objects.create(
|
||||
user=starter,
|
||||
role=RoleChoices.OWNER if starter_is_privileged else RoleChoices.MEMBER,
|
||||
recording=self,
|
||||
)
|
||||
RecordingAccess.objects.bulk_create(
|
||||
RecordingAccess(user_id=user_id, role=RoleChoices.OWNER, recording=self)
|
||||
for user_id in room_privileged_user_ids
|
||||
)
|
||||
|
||||
def is_savable(self) -> bool:
|
||||
"""Determine if the recording can be saved based on its current status."""
|
||||
|
||||
|
||||
@@ -7,18 +7,18 @@ from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import get_language, gettext, gettext_noop, override
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import get_language, gettext, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import aiohttp
|
||||
import requests
|
||||
from asgiref.sync import async_to_sync
|
||||
from celery.exceptions import OperationalError as CeleryOperationalError
|
||||
from livekit import api as livekit_api
|
||||
|
||||
from core import models, utils
|
||||
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
|
||||
from core.tasks.mail import send_email
|
||||
from core.utils import generate_download_s3_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -68,20 +68,18 @@ class NotificationService:
|
||||
@staticmethod
|
||||
def _notify_user_by_email(recording) -> bool:
|
||||
"""
|
||||
Queue an email notification to every user with access to the recording
|
||||
when it is ready: its starter and the room's owners/administrators.
|
||||
Send an email notification to recording owners when their recording is ready.
|
||||
|
||||
The email includes a direct link that redirects owners to a dedicated download
|
||||
page in the frontend where they can access their specific recording.
|
||||
|
||||
Rendering and SMTP delivery happen in a Celery worker (``send_email``), so
|
||||
this only does one DB query and enqueues one message per owner.
|
||||
Returns True when every email was queued (or sent, if Celery is disabled).
|
||||
"""
|
||||
|
||||
owner_accesses = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(recording_id=recording.id)
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.order_by("created_at")
|
||||
)
|
||||
|
||||
@@ -90,6 +88,10 @@ class NotificationService:
|
||||
return False
|
||||
|
||||
context = {
|
||||
"brandname": settings.EMAIL_BRAND_NAME,
|
||||
"support_email": settings.EMAIL_SUPPORT_EMAIL,
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
"domain": settings.EMAIL_DOMAIN,
|
||||
"room_name": recording.room.name,
|
||||
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||
"link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
@@ -102,22 +104,37 @@ class NotificationService:
|
||||
# 2. The number of recipients per recording is typically small (not thousands)
|
||||
for access in owner_accesses:
|
||||
user = access.user
|
||||
local_created_at = recording.created_at.astimezone(user.timezone)
|
||||
try:
|
||||
send_email.delay(
|
||||
template="screen_recording",
|
||||
subject=gettext_noop("Your recording is ready"),
|
||||
recipients=[user.email],
|
||||
language=user.language or get_language(),
|
||||
context={
|
||||
**context,
|
||||
"recording_date": local_created_at.strftime("%Y-%m-%d"),
|
||||
"recording_time": local_created_at.strftime("%H:%M"),
|
||||
},
|
||||
language = user.language or get_language()
|
||||
with override(language):
|
||||
personalized_context = {
|
||||
"recording_date": recording.created_at.astimezone(
|
||||
user.timezone
|
||||
).strftime("%Y-%m-%d"),
|
||||
"recording_time": recording.created_at.astimezone(
|
||||
user.timezone
|
||||
).strftime("%H:%M"),
|
||||
**context,
|
||||
}
|
||||
msg_html = render_to_string(
|
||||
"mail/html/screen_recording.html", personalized_context
|
||||
)
|
||||
except (smtplib.SMTPException, CeleryOperationalError) as exception:
|
||||
logger.error("notification could not be sent: %s", exception)
|
||||
has_failures = True
|
||||
msg_plain = render_to_string(
|
||||
"mail/text/screen_recording.txt", personalized_context
|
||||
)
|
||||
subject = gettext("Your recording is ready") # Force translation
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject.capitalize(),
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
[user.email],
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("notification could not be sent: %s", exception)
|
||||
has_failures = True
|
||||
|
||||
return not has_failures
|
||||
|
||||
@@ -205,27 +222,6 @@ class NotificationService:
|
||||
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_summary_recipient(recording: models.Recording):
|
||||
"""Return the single user who receives the transcript summary.
|
||||
|
||||
A recording can have multiple accesses (the starter and the room's
|
||||
owners/administrators), but its summary is generated only once.
|
||||
|
||||
Temporarily, until the document can be created and shared with all
|
||||
recording accesses, the summary is assigned to the oldest access,
|
||||
which belongs to the starter (see `Recording.grant_initial_accesses`).
|
||||
|
||||
Since the starter may have a MEMBER role, the lookup must not filter by role.
|
||||
"""
|
||||
oldest_access = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(recording_id=recording.id)
|
||||
.order_by("created_at")
|
||||
.first()
|
||||
)
|
||||
return oldest_access.user if oldest_access else None
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service_v1(recording: models.Recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
@@ -237,7 +233,14 @@ class NotificationService:
|
||||
logger.error("Summary service not configured")
|
||||
return False
|
||||
|
||||
recipient = NotificationService._get_summary_recipient(recording)
|
||||
owner_access = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
|
||||
"collect_metadata", False
|
||||
@@ -247,7 +250,7 @@ class NotificationService:
|
||||
else:
|
||||
metadata_filename = None
|
||||
|
||||
if not recipient:
|
||||
if not owner_access:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
@@ -256,16 +259,16 @@ class NotificationService:
|
||||
)(recording.worker_id)
|
||||
|
||||
payload = {
|
||||
"owner_id": str(recipient.id),
|
||||
"owner_id": str(owner_access.user.id),
|
||||
"recording_filename": recording.key,
|
||||
"metadata_filename": metadata_filename,
|
||||
"email": recipient.email,
|
||||
"sub": recipient.sub,
|
||||
"email": owner_access.user.email,
|
||||
"sub": owner_access.user.sub,
|
||||
"room": recording.room.name,
|
||||
"language": recording.options.get("language"),
|
||||
"owner_timezone": str(recipient.timezone),
|
||||
"owner_timezone": str(owner_access.user.timezone),
|
||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
"context_language": recipient.language,
|
||||
"context_language": owner_access.user.language,
|
||||
"recording_start_at": (started_at.isoformat() if started_at else None),
|
||||
"recording_end_at": (ended_at.isoformat() if ended_at else None),
|
||||
}
|
||||
@@ -305,7 +308,14 @@ class NotificationService:
|
||||
logger.error("Summary service not configured")
|
||||
return False
|
||||
|
||||
recipient = NotificationService._get_summary_recipient(recording)
|
||||
owner_access = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
metadata_filename: None | str = None
|
||||
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
|
||||
"collect_metadata", False
|
||||
@@ -313,7 +323,7 @@ class NotificationService:
|
||||
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
|
||||
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
|
||||
|
||||
if not recipient:
|
||||
if not owner_access:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
@@ -340,8 +350,8 @@ class NotificationService:
|
||||
}
|
||||
|
||||
payload = {
|
||||
"user_sub": recipient.sub,
|
||||
"user_email": recipient.email,
|
||||
"user_sub": owner_access.user.sub,
|
||||
"user_email": owner_access.user.email,
|
||||
"cloud_storage_url": generate_download_s3_url(
|
||||
recording.key,
|
||||
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
|
||||
@@ -350,20 +360,20 @@ class NotificationService:
|
||||
"language": recording.options.get(
|
||||
"language", get_language().split("-")[0].lower()
|
||||
),
|
||||
"context_language": recipient.language,
|
||||
"context_language": owner_access.user.language,
|
||||
"push_to_docs_config": {
|
||||
"user_email": recipient.email,
|
||||
"user_email": owner_access.user.email,
|
||||
"title": NotificationService._generate_title(
|
||||
locale=recipient.language
|
||||
locale=owner_access.user.language
|
||||
or recording.options.get("language", get_language()),
|
||||
room=recording.room.name,
|
||||
recording_datetime=started_at,
|
||||
owner_timezone=str(recipient.timezone),
|
||||
owner_timezone=str(owner_access.user.timezone),
|
||||
),
|
||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
"form_link": form_link,
|
||||
"auto_create_summary": is_user_feature_flag_enabled(
|
||||
recipient, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
owner_access.user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
),
|
||||
},
|
||||
"metadata": metadata_payload,
|
||||
|
||||
@@ -271,24 +271,20 @@ class LiveKitEventsService:
|
||||
)
|
||||
raise ActionFailedError("Failed to process room started event") from e
|
||||
|
||||
try:
|
||||
room = models.Room.all_objects.get(id=room_id)
|
||||
except models.Room.DoesNotExist as err:
|
||||
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
||||
|
||||
# The block below is intended to fix the issue where long-lived livekit
|
||||
# tokens allow users who already entered a room to re-create it,
|
||||
# even if it was closed.
|
||||
if room.is_deleted:
|
||||
self._close_deleted_room(room_id)
|
||||
return
|
||||
|
||||
# Update through the queryset to skip the full_clean run by save()
|
||||
models.Room.all_objects.filter(pk=room.pk).update(
|
||||
room_updated_count = models.Room.objects.filter(pk=room_id).update(
|
||||
last_started_at=timezone.now()
|
||||
)
|
||||
if not room_updated_count:
|
||||
raise ActionFailedError(f"Room with ID {room_id} does not exist")
|
||||
|
||||
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
||||
try:
|
||||
room = models.Room.objects.get(pk=room_id)
|
||||
except models.Room.DoesNotExist as err:
|
||||
raise ActionFailedError(
|
||||
f"Room with ID {room_id} does not exist"
|
||||
) from err
|
||||
|
||||
try:
|
||||
self.sip_management.ensure_dispatch_rule(room)
|
||||
except SIPException as e:
|
||||
@@ -296,25 +292,6 @@ class LiveKitEventsService:
|
||||
f"Failed to create sip dispatch rule for room {room_id}"
|
||||
) from e
|
||||
|
||||
@staticmethod
|
||||
def _close_deleted_room(room_id):
|
||||
"""Close a LiveKit room recreated after its room was soft deleted.
|
||||
|
||||
LiveKit auto-creates a room on join, so a participant still holding a
|
||||
valid token can bring a deleted room back to life until the token expires.
|
||||
"""
|
||||
|
||||
logger.warning(
|
||||
"LiveKit room %s started for a deleted room, closing it", room_id
|
||||
)
|
||||
|
||||
try:
|
||||
RoomManagement.delete_room(str(room_id))
|
||||
except RoomNotFoundException:
|
||||
logger.info("LiveKit room %s is already closed", room_id)
|
||||
except RoomManagementException as e:
|
||||
raise ActionFailedError(f"Failed to close deleted room {room_id}") from e
|
||||
|
||||
def _handle_room_finished(self, data):
|
||||
"""Handle 'room_finished' event."""
|
||||
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
from logging import getLogger
|
||||
from typing import Dict, Optional
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import (
|
||||
DeleteRoomRequest,
|
||||
@@ -18,9 +15,6 @@ from livekit.api import (
|
||||
)
|
||||
|
||||
from core import utils
|
||||
from core.models import Room
|
||||
|
||||
from .sip_management import SIPException, SIPManagement
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -121,54 +115,6 @@ class RoomManagement:
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@classmethod
|
||||
def soft_delete(cls, room: Room):
|
||||
"""Soft delete a room, remove its SIP routing and close its LiveKit room."""
|
||||
|
||||
room.soft_delete()
|
||||
rule_deleted = False
|
||||
|
||||
try:
|
||||
rule_deleted = cls._delete_dispatch_rule(room)
|
||||
|
||||
with contextlib.suppress(RoomNotFoundException):
|
||||
cls.delete_room(str(room.id))
|
||||
except Exception:
|
||||
Room.all_objects.filter(pk=room.pk, deleted_at=room.deleted_at).update(
|
||||
deleted_at=None
|
||||
)
|
||||
room.deleted_at = None
|
||||
if rule_deleted:
|
||||
cls._restore_dispatch_rule(room)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _delete_dispatch_rule(room: Room):
|
||||
"""Delete a room's SIP dispatch rule, so its PIN no longer routes calls.
|
||||
|
||||
This cannot be left to the room_finished webhook: a roomkit join creates
|
||||
the rule before any LiveKit room exists, so no room may ever finish.
|
||||
|
||||
Returns whether a rule was deleted
|
||||
"""
|
||||
|
||||
if not (settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED):
|
||||
return False
|
||||
|
||||
try:
|
||||
return SIPManagement().delete_dispatch_rule(room.id)
|
||||
except SIPException as e:
|
||||
raise RoomManagementException("Could not delete dispatch rule") from e
|
||||
|
||||
@staticmethod
|
||||
def _restore_dispatch_rule(room: Room):
|
||||
"""Recreate a room's SIP dispatch rule after a failed soft delete."""
|
||||
|
||||
try:
|
||||
SIPManagement().ensure_dispatch_rule(room)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
logger.exception("Could not restore dispatch rule for room %s", room.id)
|
||||
|
||||
@classmethod
|
||||
def sync_room_metadata(cls, room):
|
||||
"""Push a room's configuration and access level to its LiveKit room metadata.
|
||||
|
||||
@@ -124,11 +124,7 @@ class SIPManagement:
|
||||
|
||||
@async_to_sync
|
||||
async def delete_dispatch_rule(self, room_id):
|
||||
"""Delete all SIP inbound dispatch rules associated with a specific room.
|
||||
|
||||
A rule deleted meanwhile (e.g. by both a room deletion and its
|
||||
room_finished webhook) is not an error.
|
||||
"""
|
||||
"""Delete all SIP inbound dispatch rules associated with a specific room."""
|
||||
|
||||
rules_ids = await self._list_dispatch_rules_ids(room_id)
|
||||
|
||||
@@ -142,16 +138,9 @@ class SIPManagement:
|
||||
lkapi = utils.create_livekit_client()
|
||||
try:
|
||||
for rule_id in rules_ids:
|
||||
try:
|
||||
await lkapi.sip.delete_sip_dispatch_rule(
|
||||
delete=DeleteSIPDispatchRuleRequest(
|
||||
sip_dispatch_rule_id=rule_id
|
||||
)
|
||||
)
|
||||
except TwirpError as e:
|
||||
if e.code != TwirpErrorCode.NOT_FOUND:
|
||||
raise
|
||||
logger.info("Dispatch rule %s was already deleted", rule_id)
|
||||
await lkapi.sip.delete_sip_dispatch_rule(
|
||||
delete=DeleteSIPDispatchRuleRequest(sip_dispatch_rule_id=rule_id)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
from core.tasks.connection_test import delete_connection_test_room
|
||||
from core.tasks.file import process_file_deletion
|
||||
from core.tasks.mail import send_email
|
||||
|
||||
__all__ = (
|
||||
"delete_connection_test_room",
|
||||
"process_file_deletion",
|
||||
"send_email",
|
||||
)
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Send mail using celery task."""
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import get_language, gettext, override
|
||||
|
||||
from core.tasks._task import task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_base_email_context() -> dict:
|
||||
"""Context shared by every email template (branding, support, domain)."""
|
||||
return {
|
||||
"brandname": settings.EMAIL_BRAND_NAME,
|
||||
"support_email": settings.EMAIL_SUPPORT_EMAIL,
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
"domain": settings.EMAIL_DOMAIN,
|
||||
}
|
||||
|
||||
|
||||
@task(
|
||||
autoretry_for=(smtplib.SMTPException, ConnectionError, TimeoutError),
|
||||
retry_backoff=True,
|
||||
retry_backoff_max=600,
|
||||
retry_jitter=True,
|
||||
max_retries=5,
|
||||
)
|
||||
def send_email(
|
||||
*,
|
||||
template: str,
|
||||
subject: str,
|
||||
recipients: list[str],
|
||||
context: dict | None = None,
|
||||
language: str | None = None,
|
||||
):
|
||||
"""Render a mail template and send it.
|
||||
|
||||
Generic on purpose: any feature can reuse it with its own template and context.
|
||||
|
||||
Args:
|
||||
template: Template base name, e.g. "screen_recording". Both
|
||||
"mail/html/<template>.html" and "mail/text/<template>.txt" must exist.
|
||||
subject: Untranslated subject msgid. Mark it with ``gettext_noop`` at the
|
||||
call site; it is translated here, in ``language``.
|
||||
recipients: Email addresses, all receiving the same rendered message.
|
||||
context: Template context. Must be JSON-serializable (str, int, list, dict…):
|
||||
pre-format dates, cast UUIDs to str. Merged over the base branding context.
|
||||
language: Language to render the email in. Defaults to the active language.
|
||||
|
||||
Raises:
|
||||
OSError: SMTP or network failure, after retries are exhausted (or
|
||||
immediately when Celery is disabled and the call runs synchronously).
|
||||
"""
|
||||
full_context = {**get_base_email_context(), **(context or {})}
|
||||
|
||||
with override(language or get_language()):
|
||||
msg_html = render_to_string(f"mail/html/{template}.html", full_context)
|
||||
msg_plain = render_to_string(f"mail/text/{template}.txt", full_context)
|
||||
translated_subject = gettext(subject)
|
||||
|
||||
send_mail(
|
||||
translated_subject,
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
recipients,
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
logger.info("Email '%s' sent to %d recipient(s)", template, len(recipients))
|
||||
@@ -1,124 +0,0 @@
|
||||
"""
|
||||
Unit tests for NoOpAnalytics and the analytics backend loader.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
import pytest
|
||||
|
||||
from core.analytics import get_analytics
|
||||
from core.analytics.base import NoOpAnalytics
|
||||
from core.analytics.user_feature_flags import UserFeatureFlag
|
||||
from core.factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_analytics_cache():
|
||||
"""Clear the cached backend before and after each test."""
|
||||
get_analytics.cache_clear()
|
||||
yield
|
||||
get_analytics.cache_clear()
|
||||
|
||||
|
||||
# ==============================
|
||||
# NoOpAnalytics
|
||||
# ==============================
|
||||
|
||||
|
||||
def test_noop_has_no_feature_flags_by_default():
|
||||
"""Should keep every feature flag disabled when none is declared."""
|
||||
backend = NoOpAnalytics()
|
||||
user = UserFactory()
|
||||
|
||||
assert not backend.get_user_feature_flags(user)
|
||||
assert (
|
||||
backend.is_user_feature_enabled(
|
||||
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_noop_enables_declared_feature_flags():
|
||||
"""Should enable a feature flag declared as True."""
|
||||
backend = NoOpAnalytics(feature_flags={"summary-enabled": True})
|
||||
user = UserFactory()
|
||||
|
||||
assert backend.get_user_feature_flags(user) == {"summary-enabled": True}
|
||||
assert (
|
||||
backend.is_user_feature_enabled(
|
||||
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [False, None, "true"])
|
||||
def test_noop_only_enables_feature_flags_set_to_true(value):
|
||||
"""Should keep a feature flag disabled unless its value is exactly True."""
|
||||
backend = NoOpAnalytics(feature_flags={"summary-enabled": value})
|
||||
user = UserFactory()
|
||||
|
||||
assert (
|
||||
backend.is_user_feature_enabled(
|
||||
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_noop_ignores_unrelated_settings():
|
||||
"""Should accept settings meant for another backend without failing."""
|
||||
backend = NoOpAnalytics(api_key="my-key", host="https://eu.i.posthog.com")
|
||||
|
||||
assert not backend.get_user_feature_flags(UserFactory())
|
||||
|
||||
|
||||
# ==============================
|
||||
# get_analytics
|
||||
# ==============================
|
||||
|
||||
|
||||
def test_get_analytics_defaults_to_noop_without_feature_flags(settings):
|
||||
"""Should return a no-op backend with no feature flag by default."""
|
||||
settings.ANALYTICS_BACKEND = None
|
||||
settings.ANALYTICS_BACKEND_SETTINGS = {}
|
||||
|
||||
backend = get_analytics()
|
||||
|
||||
assert isinstance(backend, NoOpAnalytics)
|
||||
assert not backend.get_user_feature_flags(UserFactory())
|
||||
|
||||
|
||||
def test_get_analytics_passes_feature_flags_to_noop(settings):
|
||||
"""Should forward the feature flags when no backend is configured."""
|
||||
settings.ANALYTICS_BACKEND = None
|
||||
settings.ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
|
||||
|
||||
backend = get_analytics()
|
||||
|
||||
assert isinstance(backend, NoOpAnalytics)
|
||||
assert (
|
||||
backend.is_user_feature_enabled(
|
||||
UserFactory(), UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_get_analytics_instantiates_configured_backend_with_settings(settings):
|
||||
"""Should instantiate the configured backend with its settings."""
|
||||
settings.ANALYTICS_BACKEND = "core.analytics.base.NoOpAnalytics"
|
||||
settings.ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
|
||||
|
||||
backend = get_analytics()
|
||||
|
||||
assert isinstance(backend, NoOpAnalytics)
|
||||
assert (
|
||||
backend.is_user_feature_enabled(
|
||||
UserFactory(), UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
)
|
||||
is True
|
||||
)
|
||||
@@ -9,7 +9,6 @@ from django.core.management import call_command
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
@@ -20,25 +19,14 @@ COMMAND_MODULE = "core.management.commands.purge_inactive_rooms"
|
||||
BEFORE_PERIOD = timedelta(days=366)
|
||||
WITHIN_PERIOD = timedelta(days=364)
|
||||
|
||||
BEFORE_RETENTION = timedelta(days=31)
|
||||
WITHIN_RETENTION = timedelta(days=29)
|
||||
|
||||
|
||||
@pytest.fixture(name="purge_enabled", autouse=True)
|
||||
def fixture_purge_enabled(settings):
|
||||
"""Enable the purge of the rooms inactive for a year."""
|
||||
settings.ROOM_INACTIVITY_DELETION_DAYS = 365
|
||||
settings.ROOM_DELETED_RETENTION_DAYS = None
|
||||
settings.RECORDING_EXPIRATION_DAYS = 30
|
||||
|
||||
|
||||
@pytest.fixture(name="retention_only")
|
||||
def fixture_retention_only(settings):
|
||||
"""Only purge the rooms soft deleted more than 30 days ago."""
|
||||
settings.ROOM_INACTIVITY_DELETION_DAYS = None
|
||||
settings.ROOM_DELETED_RETENTION_DAYS = 30
|
||||
|
||||
|
||||
def create_at(date, factory, **kwargs):
|
||||
"""Build an object with the factory as if it was created at the given date."""
|
||||
with mock.patch("django.utils.timezone.now", return_value=date):
|
||||
@@ -53,12 +41,12 @@ def call_purge(*args):
|
||||
|
||||
|
||||
def room_exists(room):
|
||||
"""Tell whether the room is still in database, soft deleted or not."""
|
||||
return models.Room.all_objects.filter(pk=room.pk).exists()
|
||||
"""Tell whether the room is still in database."""
|
||||
return models.Room.objects.filter(pk=room.pk).exists()
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_disabled(settings):
|
||||
"""Should delete nothing when neither period is configured."""
|
||||
"""Should delete nothing when no inactivity period is configured."""
|
||||
settings.ROOM_INACTIVITY_DELETION_DAYS = None
|
||||
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
|
||||
|
||||
@@ -80,7 +68,7 @@ def test_purge_inactive_rooms_without_recording_expiration(settings):
|
||||
status=models.RecordingStatusChoices.SAVED,
|
||||
)
|
||||
|
||||
assert call_purge() == "Purged 1 room(s).\n"
|
||||
assert call_purge() == "Purged 1 inactive room(s).\n"
|
||||
|
||||
assert not room_exists(room)
|
||||
assert room_exists(room_with_recording)
|
||||
@@ -109,7 +97,7 @@ def test_purge_inactive_rooms_started_before_period(caplog):
|
||||
with caplog.at_level(logging.INFO, logger=COMMAND_MODULE):
|
||||
output = call_purge()
|
||||
|
||||
assert output == "Purged 1 room(s).\n"
|
||||
assert output == "Purged 1 inactive room(s).\n"
|
||||
assert not room_exists(room)
|
||||
assert f"Purging inactive room {room.pk} ({room.slug})" in caplog.text
|
||||
|
||||
@@ -132,7 +120,7 @@ def test_purge_inactive_rooms_started_within_period():
|
||||
last_started_at=now - WITHIN_PERIOD,
|
||||
)
|
||||
|
||||
assert call_purge() == "No room to purge.\n"
|
||||
assert call_purge() == "No inactive room to purge.\n"
|
||||
|
||||
assert room_exists(room)
|
||||
|
||||
@@ -141,7 +129,7 @@ def test_purge_inactive_rooms_never_started_created_within_period():
|
||||
"""Should keep a room that was never started but created within the period."""
|
||||
room = create_at(timezone.now() - WITHIN_PERIOD, factories.RoomFactory)
|
||||
|
||||
assert call_purge() == "No room to purge.\n"
|
||||
assert call_purge() == "No inactive room to purge.\n"
|
||||
|
||||
assert room_exists(room)
|
||||
|
||||
@@ -233,7 +221,7 @@ def test_purge_inactive_rooms_dry_run():
|
||||
factories.RoomFactory(name="Recent room")
|
||||
|
||||
assert call_purge("--dry-run") == (
|
||||
"[dry-run] 2 room(s) would be purged:\n- Alpha room\n- Beta room\n"
|
||||
"[dry-run] 2 inactive room(s) would be purged:\n- Alpha room\n- Beta room\n"
|
||||
)
|
||||
|
||||
assert all(room_exists(room) for room in rooms)
|
||||
@@ -247,134 +235,5 @@ def test_purge_inactive_rooms_several_chunks():
|
||||
with mock.patch(f"{COMMAND_MODULE}.CHUNK_SIZE", 2):
|
||||
output = call_purge()
|
||||
|
||||
assert output == "Purged 5 room(s).\n"
|
||||
assert output == "Purged 5 inactive room(s).\n"
|
||||
assert not any(room_exists(room) for room in rooms)
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_soft_deleted(caplog):
|
||||
"""Should delete an inactive room even if it was soft deleted recently."""
|
||||
now = timezone.now()
|
||||
room = create_at(
|
||||
now - BEFORE_PERIOD,
|
||||
factories.RoomFactory,
|
||||
deleted_at=now - timedelta(days=1),
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.INFO, logger=COMMAND_MODULE):
|
||||
output = call_purge()
|
||||
|
||||
assert output == "Purged 1 room(s).\n"
|
||||
assert not room_exists(room)
|
||||
assert f"Purging deleted room {room.pk} ({room.slug})" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("retention_only")
|
||||
def test_purge_deleted_rooms_before_retention(caplog):
|
||||
"""Should delete a room soft deleted before the retention period."""
|
||||
room = factories.RoomFactory(deleted_at=timezone.now() - BEFORE_RETENTION)
|
||||
|
||||
with caplog.at_level(logging.INFO, logger=COMMAND_MODULE):
|
||||
output = call_purge()
|
||||
|
||||
assert output == "Purged 1 room(s).\n"
|
||||
assert not room_exists(room)
|
||||
assert f"Purging deleted room {room.pk} ({room.slug})" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("retention_only")
|
||||
def test_purge_deleted_rooms_within_retention():
|
||||
"""Should keep a room soft deleted within the retention period."""
|
||||
room = factories.RoomFactory(deleted_at=timezone.now() - WITHIN_RETENTION)
|
||||
|
||||
assert call_purge() == "No room to purge.\n"
|
||||
|
||||
assert room_exists(room)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("retention_only")
|
||||
def test_purge_deleted_rooms_retention_only_keeps_inactive_rooms():
|
||||
"""Should keep inactive rooms that are not deleted when only retention is set."""
|
||||
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
|
||||
|
||||
assert call_purge() == "No room to purge.\n"
|
||||
|
||||
assert room_exists(room)
|
||||
|
||||
|
||||
def test_purge_deleted_rooms_within_retention_but_inactive(settings):
|
||||
"""Should delete an inactive room even if it was deleted within the retention."""
|
||||
settings.ROOM_DELETED_RETENTION_DAYS = 30
|
||||
now = timezone.now()
|
||||
room = create_at(
|
||||
now - BEFORE_PERIOD,
|
||||
factories.RoomFactory,
|
||||
deleted_at=now - WITHIN_RETENTION,
|
||||
)
|
||||
|
||||
call_purge()
|
||||
|
||||
assert not room_exists(room)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("retention_only")
|
||||
def test_purge_deleted_rooms_recording_not_expired():
|
||||
"""Should keep a deleted room holding a saved recording that has not expired."""
|
||||
room = factories.RoomFactory(deleted_at=timezone.now() - BEFORE_RETENTION)
|
||||
factories.RecordingFactory(room=room, status=models.RecordingStatusChoices.SAVED)
|
||||
|
||||
assert call_purge() == "No room to purge.\n"
|
||||
|
||||
assert room_exists(room)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("retention_only")
|
||||
def test_purge_deleted_rooms_recording_expired():
|
||||
"""Should delete a deleted room along with its expired recordings."""
|
||||
now = timezone.now()
|
||||
room = create_at(
|
||||
now - timedelta(days=40),
|
||||
factories.RoomFactory,
|
||||
deleted_at=now - BEFORE_RETENTION,
|
||||
)
|
||||
recording = create_at(
|
||||
now - timedelta(days=40),
|
||||
factories.RecordingFactory,
|
||||
room=room,
|
||||
status=models.RecordingStatusChoices.SAVED,
|
||||
)
|
||||
|
||||
call_purge()
|
||||
|
||||
assert not room_exists(room)
|
||||
assert not models.Recording.objects.filter(pk=recording.pk).exists()
|
||||
|
||||
|
||||
def test_purge_rooms_dry_run_flags_deleted_rooms(settings):
|
||||
"""Should flag the soft-deleted rooms listed on a dry run."""
|
||||
settings.ROOM_DELETED_RETENTION_DAYS = 30
|
||||
now = timezone.now()
|
||||
rooms = [
|
||||
create_at(now - BEFORE_PERIOD, factories.RoomFactory, name="Alpha room"),
|
||||
factories.RoomFactory(name="Beta room", deleted_at=now - BEFORE_RETENTION),
|
||||
]
|
||||
factories.RoomFactory(name="Gamma room", deleted_at=now - WITHIN_RETENTION)
|
||||
|
||||
assert call_purge("--dry-run") == (
|
||||
"[dry-run] 2 room(s) would be purged:\n- Alpha room\n- Beta room (deleted)\n"
|
||||
)
|
||||
|
||||
assert all(room_exists(room) for room in rooms)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("retention_only")
|
||||
def test_purge_deleted_rooms_frees_slug():
|
||||
"""Should let a new room take the slug of a purged deleted room."""
|
||||
factories.RoomFactory(name="my room", deleted_at=timezone.now() - BEFORE_RETENTION)
|
||||
client = APIClient()
|
||||
client.force_login(factories.UserFactory())
|
||||
|
||||
call_purge()
|
||||
response = client.post("/api/v1.0/rooms/", {"name": "My Room!"})
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.Room.objects.get().slug == "my-room"
|
||||
|
||||
@@ -12,7 +12,6 @@ from unittest import mock
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
import pytest
|
||||
from celery.exceptions import OperationalError as CeleryOperationalError
|
||||
|
||||
from core import factories, models
|
||||
from core.analytics import UserFeatureFlag
|
||||
@@ -100,7 +99,7 @@ def test_notify_external_services_unknown_mode(caplog):
|
||||
|
||||
# pylint: disable=too-many-locals
|
||||
def test_notify_user_by_email_success(mocked_current_site, settings):
|
||||
"""Test successful email notification to every user with access to the recording."""
|
||||
"""Test successful email notification to recording owners."""
|
||||
settings.EMAIL_BRAND_NAME = "ACME"
|
||||
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
|
||||
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
||||
@@ -133,19 +132,21 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
|
||||
recording=recording, role=models.RoleChoices.OWNER, user=english_user
|
||||
)
|
||||
|
||||
# Members (e.g. a starter with no privileged room role) are notified too
|
||||
member = factories.UserFactory(email="member@test.com", language="en-us")
|
||||
# Create non-owner users to verify they don't receive emails
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.MEMBER, user=member
|
||||
recording=recording, role=models.RoleChoices.MEMBER
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.ADMIN
|
||||
)
|
||||
|
||||
notification_service = NotificationService()
|
||||
|
||||
with mock.patch("core.tasks.mail.send_mail") as mock_send_mail:
|
||||
with mock.patch("core.recording.event.notification.send_mail") as mock_send_mail:
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is True
|
||||
assert mock_send_mail.call_count == 4
|
||||
assert mock_send_mail.call_count == 3
|
||||
|
||||
call_args_list = mock_send_mail.call_args_list
|
||||
|
||||
@@ -209,9 +210,6 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
|
||||
assert recipients3 == ["john@test.com"]
|
||||
assert sender3 == "notifications@acme.com"
|
||||
|
||||
_, _, _, recipients4 = call_args_list[3][0]
|
||||
assert recipients4 == ["member@test.com"]
|
||||
|
||||
|
||||
def test_notify_user_by_email_no_owners(mocked_current_site, caplog):
|
||||
"""Test email notification when no owners are found."""
|
||||
@@ -239,7 +237,7 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
||||
notification_service = NotificationService()
|
||||
|
||||
with mock.patch(
|
||||
"core.tasks.mail.send_mail",
|
||||
"core.recording.event.notification.send_mail",
|
||||
side_effect=smtplib.SMTPException("SMTP Error"),
|
||||
) as mock_send_mail:
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
@@ -249,60 +247,6 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
||||
assert "notification could not be sent:" in caplog.text
|
||||
|
||||
|
||||
def test_notify_user_by_email_queues_generic_task(settings):
|
||||
"""One generic send_email task is queued per owner, with serializable args."""
|
||||
settings.RECORDING_DOWNLOAD_BASE_URL = "https://acme.com/recordings"
|
||||
settings.RECORDING_EXPIRATION_DAYS = 7
|
||||
|
||||
recording = factories.RecordingFactory(room__name="Conference Room A")
|
||||
recording.created_at = datetime.datetime(
|
||||
2023, 5, 15, 14, 30, 0, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
user = factories.UserFactory(
|
||||
email="franc@test.com", language="fr-fr", timezone="Europe/Paris"
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER, user=user
|
||||
)
|
||||
|
||||
with mock.patch("core.recording.event.notification.send_email.delay") as mock_delay:
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is True
|
||||
mock_delay.assert_called_once_with(
|
||||
template="screen_recording",
|
||||
subject="Your recording is ready",
|
||||
recipients=["franc@test.com"],
|
||||
language="fr-fr",
|
||||
context={
|
||||
"room_name": "Conference Room A",
|
||||
"recording_expiration_days": 7,
|
||||
"link": f"https://acme.com/recordings/{recording.id}",
|
||||
"recording_date": "2023-05-15",
|
||||
"recording_time": "16:30",
|
||||
},
|
||||
)
|
||||
# Celery's JSON serializer must accept every argument
|
||||
json.dumps(mock_delay.call_args.kwargs)
|
||||
|
||||
|
||||
def test_notify_user_by_email_broker_unavailable(caplog):
|
||||
"""A broker outage is reported as a failure without crashing the caller."""
|
||||
recording = factories.RecordingFactory()
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
"core.recording.event.notification.send_email.delay",
|
||||
side_effect=CeleryOperationalError("broker down"),
|
||||
):
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is False
|
||||
assert "notification could not be sent: broker down" in caplog.text
|
||||
|
||||
|
||||
@mock.patch("core.recording.event.notification.requests.post")
|
||||
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
||||
@mock.patch.object(
|
||||
@@ -535,37 +479,3 @@ def test_notify_summary_service_v2_payload_json_serializable_without_timestamps(
|
||||
assert isinstance(title, str)
|
||||
# ...so the payload serializes exactly the way ``requests`` serializes it.
|
||||
json.dumps(payload)
|
||||
|
||||
|
||||
def test_summary_recipient_is_oldest_access():
|
||||
"""With several accesses, the summary goes to the oldest one."""
|
||||
recording = factories.RecordingFactory()
|
||||
oldest = factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
)
|
||||
for _ in range(2):
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
assert NotificationService._get_summary_recipient(recording) == oldest.user
|
||||
|
||||
|
||||
def test_summary_recipient_is_member_starter():
|
||||
"""A starter holding a MEMBER access still receives the summary."""
|
||||
recording = factories.RecordingFactory()
|
||||
starter = factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.MEMBER
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
assert NotificationService._get_summary_recipient(recording) == starter.user
|
||||
|
||||
|
||||
def test_summary_recipient_none_without_access():
|
||||
"""No access: nobody to send the summary to."""
|
||||
recording = factories.RecordingFactory()
|
||||
|
||||
assert NotificationService._get_summary_recipient(recording) is None
|
||||
|
||||
@@ -89,9 +89,9 @@ def test_api_recordings_media_auth_no_abilities():
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_recordings_media_auth_member():
|
||||
def test_api_recordings_media_auth_wrong_abilities():
|
||||
"""
|
||||
Members of a recording (e.g. its starter) are allowed to download it.
|
||||
Test that users with insufficient role permissions cannot download recordings
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
@@ -102,14 +102,13 @@ def test_api_recordings_media_auth_member():
|
||||
|
||||
UserRecordingAccessFactory(user=user, recording=recording, role="member")
|
||||
|
||||
original_url = f"http://localhost/media/{recording.key:s}"
|
||||
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "AWS4-HMAC-SHA256 Credential=" in response["Authorization"]
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wrong_status", ["initiated", "active", "failed_to_stop"])
|
||||
|
||||
@@ -47,8 +47,7 @@ def test_api_recording_retrieve_authenticated():
|
||||
|
||||
def test_api_recording_retrieve_members():
|
||||
"""
|
||||
A user who is a member of a recording (e.g. its starter) should be able
|
||||
to retrieve it.
|
||||
A user who is a member of a recording should not be able to retrieve it.
|
||||
"""
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory()
|
||||
@@ -59,8 +58,10 @@ def test_api_recording_retrieve_members():
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == str(recording.id)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_recording_retrieve_administrators(settings):
|
||||
|
||||
@@ -117,29 +117,6 @@ def test_api_rooms_create_authenticated_existing_slug():
|
||||
assert response.json() == {"slug": ["Room with this Slug already exists."]}
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_slug_held_by_soft_deleted_room():
|
||||
"""
|
||||
A deleted room keeps its slug: creating a room with the same name should
|
||||
fail validation rather than hit the database constraint.
|
||||
"""
|
||||
RoomFactory(name="my room").soft_delete()
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{
|
||||
"name": "My Room!",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"slug": ["Room with this Slug already exists."]}
|
||||
assert Room.all_objects.count() == 1
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_user_default_access_level():
|
||||
"""
|
||||
The user's default room access level should be applied to the new room
|
||||
|
||||
@@ -2,16 +2,11 @@
|
||||
Test rooms API endpoints in the Meet core app: delete.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...analytics import AnalyticsEvent
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import Room, RoomAccessLevel
|
||||
from ...services.room_management import RoomManagement, RoomNotFoundException
|
||||
from ...services.sip_management import SIPManagement
|
||||
from ...models import Room
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -88,52 +83,12 @@ def test_api_rooms_delete_administrators():
|
||||
assert Room.objects.count() == 1
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.analytics.capture")
|
||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_api_rooms_delete_owners(mock_delete_room, _, mock_capture):
|
||||
def test_api_rooms_delete_owners():
|
||||
"""
|
||||
Authenticated users should be able to delete a room for which they are directly
|
||||
owner. The room is soft deleted, its LiveKit room is closed and a ROOM_DELETED
|
||||
analytics event is emitted.
|
||||
owner.
|
||||
"""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")], access_level=RoomAccessLevel.TRUSTED)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/rooms/{room.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
mock_delete_room.assert_called_once_with(str(room.id))
|
||||
assert Room.objects.exists() is False
|
||||
assert Room.all_objects.get(id=room.id).deleted_at is not None
|
||||
|
||||
mock_capture.assert_called_once_with(
|
||||
user,
|
||||
AnalyticsEvent.ROOM_DELETED,
|
||||
{"room_id": str(room.pk), "access_level": RoomAccessLevel.TRUSTED},
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||
@mock.patch.object(
|
||||
RoomManagement,
|
||||
"delete_room",
|
||||
side_effect=RoomNotFoundException("Room does not exist"),
|
||||
)
|
||||
def test_api_rooms_delete_owners_room_not_live(
|
||||
mock_delete_room, mock_delete_dispatch_rule, settings
|
||||
):
|
||||
"""
|
||||
Deleting a room that is not live in LiveKit should still soft delete it, and
|
||||
delete its SIP dispatch rule, as no room_finished webhook will.
|
||||
"""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
|
||||
client = APIClient()
|
||||
@@ -144,47 +99,4 @@ def test_api_rooms_delete_owners_room_not_live(
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
mock_delete_room.assert_called_once_with(str(room.id))
|
||||
mock_delete_dispatch_rule.assert_called_once_with(room.id)
|
||||
assert Room.objects.exists() is False
|
||||
assert Room.all_objects.get(id=room.id).deleted_at is not None
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_api_rooms_delete_soft_deleted(mock_delete_room):
|
||||
"""Deleting a room that is already soft deleted should return a 410."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
room.soft_delete()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/rooms/{room.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 410
|
||||
assert response.json() == {"detail": "This room has been deleted."}
|
||||
mock_delete_room.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_api_rooms_delete_soft_deleted_not_owner(mock_delete_room):
|
||||
"""
|
||||
Deleting a soft-deleted room as a non-owner should return a 403,
|
||||
not revealing that the room has been deleted.
|
||||
"""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "administrator")])
|
||||
room.soft_delete()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/rooms/{room.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
mock_delete_room.assert_not_called()
|
||||
|
||||
@@ -974,19 +974,3 @@ def test_request_entry_accepts_signed_credential(generate_config, _notify):
|
||||
assert response.json()["livekit"] == {"token": "test-token"}
|
||||
generate_config.assert_called_once()
|
||||
assert generate_config.call_args.kwargs["participant_id"] == public_id
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_lobby_not_affected_by_recording_setting(mock_check, settings):
|
||||
"""The recording opt-out must not leak into the lobby capability."""
|
||||
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = False
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
Test rooms API endpoints in the Meet core app: participants management.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
||||
# pylint: disable=no-name-in-module,too-many-lines
|
||||
# pylint: disable=redefined-outer-name,unused-argument,protected-access,no-name-in-module,too-many-lines
|
||||
|
||||
import random
|
||||
from unittest import mock
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
"""Trusted and public rooms: any authenticated participant present in the meeting can record."""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import RecordingFactory, RoomFactory, UserFactory
|
||||
from core.models import (
|
||||
Recording,
|
||||
RecordingStatusChoices,
|
||||
RoleChoices,
|
||||
RoomAccessLevel,
|
||||
)
|
||||
from core.recording.event.notification import NotificationService
|
||||
from core.services.presence import PresenceCache
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_worker_manager():
|
||||
"""Mock the worker service factory and mediator."""
|
||||
with (
|
||||
mock.patch("core.api.viewsets.get_worker_service"),
|
||||
mock.patch("core.api.viewsets.WorkerServiceMediator") as mediator_class,
|
||||
):
|
||||
mediator = mock.Mock()
|
||||
mediator_class.return_value = mediator
|
||||
yield mediator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_recording(settings):
|
||||
"""Recording must be enabled for the endpoints to be reachable."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
|
||||
)
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_open_room_present_user_can_start_recording(
|
||||
mock_check, access_level, mock_worker_manager
|
||||
):
|
||||
"""Authenticated + present in a trusted/public room -> 201, starter is member."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=access_level)
|
||||
|
||||
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 == 201
|
||||
mock_check.assert_called_once_with(room_name=str(room.id), identity=str(user.sub))
|
||||
recording = Recording.objects.get()
|
||||
mock_worker_manager.start.assert_called_once_with(recording)
|
||||
access = recording.accesses.get()
|
||||
assert access.user == user
|
||||
assert access.role == RoleChoices.MEMBER
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_presence_is_cached_until_participant_leaves(mock_check, mock_worker_manager):
|
||||
"""Presence is memoized; clearing it forces a LiveKit re-check."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = f"/api/v1.0/rooms/{room.id}/stop-recording/"
|
||||
|
||||
assert client.post(url).status_code == 200
|
||||
assert client.post(url).status_code == 200
|
||||
assert mock_check.call_count == 1
|
||||
|
||||
# participant_left webhook
|
||||
PresenceCache().clear(room.id, str(user.sub))
|
||||
mock_check.return_value = False
|
||||
|
||||
assert client.post(url).status_code == 403
|
||||
assert mock_check.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
|
||||
)
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=False,
|
||||
)
|
||||
def test_open_room_absent_user_forbidden(mock_check, access_level, mock_worker_manager):
|
||||
"""Authenticated but not connected to the meeting -> 403."""
|
||||
room = RoomFactory(access_level=access_level)
|
||||
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 response.json() == {"detail": "You are not allowed to perform this action."}
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_restricted_room_present_user_forbidden(mock_check, mock_worker_manager):
|
||||
"""Presence is not enough on restricted rooms; LiveKit is not even asked."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
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
|
||||
mock_check.assert_not_called()
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
|
||||
)
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_open_room_present_user_can_stop_recording(
|
||||
mock_check, access_level, mock_worker_manager
|
||||
):
|
||||
"""Any present authenticated user can stop the active recording."""
|
||||
room = RoomFactory(access_level=access_level)
|
||||
recording = RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_worker_manager.stop.assert_called_once_with(recording)
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_admin_does_not_need_presence(mock_check, mock_worker_manager):
|
||||
"""Admins/owners keep recording rights on any room, without a LiveKit check."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
room.accesses.create(user=user, role=RoleChoices.ADMIN)
|
||||
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 == 201
|
||||
mock_check.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
|
||||
)
|
||||
def test_open_room_anonymous_forbidden(access_level):
|
||||
"""Anonymous users never record, even on public rooms."""
|
||||
room = RoomFactory(access_level=access_level)
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
|
||||
)
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_participant_path_disabled_by_setting(
|
||||
mock_check, access_level, settings, mock_worker_manager
|
||||
):
|
||||
"""Self-hosters can opt out: participants then get 403, LiveKit not asked."""
|
||||
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = False
|
||||
room = RoomFactory(access_level=access_level)
|
||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
start = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
stop = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert start.status_code == 403
|
||||
assert stop.status_code == 403
|
||||
mock_check.assert_not_called()
|
||||
mock_worker_manager.start.assert_not_called()
|
||||
mock_worker_manager.stop.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_participant_path_disabled_admin_still_records(
|
||||
mock_check, settings, mock_worker_manager
|
||||
):
|
||||
"""The setting only removes the participant path, never admin rights."""
|
||||
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = False
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
room.accesses.create(user=user, role=RoleChoices.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 == 201
|
||||
mock_check.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_public_room_lobby_is_open_but_empty(mock_check):
|
||||
"""Participants pass the lobby permission on public rooms, which have no lobby."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
listing = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
entry = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
|
||||
)
|
||||
|
||||
assert listing.status_code == 200
|
||||
assert listing.json() == {"participants": []}
|
||||
assert entry.status_code == 404
|
||||
assert entry.json() == {"message": "Room has no lobby system."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [True, False])
|
||||
def test_config_exposes_authenticated_participants_setting(settings, enabled):
|
||||
"""The frontend reads the setting from /config/ to gate the UI."""
|
||||
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = enabled
|
||||
|
||||
response = APIClient().get("/api/v1.0/config/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["recording"]["authenticated_participants_enabled"] is enabled
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_room_owners_and_admins_get_access_to_participant_recording(
|
||||
mock_check, mock_worker_manager
|
||||
):
|
||||
"""Room owners/admins own the recording, the starter is member; room members get nothing."""
|
||||
starter = UserFactory()
|
||||
room_owner = UserFactory()
|
||||
room_admin = UserFactory()
|
||||
room_member = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
room.accesses.create(user=room_owner, role=RoleChoices.OWNER)
|
||||
room.accesses.create(user=room_admin, role=RoleChoices.ADMIN)
|
||||
room.accesses.create(user=room_member, role=RoleChoices.MEMBER)
|
||||
client = APIClient()
|
||||
client.force_login(starter)
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get()
|
||||
assert dict(recording.accesses.values_list("user_id", "role")) == {
|
||||
starter.id: RoleChoices.MEMBER,
|
||||
room_owner.id: RoleChoices.OWNER,
|
||||
room_admin.id: RoleChoices.OWNER,
|
||||
}
|
||||
|
||||
# Room admins can see and download it; plain members cannot.
|
||||
client.force_login(room_admin)
|
||||
assert client.get(f"/api/v1.0/recordings/{recording.id}/").status_code == 200
|
||||
client.force_login(room_member)
|
||||
assert client.get(f"/api/v1.0/recordings/{recording.id}/").status_code == 404
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_room_admin_starter_is_not_duplicated(mock_check, mock_worker_manager):
|
||||
"""A room admin who starts the recording gets an exclusive access."""
|
||||
starter = UserFactory()
|
||||
co_owner = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
room.accesses.create(user=starter, role=RoleChoices.ADMIN)
|
||||
room.accesses.create(user=co_owner, role=RoleChoices.OWNER)
|
||||
client = APIClient()
|
||||
client.force_login(starter)
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get()
|
||||
assert dict(recording.accesses.values_list("user_id", "role")) == {
|
||||
starter.id: RoleChoices.OWNER,
|
||||
co_owner.id: RoleChoices.OWNER,
|
||||
}
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_participant_can_stop_recording_started_by_someone_else(
|
||||
mock_check, mock_worker_manager
|
||||
):
|
||||
"""Stopping is room-wide on trusted rooms, not tied to who started it."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
recording = RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
recording.accesses.create(user=UserFactory(), role=RoleChoices.OWNER)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_worker_manager.stop.assert_called_once_with(recording)
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_summary_goes_to_starter_among_several_owners(mock_check, mock_worker_manager):
|
||||
"""The starter's access is created first, so the summary goes to them."""
|
||||
starter = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
for _ in range(2):
|
||||
room.accesses.create(user=UserFactory(), role=RoleChoices.OWNER)
|
||||
client = APIClient()
|
||||
client.force_login(starter)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "transcript"}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get()
|
||||
assert recording.accesses.count() == 3
|
||||
assert NotificationService._get_summary_recipient(recording) == starter
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_participant_starter_can_retrieve_but_not_delete(
|
||||
mock_check, mock_worker_manager
|
||||
):
|
||||
"""A starter without a privileged room role can download but not delete."""
|
||||
starter = UserFactory()
|
||||
room_owner = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
room.accesses.create(user=room_owner, role=RoleChoices.OWNER)
|
||||
client = APIClient()
|
||||
client.force_login(starter)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get()
|
||||
recording.status = RecordingStatusChoices.SAVED
|
||||
recording.save()
|
||||
|
||||
assert client.get(f"/api/v1.0/recordings/{recording.id}/").status_code == 200
|
||||
assert client.delete(f"/api/v1.0/recordings/{recording.id}/").status_code == 403
|
||||
assert Recording.objects.filter(id=recording.id).exists()
|
||||
|
||||
client.force_login(room_owner)
|
||||
assert client.delete(f"/api/v1.0/recordings/{recording.id}/").status_code == 204
|
||||
assert not Recording.objects.filter(id=recording.id).exists()
|
||||
@@ -193,28 +193,6 @@ def test_api_rooms_retrieve_anonymous_unregistered_not_allowed():
|
||||
assert response.json() == {"detail": "No Room matches the given query."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("allow_unregistered_rooms", [True, False])
|
||||
@pytest.mark.parametrize("lookup", ["id", "slug"])
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
def test_api_rooms_retrieve_soft_deleted(
|
||||
mock_token, lookup, allow_unregistered_rooms, settings
|
||||
):
|
||||
"""
|
||||
Retrieving a soft-deleted room should return a 410, and never fall back
|
||||
to an unregistered room with the same slug.
|
||||
"""
|
||||
settings.ALLOW_UNREGISTERED_ROOMS = allow_unregistered_rooms
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
room.soft_delete()
|
||||
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/rooms/{getattr(room, lookup)!s}/")
|
||||
|
||||
assert response.status_code == 410
|
||||
assert response.json() == {"detail": "This room has been deleted."}
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
@override_settings(
|
||||
LIVEKIT_CONFIGURATION={
|
||||
|
||||
@@ -11,7 +11,7 @@ from livekit import api as livekit_api
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import Recording, RoomAccessLevel
|
||||
from ...models import Recording
|
||||
from ...recording.worker.exceptions import RecordingStartError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -42,13 +42,8 @@ def mock_worker_manager(mock_worker_service):
|
||||
yield mock_mediator
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"recording_enabled, expected_status",
|
||||
[(True, 401), (False, 404)],
|
||||
)
|
||||
def test_start_recording_anonymous(settings, recording_enabled, expected_status):
|
||||
def test_start_recording_anonymous():
|
||||
"""Anonymous users should not be allowed to start room recordings."""
|
||||
settings.RECORDING_ENABLE = recording_enabled
|
||||
room = RoomFactory()
|
||||
client = APIClient()
|
||||
|
||||
@@ -57,24 +52,14 @@ def test_start_recording_anonymous(settings, recording_enabled, expected_status)
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == expected_status
|
||||
assert response.status_code == 401
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("authenticated_participants_enabled", [True, False])
|
||||
def test_start_recording_restricted_room_non_owner_and_non_administrator(
|
||||
settings, authenticated_participants_enabled
|
||||
):
|
||||
"""
|
||||
On restricted rooms, non-owner and non-administrator users should not be
|
||||
allowed to start recordings, whether or not authenticated participants
|
||||
are allowed to record.
|
||||
"""
|
||||
def test_start_recording_non_owner_and_non_administrator(settings):
|
||||
"""Non-owner and Non-Administrator users should not be allowed to start room recordings."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = (
|
||||
authenticated_participants_enabled
|
||||
)
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
@@ -10,7 +10,7 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RecordingFactory, RoomFactory, UserFactory
|
||||
from ...models import Recording, RecordingStatusChoices, RoomAccessLevel
|
||||
from ...models import Recording, RecordingStatusChoices
|
||||
from ...recording.worker.exceptions import RecordingStopError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -41,20 +41,15 @@ def mock_worker_manager(mock_worker_service):
|
||||
yield mock_mediator
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"recording_enabled, expected_status",
|
||||
[(True, 401), (False, 404)],
|
||||
)
|
||||
def test_stop_recording_anonymous(settings, recording_enabled, expected_status):
|
||||
def test_stop_recording_anonymous():
|
||||
"""Anonymous users should not be allowed to stop room recordings."""
|
||||
settings.RECORDING_ENABLE = recording_enabled
|
||||
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 == expected_status
|
||||
assert response.status_code == 401
|
||||
# Verify recording status hasn't changed
|
||||
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
|
||||
|
||||
@@ -62,7 +57,7 @@ def test_stop_recording_anonymous(settings, recording_enabled, expected_status):
|
||||
def test_stop_recording_non_owner_and_non_administrator(settings):
|
||||
"""Non-owner and Non-Administrator users should not be allowed to stop room recordings."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
client = APIClient()
|
||||
|
||||
@@ -11,9 +11,8 @@ from django.utils import timezone
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...api.serializers import RoomSerializer
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import Room, RoomAccessLevel
|
||||
from ...models import RoomAccessLevel
|
||||
from ...services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
@@ -469,40 +468,3 @@ def test_api_rooms_update_authenticated_with_user_access_token(privileged_role):
|
||||
assert response.status_code == 200
|
||||
room.refresh_from_db()
|
||||
assert room.name == "new name"
|
||||
|
||||
|
||||
@patch.object(RoomManagement, "sync_room_metadata")
|
||||
def test_api_rooms_update_soft_deleted_after_load(mock_sync_room_metadata):
|
||||
"""A room soft deleted while an update is in flight should not be restored."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
name="Old name",
|
||||
access_level=RoomAccessLevel.RESTRICTED,
|
||||
users=[(user, "owner")],
|
||||
)
|
||||
|
||||
original_is_valid = RoomSerializer.is_valid
|
||||
|
||||
def soft_delete_while_validating(self, **kwargs):
|
||||
is_valid = original_is_valid(self, **kwargs)
|
||||
Room.all_objects.get(pk=self.instance.pk).soft_delete()
|
||||
return is_valid
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with patch.object(RoomSerializer, "is_valid", soft_delete_while_validating):
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{"name": "New name", "access_level": RoomAccessLevel.PUBLIC},
|
||||
)
|
||||
|
||||
assert response.status_code == 410
|
||||
assert response.json() == {"detail": "This room has been deleted."}
|
||||
|
||||
refreshed_room = Room.all_objects.get(pk=room.pk)
|
||||
assert refreshed_room.deleted_at is not None
|
||||
assert refreshed_room.name == "Old name"
|
||||
assert refreshed_room.access_level == RoomAccessLevel.RESTRICTED
|
||||
|
||||
mock_sync_room_metadata.assert_not_called()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Test LiveKitEvents service.
|
||||
"""
|
||||
# pylint: disable=W0621,W0613, W0212, E0611, C0302
|
||||
# pylint: disable=W0621,W0613, W0212, E0611
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
@@ -27,10 +27,7 @@ from core.services.livekit_events import (
|
||||
to_recording_event,
|
||||
)
|
||||
from core.services.lobby import LobbyService
|
||||
from core.services.room_management import (
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from core.services.room_management import RoomManagementException
|
||||
from core.services.sip_management import (
|
||||
SIPException,
|
||||
SIPManagement,
|
||||
@@ -799,61 +796,6 @@ def test_handle_room_started_raises_error_for_nonexistent_room(service):
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
|
||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
||||
@mock.patch("core.services.room_management.RoomManagement.delete_room")
|
||||
def test_handle_room_started_closes_deleted_room(
|
||||
mock_delete_room, mock_ensure_dispatch_rule, service, settings
|
||||
):
|
||||
"""Should close a LiveKit room recreated for a soft-deleted room."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
room = RoomFactory()
|
||||
room.soft_delete()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
mock_delete_room.assert_called_once_with(str(room.id))
|
||||
mock_ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.room_management.RoomManagement.delete_room",
|
||||
side_effect=RoomNotFoundException("Room does not exist"),
|
||||
)
|
||||
def test_handle_room_started_ignores_already_closed_deleted_room(
|
||||
mock_delete_room, service
|
||||
):
|
||||
"""Should proceed silently when the deleted room is already closed in LiveKit."""
|
||||
room = RoomFactory()
|
||||
room.soft_delete()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
mock_delete_room.assert_called_once_with(str(room.id))
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.room_management.RoomManagement.delete_room",
|
||||
side_effect=RoomManagementException("Could not delete room"),
|
||||
)
|
||||
def test_handle_room_started_raises_error_when_closing_deleted_room_fails(
|
||||
mock_delete_room, service
|
||||
):
|
||||
"""Should raise ActionFailedError when the deleted room cannot be closed."""
|
||||
room = RoomFactory()
|
||||
room.soft_delete()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
expected_error = f"Failed to close deleted room {room.id}"
|
||||
|
||||
with pytest.raises(ActionFailedError, match=expected_error):
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
api.WebhookReceiver, "receive", side_effect=Exception("Invalid payload")
|
||||
)
|
||||
|
||||
@@ -2,19 +2,16 @@
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.db import connection
|
||||
|
||||
import pytest
|
||||
from livekit.api import TwirpError
|
||||
|
||||
from core.factories import RoomFactory
|
||||
from core.models import Room, RoomAccessLevel
|
||||
from core.models import RoomAccessLevel
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from core.services.sip_management import SIPException, SIPManagement
|
||||
|
||||
|
||||
@mock.patch("core.services.room_management.utils.create_livekit_client")
|
||||
@@ -65,221 +62,6 @@ def test_delete_room_raises_management_exception(mock_create_livekit_client):
|
||||
mock_api.aclose.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_soft_delete_commits_before_closing_livekit_room(mock_delete_room, _):
|
||||
"""The deletion is committed before the LiveKit room is closed, so the
|
||||
room_started webhook of a participant reconnecting right away sees it."""
|
||||
room = RoomFactory()
|
||||
|
||||
def assert_deletion_committed(room_name):
|
||||
assert connection.in_atomic_block is False
|
||||
assert Room.all_objects.get(id=room_name).is_deleted
|
||||
|
||||
mock_delete_room.side_effect = assert_deletion_committed
|
||||
|
||||
RoomManagement.soft_delete(room)
|
||||
|
||||
mock_delete_room.assert_called_once_with(str(room.id))
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
RoomManagementException("Could not delete room"),
|
||||
ConnectionError("LiveKit is unreachable"),
|
||||
],
|
||||
)
|
||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule", return_value=False)
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_soft_delete_failure_rolls_back_and_can_be_retried(mock_delete_room, _, error):
|
||||
"""A failed soft delete leaves the room untouched, in database and in memory,
|
||||
so it can be retried."""
|
||||
room = RoomFactory()
|
||||
mock_delete_room.side_effect = error
|
||||
|
||||
with pytest.raises(type(error)):
|
||||
RoomManagement.soft_delete(room)
|
||||
|
||||
assert room.deleted_at is None
|
||||
assert Room.objects.filter(id=room.id).exists()
|
||||
|
||||
mock_delete_room.side_effect = None
|
||||
RoomManagement.soft_delete(room)
|
||||
|
||||
assert mock_delete_room.call_count == 2
|
||||
assert room.deleted_at is not None
|
||||
assert Room.all_objects.get(id=room.id).deleted_at is not None
|
||||
assert Room.objects.filter(id=room.id).exists() is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("sip_setting", ["ROOM_TELEPHONY_ENABLED", "ROOMKIT_ENABLED"])
|
||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||
@mock.patch.object(
|
||||
RoomManagement,
|
||||
"delete_room",
|
||||
side_effect=RoomNotFoundException("Room does not exist"),
|
||||
)
|
||||
def test_soft_delete_room_not_live_deletes_dispatch_rule(
|
||||
mock_delete_room, mock_delete_dispatch_rule, sip_setting, settings
|
||||
):
|
||||
"""A roomkit join creates the dispatch rule before any LiveKit room exists."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
settings.ROOMKIT_ENABLED = False
|
||||
setattr(settings, sip_setting, True)
|
||||
room = RoomFactory()
|
||||
|
||||
RoomManagement.soft_delete(room)
|
||||
|
||||
mock_delete_room.assert_called_once_with(str(room.id))
|
||||
mock_delete_dispatch_rule.assert_called_once_with(room.id)
|
||||
assert Room.all_objects.get(id=room.id).is_deleted
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_soft_delete_live_room_deletes_dispatch_rule_before_closing_it(settings):
|
||||
"""The dispatch rule is deleted before the LiveKit room is closed."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
room = RoomFactory()
|
||||
calls = mock.Mock()
|
||||
|
||||
with (
|
||||
mock.patch.object(RoomManagement, "delete_room", calls.delete_room),
|
||||
mock.patch.object(
|
||||
SIPManagement, "delete_dispatch_rule", calls.delete_dispatch_rule
|
||||
),
|
||||
):
|
||||
RoomManagement.soft_delete(room)
|
||||
|
||||
assert calls.mock_calls == [
|
||||
mock.call.delete_dispatch_rule(room.id),
|
||||
mock.call.delete_room(str(room.id)),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_soft_delete_sip_disabled_skips_dispatch_rule(
|
||||
mock_delete_room, mock_delete_dispatch_rule, settings
|
||||
):
|
||||
"""Without telephony nor roomkit, no dispatch rule is looked up."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
settings.ROOMKIT_ENABLED = False
|
||||
room = RoomFactory()
|
||||
|
||||
RoomManagement.soft_delete(room)
|
||||
|
||||
mock_delete_room.assert_called_once_with(str(room.id))
|
||||
mock_delete_dispatch_rule.assert_not_called()
|
||||
assert Room.all_objects.get(id=room.id).is_deleted
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
||||
@mock.patch.object(
|
||||
SIPManagement,
|
||||
"delete_dispatch_rule",
|
||||
side_effect=SIPException("Could not delete dispatch rules"),
|
||||
)
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_soft_delete_dispatch_rule_failure_rolls_back(
|
||||
mock_delete_room, mock_delete_dispatch_rule, mock_ensure_dispatch_rule, settings
|
||||
):
|
||||
"""A dispatch rule that can't be deleted fails the soft delete like a LiveKit
|
||||
room that can't be closed: it is rolled back so it can be retried, and the
|
||||
LiveKit room is left open."""
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
room = RoomFactory()
|
||||
|
||||
with pytest.raises(RoomManagementException):
|
||||
RoomManagement.soft_delete(room)
|
||||
|
||||
mock_delete_room.assert_not_called()
|
||||
mock_delete_dispatch_rule.assert_called_once_with(room.id)
|
||||
mock_ensure_dispatch_rule.assert_not_called()
|
||||
assert room.deleted_at is None
|
||||
assert Room.objects.filter(id=room.id).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule", return_value=True)
|
||||
@mock.patch.object(
|
||||
RoomManagement,
|
||||
"delete_room",
|
||||
side_effect=RoomManagementException("Could not delete room"),
|
||||
)
|
||||
def test_soft_delete_room_close_failure_restores_dispatch_rule(
|
||||
_, __, mock_ensure_dispatch_rule, settings
|
||||
):
|
||||
"""A LiveKit room that can't be closed rolls the deletion back, including
|
||||
its dispatch rule, so phone callers can still join the room with its PIN."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
room = RoomFactory()
|
||||
|
||||
with pytest.raises(RoomManagementException, match="Could not delete room"):
|
||||
RoomManagement.soft_delete(room)
|
||||
|
||||
mock_ensure_dispatch_rule.assert_called_once_with(room)
|
||||
assert room.deleted_at is None
|
||||
assert Room.objects.filter(id=room.id).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule", return_value=False)
|
||||
@mock.patch.object(
|
||||
RoomManagement,
|
||||
"delete_room",
|
||||
side_effect=RoomManagementException("Could not delete room"),
|
||||
)
|
||||
def test_soft_delete_room_close_failure_without_rule_skips_restore(
|
||||
_, __, mock_ensure_dispatch_rule, settings
|
||||
):
|
||||
"""A room that had no dispatch rule does not get one on rollback, as nothing
|
||||
would ever delete it."""
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
room = RoomFactory()
|
||||
|
||||
with pytest.raises(RoomManagementException):
|
||||
RoomManagement.soft_delete(room)
|
||||
|
||||
mock_ensure_dispatch_rule.assert_not_called()
|
||||
assert room.deleted_at is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@mock.patch.object(
|
||||
SIPManagement,
|
||||
"ensure_dispatch_rule",
|
||||
side_effect=SIPException("Could not create dispatch rule"),
|
||||
)
|
||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule", return_value=True)
|
||||
@mock.patch.object(
|
||||
RoomManagement,
|
||||
"delete_room",
|
||||
side_effect=RoomManagementException("Could not delete room"),
|
||||
)
|
||||
def test_soft_delete_restore_failure_raises_original_error(
|
||||
_, __, ___, settings, caplog
|
||||
):
|
||||
"""A dispatch rule that can't be restored is logged without hiding the error
|
||||
that caused the rollback."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
room = RoomFactory()
|
||||
|
||||
with pytest.raises(RoomManagementException, match="Could not delete room"):
|
||||
RoomManagement.soft_delete(room)
|
||||
|
||||
assert f"Could not restore dispatch rule for room {room.id}" in caplog.text
|
||||
assert room.deleted_at is None
|
||||
assert Room.objects.filter(id=room.id).exists()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_sync_room_metadata_pushes_configuration_and_access_level(mock_update_metadata):
|
||||
"""The room's configuration and access level are forwarded to LiveKit."""
|
||||
|
||||
@@ -257,31 +257,6 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_already_deleted(mock_client_factory, mock_list_rules):
|
||||
"""A rule deleted between listing and deletion (e.g. by both a room deletion and
|
||||
its room_finished webhook) should not fail, nor stop the other deletions."""
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = ["rule-1", "rule-2"]
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
|
||||
side_effect=[
|
||||
TwirpError(msg="sip dispatch rule not found", code="not_found", status=404),
|
||||
None,
|
||||
]
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = sip_management.delete_dispatch_rule(room.id)
|
||||
|
||||
assert result is True
|
||||
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
"""Tests for the generic send_email task."""
|
||||
|
||||
# pylint: disable=unused-argument,redefined-outer-name
|
||||
|
||||
import smtplib
|
||||
from unittest import mock
|
||||
|
||||
from django.core.mail import get_connection
|
||||
from django.utils.translation import gettext_noop
|
||||
|
||||
import pytest
|
||||
|
||||
from core.tasks.mail import send_email
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def email_settings(settings):
|
||||
"""Branding settings shared by every email."""
|
||||
settings.EMAIL_BRAND_NAME = "ACME"
|
||||
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
|
||||
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
||||
settings.EMAIL_DOMAIN = "acme.com"
|
||||
settings.EMAIL_FROM = "notifications@acme.com"
|
||||
return settings
|
||||
|
||||
|
||||
@mock.patch("core.tasks.mail.send_mail")
|
||||
@mock.patch("core.tasks.mail.render_to_string", side_effect=["<p>html</p>", "text"])
|
||||
def test_send_email_renders_template_and_sends(
|
||||
mock_render, mock_send_mail, email_settings
|
||||
):
|
||||
"""Both template variants are rendered with base + caller context, then sent."""
|
||||
send_email(
|
||||
template="some_template",
|
||||
subject="Any subject",
|
||||
recipients=["a@test.com", "b@test.com"],
|
||||
context={"foo": "bar", "brandname": "Overridden"},
|
||||
)
|
||||
|
||||
expected_context = {
|
||||
"brandname": "Overridden",
|
||||
"support_email": "support@acme.com",
|
||||
"logo_img": "https://acme.com/logo",
|
||||
"domain": "acme.com",
|
||||
"foo": "bar",
|
||||
}
|
||||
assert mock_render.call_args_list == [
|
||||
mock.call("mail/html/some_template.html", expected_context),
|
||||
mock.call("mail/text/some_template.txt", expected_context),
|
||||
]
|
||||
mock_send_mail.assert_called_once_with(
|
||||
"Any subject",
|
||||
"text",
|
||||
"notifications@acme.com",
|
||||
["a@test.com", "b@test.com"],
|
||||
html_message="<p>html</p>",
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.tasks.mail.send_mail")
|
||||
def test_send_email_translates_subject_in_requested_language(
|
||||
mock_send_mail, email_settings
|
||||
):
|
||||
"""The subject msgid is translated in the worker, in the recipient language."""
|
||||
send_email(
|
||||
template="screen_recording",
|
||||
subject=gettext_noop("Your recording is ready"),
|
||||
recipients=["franc@test.com"],
|
||||
context={"room_name": "Room", "link": "https://acme.com/r/1"},
|
||||
language="fr-fr",
|
||||
)
|
||||
|
||||
subject, body, *_ = mock_send_mail.call_args[0]
|
||||
assert subject == "Votre enregistrement est prêt"
|
||||
assert "Votre enregistrement est prêt !" in body
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.tasks.mail.send_mail", side_effect=smtplib.SMTPException("SMTP Error")
|
||||
)
|
||||
@mock.patch("core.tasks.mail.render_to_string", return_value="content")
|
||||
def test_send_email_raises_on_smtp_error(mock_render, mock_send_mail, email_settings):
|
||||
"""SMTP errors propagate so Celery can retry the task."""
|
||||
with pytest.raises(smtplib.SMTPException):
|
||||
send_email(
|
||||
template="some_template",
|
||||
subject="Any subject",
|
||||
recipients=["a@test.com"],
|
||||
)
|
||||
|
||||
|
||||
def test_smtp_backend_uses_email_timeout(settings):
|
||||
"""A stalled SMTP server cannot hold the worker forever."""
|
||||
settings.EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||
settings.EMAIL_TIMEOUT = 12
|
||||
|
||||
assert get_connection().timeout == 12
|
||||
@@ -2,8 +2,6 @@
|
||||
Test resource accesses API endpoints in the Meet core app.
|
||||
"""
|
||||
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import random
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
@@ -958,99 +956,3 @@ def test_api_room_user_access_delete_owners_last_owner():
|
||||
|
||||
assert response.status_code == 403
|
||||
assert ResourceAccess.objects.count() == 1
|
||||
|
||||
|
||||
# Soft-deleted rooms
|
||||
|
||||
|
||||
def test_api_room_user_accesses_create_soft_deleted_room():
|
||||
"""
|
||||
Owners of a soft-deleted room should not be allowed to add accesses to it.
|
||||
"""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
room.soft_delete()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/resource-accesses/",
|
||||
{
|
||||
"user": str(UserFactory().id),
|
||||
"resource": str(room.id),
|
||||
"role": "member",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 410
|
||||
assert response.json() == {"detail": "This room has been deleted."}
|
||||
assert ResourceAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_room_user_accesses_create_soft_deleted_room_not_administrator():
|
||||
"""
|
||||
Users without privileges on a soft-deleted room should get a 403,
|
||||
not revealing that the room has been deleted.
|
||||
"""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "member")])
|
||||
room.soft_delete()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/resource-accesses/",
|
||||
{
|
||||
"user": str(UserFactory().id),
|
||||
"resource": str(room.id),
|
||||
"role": "member",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert ResourceAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_room_user_accesses_update_soft_deleted_room():
|
||||
"""
|
||||
Owners of a soft-deleted room should not be allowed to update its accesses.
|
||||
"""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
access = UserResourceAccessFactory(resource=room, role="member")
|
||||
room.soft_delete()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/resource-accesses/{access.id!s}/",
|
||||
{"role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 410
|
||||
access.refresh_from_db()
|
||||
assert access.role == "member"
|
||||
|
||||
|
||||
def test_api_room_user_access_delete_soft_deleted_room():
|
||||
"""
|
||||
Owners of a soft-deleted room should not be allowed to remove its accesses.
|
||||
"""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
access = UserResourceAccessFactory(resource=room, role="member")
|
||||
room.soft_delete()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/resource-accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 410
|
||||
assert ResourceAccess.objects.filter(id=access.id).exists() is True
|
||||
|
||||
@@ -35,20 +35,6 @@ def test_user_access_token_users_me():
|
||||
assert response.data["email"] == user.email
|
||||
|
||||
|
||||
def test_user_access_token_custom_token_type(settings):
|
||||
"""The Authorization scheme should match the configured token type."""
|
||||
settings.USER_ACCESS_TOKEN_TYPE = "Token"
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Token {generate_user_access_token(user)}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["email"] == user.email
|
||||
|
||||
|
||||
def test_user_access_token_expired():
|
||||
"""An expired user access token should be rejected."""
|
||||
user = UserFactory()
|
||||
|
||||
@@ -67,19 +67,6 @@ def test_resource_server_backend_get_or_create_user_no_creation(settings):
|
||||
assert not User.objects.filter(sub="new-sub").exists()
|
||||
|
||||
|
||||
def test_resource_server_backend_get_or_create_user_invalid_sub(settings):
|
||||
"""A sub longer than 255 characters should be rejected without creating a user."""
|
||||
|
||||
settings.OIDC_CREATE_USER = True
|
||||
|
||||
with pytest.raises(SuspiciousOperation, match="invalid sub claim"):
|
||||
ResourceServerBackend().get_or_create_user(
|
||||
access_token="token", id_token=None, payload=_payload("a" * 256)
|
||||
)
|
||||
|
||||
assert not User.objects.exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_rooms_list_resource_server_inactive_user(settings):
|
||||
"""End to end: a valid introspected token for an inactive user should get 401."""
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
"""Tests for the external API MenshenAuthentication."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import UserFactory
|
||||
from core.models import RoleChoices, Room, User
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
SERVER_URL = "https://menshen.example.com"
|
||||
INTROSPECTION_ENDPOINT = f"{SERVER_URL}/auth/token/introspect/"
|
||||
TOKEN = "menshen-exchanged-token"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def menshen_settings(settings):
|
||||
"""Enable Menshen authentication."""
|
||||
settings.MENSHEN_ENABLED = True
|
||||
settings.MENSHEN_SERVER_URL = SERVER_URL
|
||||
settings.MENSHEN_CLIENT_ID = "meet"
|
||||
settings.MENSHEN_CLIENT_SECRET = "meet-secret"
|
||||
|
||||
|
||||
def _introspection(sub, scope="meet:room:create", **overrides):
|
||||
return {
|
||||
"active": True,
|
||||
"sub": sub,
|
||||
"email": "user@example.com",
|
||||
"scope": scope,
|
||||
"aud": "meet",
|
||||
"client_id": "menshen",
|
||||
**overrides,
|
||||
}
|
||||
|
||||
|
||||
def _create_room(token=TOKEN):
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
return client.post("/external-api/v1.0/rooms/", {}, format="json")
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_menshen_active_token():
|
||||
"""An active token with a mapped scope should create a room owned by the user."""
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
# Catch and mock external HTTP requests
|
||||
responses.add(
|
||||
responses.POST,
|
||||
INTROSPECTION_ENDPOINT,
|
||||
json=_introspection(user.sub),
|
||||
match=[responses.matchers.urlencoded_params_matcher({"token": TOKEN})],
|
||||
)
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get(id=response.data["id"])
|
||||
assert room.get_role(user) == RoleChoices.OWNER
|
||||
assert responses.calls[0].request.headers["Authorization"].startswith("Basic ")
|
||||
|
||||
|
||||
@responses.activate
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_menshen_other_token_type_ignored(mock_rs_authenticate):
|
||||
"""A token sent with another scheme than Bearer should not be introspected."""
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Token {TOKEN}")
|
||||
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert len(responses.calls) == 0
|
||||
mock_rs_authenticate.assert_called_once()
|
||||
|
||||
|
||||
@responses.activate
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_menshen_disabled(mock_rs_authenticate, settings):
|
||||
"""Menshen should not be called when disabled."""
|
||||
|
||||
settings.MENSHEN_ENABLED = False
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 401
|
||||
assert len(responses.calls) == 0
|
||||
mock_rs_authenticate.assert_called_once()
|
||||
|
||||
|
||||
@responses.activate
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_menshen_inactive_token_defers(mock_rs_authenticate):
|
||||
"""An inactive token should defer to the next authentication backend."""
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
INTROSPECTION_ENDPOINT,
|
||||
json=_introspection(user.sub, scope="meet:room:create", active=False),
|
||||
)
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 401
|
||||
mock_rs_authenticate.assert_called_once()
|
||||
|
||||
|
||||
@responses.activate
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_menshen_error_defers(mock_rs_authenticate):
|
||||
"""A Menshen error should defer to the next authentication backend."""
|
||||
|
||||
responses.add(responses.POST, INTROSPECTION_ENDPOINT, status=500)
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 401
|
||||
mock_rs_authenticate.assert_called_once()
|
||||
|
||||
|
||||
@responses.activate
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_menshen_unparsable_response_defers(mock_rs_authenticate):
|
||||
"""A response the client can't parse should defer to the next backend."""
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
INTROSPECTION_ENDPOINT,
|
||||
json={"active": True, "unexpected": "field"},
|
||||
)
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 401
|
||||
mock_rs_authenticate.assert_called_once()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_menshen_resource_server_token(settings):
|
||||
"""End to end: a token issued by the OIDC provider is unknown to Menshen and
|
||||
should still authenticate through the resource server."""
|
||||
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
|
||||
user = UserFactory()
|
||||
token = "oidc-access-token"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
INTROSPECTION_ENDPOINT,
|
||||
json={"active": False},
|
||||
match=[responses.matchers.urlencoded_params_matcher({"token": token})],
|
||||
)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"active": True,
|
||||
"sub": user.sub,
|
||||
"scope": "openid lasuite_meet rooms:create",
|
||||
"client_id": "app",
|
||||
},
|
||||
)
|
||||
|
||||
response = _create_room(token)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get(id=response.data["id"])
|
||||
assert room.get_role(user) == RoleChoices.OWNER
|
||||
responses.assert_call_count(INTROSPECTION_ENDPOINT, 1)
|
||||
responses.assert_call_count("https://oidc.example.com/introspect", 1)
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_menshen_unmapped_scope():
|
||||
"""Scopes granted for other services or other Meet actions should not grant access."""
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
INTROSPECTION_ENDPOINT,
|
||||
json=_introspection(user.sub, scope="drive:item:create meet:room:list"),
|
||||
)
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "insufficient permissions." in str(response.data).lower()
|
||||
assert not Room.objects.exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_menshen_missing_sub():
|
||||
"""An active token without sub should be rejected."""
|
||||
|
||||
responses.add(responses.POST, INTROSPECTION_ENDPOINT, json=_introspection(None))
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "invalid token claims." in str(response.data).lower()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_menshen_invalid_sub(settings):
|
||||
"""An active token with an invalid sub should be rejected without creating a user."""
|
||||
|
||||
settings.OIDC_CREATE_USER = True
|
||||
|
||||
responses.add(
|
||||
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("invalid\nsub")
|
||||
)
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "invalid sub claim" in str(response.data).lower()
|
||||
assert not User.objects.exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_menshen_inactive_user():
|
||||
"""An active token for an inactive user should be rejected."""
|
||||
|
||||
user = UserFactory(is_active=False)
|
||||
|
||||
responses.add(responses.POST, INTROSPECTION_ENDPOINT, json=_introspection(user.sub))
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "user account is disabled." in str(response.data).lower()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_menshen_unknown_user_created(settings):
|
||||
"""An unknown sub should create a user when OIDC_CREATE_USER is set."""
|
||||
|
||||
settings.OIDC_CREATE_USER = True
|
||||
|
||||
responses.add(
|
||||
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
|
||||
)
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 201
|
||||
user = User.objects.get(sub="new-sub")
|
||||
assert user.email == "user@example.com"
|
||||
assert user.is_active is True
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_menshen_unknown_user_not_created(settings):
|
||||
"""An unknown sub should be rejected when OIDC_CREATE_USER is unset."""
|
||||
|
||||
settings.OIDC_CREATE_USER = False
|
||||
|
||||
responses.add(
|
||||
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
|
||||
)
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "user not found." in str(response.data).lower()
|
||||
assert not User.objects.filter(sub="new-sub").exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize("existing_sub", [None, "other-sub"])
|
||||
def test_menshen_unknown_user_email_already_used(settings, existing_sub):
|
||||
"""An unknown sub should not create a second account with an email already
|
||||
used, by a provisional user or a user with another sub, when
|
||||
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=True."""
|
||||
|
||||
settings.OIDC_CREATE_USER = True
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
|
||||
existing_user = UserFactory(sub=existing_sub, email="User@Example.com")
|
||||
|
||||
responses.add(
|
||||
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
|
||||
)
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "email already used by another account." in str(response.data).lower()
|
||||
assert User.objects.count() == 1
|
||||
existing_user.refresh_from_db()
|
||||
assert existing_user.sub == existing_sub
|
||||
assert not Room.objects.exists()
|
||||
@@ -17,7 +17,6 @@ from lasuite.oidc_resource_server.authentication import ResourceServerAuthentica
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.analytics import AnalyticsEvent
|
||||
from core.external_api.serializers import RoomSerializer
|
||||
from core.factories import ApplicationFactory, RoomFactory, UserFactory
|
||||
from core.models import (
|
||||
Application,
|
||||
@@ -27,12 +26,7 @@ from core.models import (
|
||||
RoomAccessLevel,
|
||||
User,
|
||||
)
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from core.services.sip_management import SIPManagement
|
||||
from core.services.room_management import RoomManagement
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -569,53 +563,6 @@ def test_api_rooms_retrieve_not_found():
|
||||
assert "no room matches the given query." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_invalid_id():
|
||||
"""Retrieving a room with a malformed id should return a 404."""
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/not-a-uuid/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_soft_deleted():
|
||||
"""Retrieving a soft-deleted room should return a 410."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
room.soft_delete()
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 410
|
||||
assert response.json() == {"detail": "This room has been deleted."}
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_soft_deleted_not_member():
|
||||
"""Retrieving a soft-deleted room without any role on it should return a 403,
|
||||
not revealing that the room has been deleted."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory()
|
||||
room.soft_delete()
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_rooms_create_requires_authentication():
|
||||
"""Creating rooms without authentication should return 401."""
|
||||
|
||||
@@ -1445,253 +1392,6 @@ def test_api_rooms_update_tracks_analytics(mock_update_metadata, mock_capture):
|
||||
mock_update_metadata.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.external_api.viewsets.analytics.capture")
|
||||
@mock.patch.object(RoomManagement, "sync_room_metadata")
|
||||
def test_api_rooms_update_soft_deleted_after_load(
|
||||
mock_sync_room_metadata, mock_capture
|
||||
):
|
||||
"""A room soft deleted while an update is in flight should not be restored."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)], access_level=RoomAccessLevel.TRUSTED
|
||||
)
|
||||
|
||||
original_is_valid = RoomSerializer.is_valid
|
||||
|
||||
def soft_delete_while_validating(self, **kwargs):
|
||||
is_valid = original_is_valid(self, **kwargs)
|
||||
Room.all_objects.get(pk=self.instance.pk).soft_delete()
|
||||
return is_valid
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
with mock.patch.object(RoomSerializer, "is_valid", soft_delete_while_validating):
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 410
|
||||
|
||||
refreshed_room = Room.all_objects.get(pk=room.pk)
|
||||
assert refreshed_room.deleted_at is not None
|
||||
assert refreshed_room.access_level == RoomAccessLevel.TRUSTED
|
||||
|
||||
mock_sync_room_metadata.assert_not_called()
|
||||
mock_capture.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_api_rooms_delete_requires_authentication(mock_delete_room):
|
||||
"""Deleting a room without authentication should return 401."""
|
||||
|
||||
room = RoomFactory(users=[(UserFactory(), RoleChoices.OWNER)])
|
||||
|
||||
client = APIClient()
|
||||
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert Room.objects.filter(id=room.id).exists() is True
|
||||
mock_delete_room.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_api_rooms_delete_requires_scope(mock_delete_room):
|
||||
"""Deleting a room requires the ROOMS_DELETE scope."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
# Token without ROOMS_DELETE scope
|
||||
token = generate_test_token(
|
||||
user, [ApplicationScope.ROOMS_RETRIEVE, ApplicationScope.ROOMS_UPDATE]
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert (
|
||||
"insufficient permissions. required scope: rooms:delete"
|
||||
in str(response.data).lower()
|
||||
)
|
||||
assert Room.objects.filter(id=room.id).exists() is True
|
||||
mock_delete_room.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", [RoleChoices.ADMIN, RoleChoices.MEMBER, None])
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_api_rooms_delete_without_ownership(mock_delete_room, role):
|
||||
"""Only owners should be able to delete a room, administrators included."""
|
||||
|
||||
user = UserFactory()
|
||||
users = [(user, role)] if role else []
|
||||
room = RoomFactory(users=users)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert Room.objects.filter(id=room.id).exists() is True
|
||||
mock_delete_room.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_api_rooms_delete_unknown_room(mock_delete_room):
|
||||
"""Deleting a room that does not exist should return 404."""
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.delete(f"/external-api/v1.0/rooms/{uuid.uuid4()}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
mock_delete_room.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.external_api.viewsets.analytics.capture")
|
||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_api_rooms_delete_success(mock_delete_room, _, mock_capture):
|
||||
"""Owners should be able to delete a room: it is soft deleted, its LiveKit
|
||||
room is closed and a ROOM_DELETED analytics event is emitted."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)], access_level=RoomAccessLevel.TRUSTED
|
||||
)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
|
||||
application = Application.objects.get()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 204
|
||||
mock_delete_room.assert_called_once_with(str(room.id))
|
||||
assert Room.objects.filter(id=room.id).exists() is False
|
||||
assert Room.all_objects.get(id=room.id).deleted_at is not None
|
||||
|
||||
mock_capture.assert_called_once()
|
||||
captured_user, event, properties = mock_capture.call_args[0]
|
||||
|
||||
assert captured_user == user
|
||||
assert event == AnalyticsEvent.ROOM_DELETED
|
||||
assert properties == {
|
||||
"room_id": str(room.pk),
|
||||
"access_level": RoomAccessLevel.TRUSTED,
|
||||
"client_id": str(application.client_id),
|
||||
"external_api": True,
|
||||
"auth_method": "ApplicationJWTAuthentication",
|
||||
"$set": {"email": user.email},
|
||||
}
|
||||
|
||||
|
||||
@mock.patch("core.external_api.viewsets.analytics.capture")
|
||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||
@mock.patch.object(
|
||||
RoomManagement,
|
||||
"delete_room",
|
||||
side_effect=RoomManagementException("Could not delete room"),
|
||||
)
|
||||
def test_api_rooms_delete_livekit_failure(mock_delete_room, _, mock_capture):
|
||||
"""When the LiveKit room can't be closed, the deletion should be rolled back
|
||||
and no analytics event emitted."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {"detail": "Could not delete the room, please try again."}
|
||||
mock_delete_room.assert_called_once_with(str(room.id))
|
||||
assert Room.objects.get(id=room.id).deleted_at is None
|
||||
mock_capture.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||
@mock.patch.object(
|
||||
RoomManagement,
|
||||
"delete_room",
|
||||
side_effect=RoomNotFoundException("Room does not exist"),
|
||||
)
|
||||
def test_api_rooms_delete_room_not_live(
|
||||
mock_delete_room, mock_delete_dispatch_rule, settings
|
||||
):
|
||||
"""Deleting a room that is not live in LiveKit should still soft delete it,
|
||||
and delete its SIP dispatch rule, as no room_finished webhook will."""
|
||||
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 204
|
||||
mock_delete_room.assert_called_once_with(str(room.id))
|
||||
mock_delete_dispatch_rule.assert_called_once_with(room.id)
|
||||
assert Room.objects.filter(id=room.id).exists() is False
|
||||
assert Room.all_objects.get(id=room.id).deleted_at is not None
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_api_rooms_delete_soft_deleted(mock_delete_room):
|
||||
"""Deleting a room that is already soft deleted should return a 410."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
room.soft_delete()
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 410
|
||||
mock_delete_room.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_api_rooms_delete_soft_deleted_not_owner(mock_delete_room):
|
||||
"""Deleting a soft-deleted room as a non-owner should return a 403,
|
||||
not revealing that the room has been deleted."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
|
||||
room.soft_delete()
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
mock_delete_room.assert_not_called()
|
||||
|
||||
|
||||
def test_api_rooms_response_no_url(settings):
|
||||
"""Response should not include url field when APPLICATION_BASE_URL is None."""
|
||||
settings.APPLICATION_BASE_URL = None
|
||||
|
||||
@@ -91,7 +91,7 @@ def test_models_recording_get_abilities_privileges_active(role):
|
||||
|
||||
|
||||
def test_models_recording_get_abilities_member_active():
|
||||
"""A member can retrieve the recording but not stop or delete it."""
|
||||
"""Test abilities for a user who is unprivileged."""
|
||||
|
||||
user = UserFactory()
|
||||
access = UserRecordingAccessFactory(role="member", user=user)
|
||||
@@ -101,7 +101,7 @@ def test_models_recording_get_abilities_member_active():
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"retrieve": False,
|
||||
"stop": False,
|
||||
"update": False,
|
||||
}
|
||||
@@ -129,7 +129,7 @@ def test_models_recording_get_abilities_privileges_final(role):
|
||||
|
||||
|
||||
def test_models_recording_get_abilities_member_final():
|
||||
"""A member can retrieve the recording but not stop or delete it."""
|
||||
"""Test abilities for a user who is unprivileged."""
|
||||
|
||||
user = UserFactory()
|
||||
access = UserRecordingAccessFactory(role="member", user=user)
|
||||
@@ -139,7 +139,7 @@ def test_models_recording_get_abilities_member_final():
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"retrieve": False,
|
||||
"stop": False,
|
||||
"update": False,
|
||||
}
|
||||
|
||||
@@ -189,21 +189,6 @@ def test_models_rooms_is_public_property():
|
||||
assert private_room.is_public is False
|
||||
|
||||
|
||||
def test_models_rooms_soft_delete_concurrent():
|
||||
"""A deletion racing another one on a stale instance matches no row and fails,
|
||||
so the winner's timestamp is never overwritten."""
|
||||
room = RoomFactory()
|
||||
stale_room = Room.all_objects.get(pk=room.pk)
|
||||
|
||||
room.soft_delete()
|
||||
|
||||
assert stale_room.deleted_at is None
|
||||
with pytest.raises(RuntimeError, match="This room is already deleted."):
|
||||
stale_room.soft_delete()
|
||||
|
||||
assert Room.all_objects.get(pk=room.pk).deleted_at == room.deleted_at
|
||||
|
||||
|
||||
@mock.patch.object(Room, "generate_unique_pin_code")
|
||||
def test_telephony_and_roomkit_disabled_skips_pin_generation(
|
||||
mock_generate_unique_pin_code, settings
|
||||
|
||||
@@ -69,10 +69,7 @@ def test_models_users_sub_validator(sub, is_valid):
|
||||
else:
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match=(
|
||||
"Enter a valid sub. This value should be printable ASCII only, "
|
||||
"with 255 characters or less."
|
||||
),
|
||||
match="Enter a valid sub. This value should be printable ASCII only.",
|
||||
):
|
||||
user.full_clean()
|
||||
|
||||
|
||||
@@ -333,8 +333,7 @@ def detect_mimetype(file_buffer: bytes, filename: str | None = None) -> str:
|
||||
str: The detected MIME type
|
||||
|
||||
Notes:
|
||||
Originally from
|
||||
https://github.com/suitenumerique/drive/blob/564822d31f071c6dfacd112ef4b7146c73077cd9/src/backend/core/api/utils.py#L166
|
||||
Originally from https://github.com/suitenumerique/drive/blob/564822d31f071c6dfacd112ef4b7146c73077cd9/src/backend/core/api/utils.py#L166 # pylint:disable=line-too-long
|
||||
"""
|
||||
# Initialize magic detector
|
||||
mime_detector = magic.Magic(mime=True)
|
||||
|
||||
@@ -17,16 +17,7 @@ def sub_validator(value):
|
||||
``isprintable()`` is false exactly for those control characters, while
|
||||
space (U+0020) remains allowed.
|
||||
"""
|
||||
msg = (
|
||||
"Enter a valid sub. This value should be printable ASCII only, "
|
||||
"with 255 characters or less."
|
||||
)
|
||||
|
||||
if value is None:
|
||||
raise ValidationError(_(msg))
|
||||
|
||||
if len(value) > 255:
|
||||
raise ValidationError(_(msg))
|
||||
|
||||
if not value.isascii() or not value.isprintable():
|
||||
raise ValidationError(_(msg))
|
||||
raise ValidationError(
|
||||
_("Enter a valid sub. This value should be printable ASCII only.")
|
||||
)
|
||||
|
||||
@@ -508,7 +508,6 @@ class Base(Configuration):
|
||||
EMAIL_PORT = values.PositiveIntegerValue(None)
|
||||
EMAIL_USE_TLS = values.BooleanValue(False)
|
||||
EMAIL_USE_SSL = values.BooleanValue(False)
|
||||
EMAIL_TIMEOUT = values.PositiveIntegerValue(30)
|
||||
EMAIL_FROM = values.Value("from@example.com")
|
||||
EMAIL_BRAND_NAME = values.Value(None)
|
||||
EMAIL_SUPPORT_EMAIL = values.Value(None)
|
||||
@@ -709,20 +708,6 @@ class Base(Configuration):
|
||||
default=None, environ_name="OIDC_RS_SCOPES_PREFIX", environ_prefix=None
|
||||
)
|
||||
|
||||
# Menshen, La Suite's OAuth 2.0 token exchange server
|
||||
MENSHEN_ENABLED = values.BooleanValue(
|
||||
False, environ_name="MENSHEN_ENABLED", environ_prefix=None
|
||||
)
|
||||
MENSHEN_SERVER_URL = values.Value(
|
||||
None, environ_name="MENSHEN_SERVER_URL", environ_prefix=None
|
||||
)
|
||||
MENSHEN_CLIENT_ID = values.Value(
|
||||
None, environ_name="MENSHEN_CLIENT_ID", environ_prefix=None
|
||||
)
|
||||
MENSHEN_CLIENT_SECRET = SecretFileValue(
|
||||
None, environ_name="MENSHEN_CLIENT_SECRET", environ_prefix=None
|
||||
)
|
||||
|
||||
# Video conference configuration
|
||||
LIVEKIT_CONFIGURATION = {
|
||||
"api_key": SecretFileValue(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
|
||||
@@ -792,9 +777,6 @@ class Base(Configuration):
|
||||
ROOM_INACTIVITY_DELETION_DAYS = values.PositiveIntegerValue(
|
||||
None, environ_name="ROOM_INACTIVITY_DELETION_DAYS", environ_prefix=None
|
||||
)
|
||||
ROOM_DELETED_RETENTION_DAYS = values.PositiveIntegerValue(
|
||||
None, environ_name="ROOM_DELETED_RETENTION_DAYS", environ_prefix=None
|
||||
)
|
||||
# if provided, treat as suspicious (possible privilege escalation attempt).
|
||||
PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS = values.ListValue(
|
||||
["hidden", "recorder", "agent"],
|
||||
@@ -811,14 +793,6 @@ class Base(Configuration):
|
||||
RECORDING_ENABLE = values.BooleanValue(
|
||||
False, environ_name="RECORDING_ENABLE", environ_prefix=None
|
||||
)
|
||||
# Let any authenticated participant connected to a trusted or public room
|
||||
# start/stop recordings. Disable to keep recording reserved to room
|
||||
# admins/owners.
|
||||
RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = values.BooleanValue(
|
||||
True,
|
||||
environ_name="RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED",
|
||||
environ_prefix=None,
|
||||
)
|
||||
RECORDING_OUTPUT_FOLDER = values.Value(
|
||||
"recordings", environ_name="RECORDING_OUTPUT_FOLDER", environ_prefix=None
|
||||
)
|
||||
@@ -1500,26 +1474,21 @@ class Base(Configuration):
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
for setting_name, rooms, period in (
|
||||
("ROOM_INACTIVITY_DELETION_DAYS", "Inactive rooms", "inactivity period"),
|
||||
("ROOM_DELETED_RETENTION_DAYS", "Deleted rooms", "retention period"),
|
||||
):
|
||||
purge_days = getattr(cls, setting_name)
|
||||
if not purge_days:
|
||||
continue
|
||||
if cls.ROOM_INACTIVITY_DELETION_DAYS:
|
||||
if not cls.RECORDING_EXPIRATION_DAYS:
|
||||
warnings.warn(
|
||||
f"{setting_name} is set but RECORDING_EXPIRATION_DAYS is not. "
|
||||
f"Recordings never expire, so {rooms.lower()} holding a saved "
|
||||
"recording will never be purged.",
|
||||
"ROOM_INACTIVITY_DELETION_DAYS is set but "
|
||||
"RECORDING_EXPIRATION_DAYS is not. Recordings never expire, so "
|
||||
"inactive rooms holding a saved recording will never be purged.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
elif cls.RECORDING_EXPIRATION_DAYS >= purge_days:
|
||||
elif cls.RECORDING_EXPIRATION_DAYS >= cls.ROOM_INACTIVITY_DELETION_DAYS:
|
||||
warnings.warn(
|
||||
"RECORDING_EXPIRATION_DAYS is greater than or equal to "
|
||||
f"{setting_name}. {rooms} holding a saved recording will be "
|
||||
f"kept past the {period}, until their recordings expire.",
|
||||
"ROOM_INACTIVITY_DELETION_DAYS. Inactive rooms holding a saved "
|
||||
"recording will be kept past the inactivity period, until their "
|
||||
"recordings expire.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.35.0"
|
||||
version = "1.34.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -49,7 +49,6 @@ dependencies = [
|
||||
"gunicorn==26.2.0",
|
||||
"jsonschema==4.26.0",
|
||||
"markdown==3.10.3",
|
||||
"menshen-client==0.2.0",
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"posthog==7.44.0",
|
||||
"psycopg[binary]==3.3.4",
|
||||
@@ -83,7 +82,7 @@ dev = [
|
||||
"ipython==9.16.1",
|
||||
"pyfakefs==6.2.0",
|
||||
"pylint-django==2.8.0",
|
||||
"pylint==4.1.3",
|
||||
"pylint<4.0.0",
|
||||
"pytest-cov==7.1.0",
|
||||
"pytest-django==4.14.0",
|
||||
"pytest==9.1.1",
|
||||
|
||||
Generated
+9
-23
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 5
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[[package]]
|
||||
@@ -136,11 +136,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "astroid"
|
||||
version = "4.3.4"
|
||||
version = "3.3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2d/87/5732fa68bf100a095cfcbd108f919220d995db99e1a7502b8119a869fd62/astroid-4.3.4.tar.gz", hash = "sha256:d515a105722b72098bbe82d430d65e635f742b6cbac3bdfaf8b7c188b87c5e39", size = 444627, upload-time = "2026-10-08T09:36:44.122Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/74/dfb75f9ccd592bbedb175d4a32fc643cf569d7c218508bfbd6ea7ef9c091/astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce", size = 400439, upload-time = "2025-07-13T18:04:23.177Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/16/d4/f23c0ac6e6de33ba5686cb21c672c95e0c2d3d4c9351f16d3b5fed818652/astroid-4.3.4-py3-none-any.whl", hash = "sha256:2bcd0d02648a443a4b818c952c3550091989daefac3c12d3b83b2289482e0818", size = 288310, upload-time = "2026-10-08T09:36:42.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/0f/3b8fdc946b4d9cc8cc1e8af42c4e409468c84441b933d037e101b3d72d86/astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec", size = 275612, upload-time = "2025-07-13T18:04:21.07Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1297,7 +1297,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.35.0"
|
||||
version = "1.34.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1327,7 +1327,6 @@ dependencies = [
|
||||
{ name = "jsonschema" },
|
||||
{ name = "livekit-api" },
|
||||
{ name = "markdown" },
|
||||
{ name = "menshen-client" },
|
||||
{ name = "mozilla-django-oidc" },
|
||||
{ name = "nested-multipart-parser" },
|
||||
{ name = "phonenumbers" },
|
||||
@@ -1393,7 +1392,6 @@ requires-dist = [
|
||||
{ name = "jsonschema", specifier = "==4.26.0" },
|
||||
{ name = "livekit-api", specifier = "==1.2.0" },
|
||||
{ name = "markdown", specifier = "==3.10.3" },
|
||||
{ name = "menshen-client", specifier = "==0.2.0" },
|
||||
{ name = "mozilla-django-oidc", specifier = "==5.0.2" },
|
||||
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
|
||||
{ name = "phonenumbers", specifier = "==9.0.37" },
|
||||
@@ -1418,7 +1416,7 @@ dev = [
|
||||
{ name = "ipdb", specifier = "==0.13.13" },
|
||||
{ name = "ipython", specifier = "==9.16.1" },
|
||||
{ name = "pyfakefs", specifier = "==6.2.0" },
|
||||
{ name = "pylint", specifier = "==4.1.3" },
|
||||
{ name = "pylint", specifier = "<4.0.0" },
|
||||
{ name = "pylint-django", specifier = "==2.8.0" },
|
||||
{ name = "pytest", specifier = "==9.1.1" },
|
||||
{ name = "pytest-cov", specifier = "==7.1.0" },
|
||||
@@ -1430,18 +1428,6 @@ dev = [
|
||||
{ name = "types-requests", specifier = "==2.33.0.20260712" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "menshen-client"
|
||||
version = "0.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/a2/5862b0a39d5f96301e1daff146445f591b1980412c20417162f5cfd6aa34/menshen_client-0.2.0.tar.gz", hash = "sha256:548c4224c38b2c844a76325b5a4b65da520ea9eba23aafb004da0f430a7e4fcf", size = 6326, upload-time = "2026-10-13T10:19:34.344Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/53/4d/932efd81c340d12d07b1c33633c6e7a4a2d0c9086ac0da7ffe9a1ce17068/menshen_client-0.2.0-py3-none-any.whl", hash = "sha256:415eaba30184452bc393b528c6088ed6e37cbd00ce60716aef9e2d9bd0697221", size = 7149, upload-time = "2026-10-13T10:19:33.272Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mozilla-django-oidc"
|
||||
version = "5.0.2"
|
||||
@@ -2046,7 +2032,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pylint"
|
||||
version = "4.1.3"
|
||||
version = "3.3.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "astroid" },
|
||||
@@ -2057,9 +2043,9 @@ dependencies = [
|
||||
{ name = "platformdirs" },
|
||||
{ name = "tomlkit" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/c9/0bde4152a05a7686b431ce216fe3f3698b0c0d94c20bfa256dd935b608f6/pylint-4.1.3.tar.gz", hash = "sha256:9928603068edfa0d1a3c167f174b099d4b97c3db75d32d0fcdd029770b4713a9", size = 1756180, upload-time = "2026-10-11T07:49:10.69Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/9d/81c84a312d1fa8133b0db0c76148542a98349298a01747ab122f9314b04e/pylint-3.3.9.tar.gz", hash = "sha256:d312737d7b25ccf6b01cc4ac629b5dcd14a0fcf3ec392735ac70f137a9d5f83a", size = 1525946, upload-time = "2025-10-05T18:41:43.786Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/ba/62b18e2e157d05f976bd490b349b55d8850c2a454a5c313c91af9d2e0baf/pylint-4.1.3-py3-none-any.whl", hash = "sha256:a85357cae24f33ad8d86c8f3daaa92c600ae4012b54a57299cee76000e9364cf", size = 588959, upload-time = "2026-10-11T07:49:08.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/a7/69460c4a6af7575449e615144aa2205b89408dc2969b87bc3df2f262ad0b/pylint-3.3.9-py3-none-any.whl", hash = "sha256:01f9b0462c7730f94786c283f3e52a1fbdf0494bbe0971a78d7277ef46a751e7", size = 523465, upload-time = "2025-10-05T18:41:41.766Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.35.0",
|
||||
"version": "1.34.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.35.0",
|
||||
"version": "1.34.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||
"@fontsource-variable/lexend": "5.3.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.35.0",
|
||||
"version": "1.34.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
|
||||
@@ -6,5 +6,4 @@ export const keys = {
|
||||
waitingParticipants: 'waitingParticipants',
|
||||
roomCreationCallback: 'roomCreationCallback',
|
||||
files: 'files',
|
||||
startRecording: 'startRecording',
|
||||
}
|
||||
|
||||
@@ -36,14 +36,6 @@ export interface ApiConfig {
|
||||
available_modes?: RecordingMode[]
|
||||
expiration_days?: number
|
||||
max_duration?: number
|
||||
encoding?: {
|
||||
is_custom_enabled: boolean
|
||||
available_resolutions: string[]
|
||||
available_profiles: string[]
|
||||
default_resolution?: string | null
|
||||
default_profile?: string | null
|
||||
}
|
||||
authenticated_participants_enabled?: boolean
|
||||
}
|
||||
background_image: {
|
||||
upload_is_enabled: boolean
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8.59456 12.8115C8.53273 12.5511 8.5 12.2794 8.5 12C8.5 11.7207 8.53272 11.449 8.59454 11.1886L7.60291 10.6161L8.60291 8.88402L9.59523 9.45694C9.98834 9.08508 10.4676 8.80338 11 8.64494V7.5H13V8.64494C13.5324 8.80337 14.0116 9.08506 14.4047 9.4569L15.3971 8.88393L16.3972 10.616L15.4054 11.1885C15.4673 11.449 15.5 11.7207 15.5 12C15.5 12.2793 15.4673 12.551 15.4055 12.8114L16.3972 13.3839L15.3972 15.116L14.4048 14.543C14.0117 14.9149 13.5325 15.1966 13.0001 15.355V16.5H11.0001V15.3551C10.4677 15.1967 9.98844 14.915 9.59532 14.5431L8.60297 15.1161L7.60291 13.384L8.59456 12.8115ZM12 13.5C12.8284 13.5 13.5 12.8284 13.5 12C13.5 11.1716 12.8284 10.5 12 10.5C11.1716 10.5 10.5 11.1716 10.5 12C10.5 12.8284 11.1716 13.5 12 13.5ZM15 4H5V20H19V8H15V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H16L20.9997 7L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 994 B |
@@ -177,18 +177,31 @@ export const MainNotificationToast = () => {
|
||||
changedAttributes: Record<string, string>,
|
||||
participant: Participant
|
||||
) => {
|
||||
if (!participant.isLocal || !('room_role' in changedAttributes)) return
|
||||
const newRole = changedAttributes['room_role']
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.RoleChanged,
|
||||
newRole: newRole,
|
||||
},
|
||||
{
|
||||
timeout: NotificationDuration.ROLE_CHANGED,
|
||||
}
|
||||
)
|
||||
if (!participant.isLocal) return
|
||||
if ('room_role' in changedAttributes) {
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.RoleChanged,
|
||||
newRole: changedAttributes['room_role'],
|
||||
},
|
||||
{
|
||||
timeout: NotificationDuration.ROLE_CHANGED,
|
||||
}
|
||||
)
|
||||
}
|
||||
if ('is_speaker' in changedAttributes) {
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.SpeakerChanged,
|
||||
isSpeaker: changedAttributes['is_speaker'] === 'true',
|
||||
},
|
||||
{
|
||||
timeout: NotificationDuration.ROLE_CHANGED,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
room.on(RoomEvent.ParticipantAttributesChanged, handleAttributeChanged)
|
||||
|
||||
|
||||
@@ -22,4 +22,5 @@ export enum NotificationType {
|
||||
RecordingSaving = 'recordingSaving',
|
||||
PermissionsRemoved = 'permissionsRemoved',
|
||||
RoleChanged = 'roleChanged',
|
||||
SpeakerChanged = 'speakerChanged',
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
|
||||
import { ToastRecordingRequest } from './ToastRecordingRequest'
|
||||
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
|
||||
import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged'
|
||||
import { ToastSpeakerChanged } from './ToastSpeakerChanged'
|
||||
|
||||
interface ToastRegionProps extends AriaToastRegionProps {
|
||||
state: ToastState<ToastData>
|
||||
@@ -78,6 +79,9 @@ const renderToast = (
|
||||
case NotificationType.RoleChanged:
|
||||
return <ToastRoleChanged key={toast.key} toast={toast} state={state} />
|
||||
|
||||
case NotificationType.SpeakerChanged:
|
||||
return <ToastSpeakerChanged key={toast.key} toast={toast} state={state} />
|
||||
|
||||
default:
|
||||
return <Toast key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useToast } from 'react-aria'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { type ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StyledToastContainer } from './StyledToastContainer'
|
||||
|
||||
export function ToastSpeakerChanged({ state, ...props }: Readonly<ToastProps>) {
|
||||
const { t } = useTranslation('notifications', { keyPrefix: 'speakerChanged' })
|
||||
const ref = useRef(null)
|
||||
const { toastProps, contentProps } = useToast(props, state, ref)
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<HStack
|
||||
justify="center"
|
||||
alignItems="center"
|
||||
{...contentProps}
|
||||
padding={14}
|
||||
gap={0}
|
||||
>
|
||||
{t(props.toast.content.isSpeaker ? 'granted' : 'removed')}
|
||||
</HStack>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Participant, Track } from 'livekit-client'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { useDefaultPublishSources } from '@/features/rooms/livekit/hooks/useRoomPublishSources'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const SPEAKER_ATTRIBUTE = 'is_speaker'
|
||||
|
||||
export const getParticipantIsSpeaker = (participant: Participant): boolean =>
|
||||
participant.attributes?.[SPEAKER_ATTRIBUTE] === 'true'
|
||||
|
||||
export const useParticipantSpeaker = () => {
|
||||
const data = useRoomData()
|
||||
const defaultSources = useDefaultPublishSources()
|
||||
|
||||
const updateSpeaker = async (
|
||||
participant: Participant,
|
||||
isSpeaker: boolean,
|
||||
sources: Track.Source[]
|
||||
) => {
|
||||
if (!data?.id) {
|
||||
throw new Error('Room id is not available')
|
||||
}
|
||||
|
||||
const attributes: Record<string, string> = {
|
||||
[SPEAKER_ATTRIBUTE]: isSpeaker ? 'true' : '',
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetchApi(`rooms/${data.id}/update-participant/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
participant_identity: participant.identity,
|
||||
attributes,
|
||||
permission: {
|
||||
can_subscribe: participant.permissions?.canSubscribe,
|
||||
can_publish_data: participant.permissions?.canPublishData,
|
||||
can_update_metadata: participant.permissions?.canUpdateMetadata,
|
||||
can_subscribe_metrics: participant.permissions?.canSubscribeMetrics,
|
||||
can_publish: sources.length > 0,
|
||||
can_publish_sources: sources,
|
||||
},
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
reportError('permissions_api_failure', error, {
|
||||
context: `Failed to update speaker status of ${participant.identity}`,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const makeSpeaker = (participant: Participant) =>
|
||||
updateSpeaker(participant, true, defaultSources)
|
||||
|
||||
const removeSpeaker = (
|
||||
participant: Participant,
|
||||
roomSources: Track.Source[]
|
||||
) => updateSpeaker(participant, false, roomSources)
|
||||
|
||||
return { makeSpeaker, removeSpeaker }
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { type LocalParticipant, type Participant, Track } from 'livekit-client'
|
||||
import { isLocal } from '@/utils/livekit'
|
||||
import {
|
||||
useIsSpeaking,
|
||||
useParticipantAttribute,
|
||||
useTrackMutedIndicator,
|
||||
} from '@livekit/components-react'
|
||||
import Source = Track.Source
|
||||
@@ -86,6 +87,16 @@ type ParticipantListItemProps = {
|
||||
export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const name = participant.name || participant.identity
|
||||
const isSpeaker =
|
||||
useParticipantAttribute('is_speaker', { participant }) === 'true'
|
||||
|
||||
const roleLabels = [
|
||||
getParticipantIsRoomOwner(participant) && t('participants.host'),
|
||||
getParticipantIsRoomAdmin(participant) && t('participants.cohost'),
|
||||
getParticipantIsRoomMember(participant) && t('participants.member'),
|
||||
isSpeaker && t('participants.speaker'),
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<HStack
|
||||
role="listitem"
|
||||
@@ -107,12 +118,7 @@ export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
|
||||
displayedName={name}
|
||||
isLocal={isLocal(participant)}
|
||||
/>
|
||||
<Text variant="xsNote">
|
||||
{getParticipantIsRoomOwner(participant) && t('participants.host')}
|
||||
{getParticipantIsRoomAdmin(participant) && t('participants.cohost')}
|
||||
{getParticipantIsRoomMember(participant) &&
|
||||
t('participants.member')}
|
||||
</Text>
|
||||
<Text variant="xsNote">{roleLabels.join(' · ')}</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<HStack flexShrink={0}>
|
||||
|
||||
@@ -3,8 +3,10 @@ import type { Participant } from 'livekit-client'
|
||||
import { PinMenuItem } from './items/PinMenuItem'
|
||||
import { RemoveMenuItem } from './items/RemoveMenuItem'
|
||||
import { PromoteMenuItem } from './items/PromoteMenuItem'
|
||||
import { SpeakerMenuItem } from './items/SpeakerMenuItem'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { useParticipantAttributes } from '@livekit/components-react'
|
||||
import { useHasLockedSource } from '@/features/rooms/livekit/hooks/useRoomPublishSources'
|
||||
|
||||
export const ParticipantMenu = ({
|
||||
participant,
|
||||
@@ -18,8 +20,13 @@ export const ParticipantMenu = ({
|
||||
const isAdmin = attributes?.room_role === 'administrator'
|
||||
const isOwner = attributes?.room_role === 'owner'
|
||||
const isAuthenticated = attributes?.is_authenticated == 'true'
|
||||
const isSpeaker = attributes?.is_speaker === 'true'
|
||||
|
||||
const hasLockedSource = useHasLockedSource()
|
||||
|
||||
const canManage = !participant.isLocal && isLocalUserAdminOrOwner && !isOwner
|
||||
const canToggleSpeaker =
|
||||
canManage && !isAdmin && (isSpeaker || hasLockedSource)
|
||||
|
||||
return (
|
||||
<RACMenu
|
||||
@@ -41,6 +48,9 @@ export const ParticipantMenu = ({
|
||||
displayedName={participant.name}
|
||||
/>
|
||||
)}
|
||||
{canToggleSpeaker && (
|
||||
<SpeakerMenuItem participant={participant} isSpeaker={isSpeaker} />
|
||||
)}
|
||||
</RACMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { RiMicOffLine, RiUserVoiceLine } from '@remixicon/react'
|
||||
import { useParticipantSpeaker } from '@/features/participants/api/updateParticipantSpeaker'
|
||||
import { useRoomPublishSources } from '@/features/rooms/livekit/hooks/useRoomPublishSources'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
|
||||
type SpeakerMenuItemProps = {
|
||||
participant: Participant
|
||||
isSpeaker: boolean
|
||||
}
|
||||
|
||||
export const SpeakerMenuItem = React.memo(
|
||||
({ participant, isSpeaker }: SpeakerMenuItemProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
|
||||
const { makeSpeaker, removeSpeaker } = useParticipantSpeaker()
|
||||
const roomSources = useRoomPublishSources()
|
||||
|
||||
const label = isSpeaker ? 'removeSpeaker' : 'makeSpeaker'
|
||||
const Icon = isSpeaker ? RiMicOffLine : RiUserVoiceLine
|
||||
|
||||
const toggleSpeaker = useCallback(
|
||||
() =>
|
||||
isSpeaker
|
||||
? removeSpeaker(participant, roomSources)
|
||||
: makeSpeaker(participant),
|
||||
[isSpeaker, participant, roomSources, makeSpeaker, removeSpeaker]
|
||||
)
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
aria-label={t(`${label}.ariaLabel`, {
|
||||
name: participant.name || participant.identity,
|
||||
})}
|
||||
className={menuRecipe({ icon: true }).item}
|
||||
onAction={toggleSpeaker}
|
||||
>
|
||||
<HStack gap={0.25} minWidth={280}>
|
||||
<Icon size={20} aria-hidden />
|
||||
{t(`${label}.label`)}
|
||||
</HStack>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
SpeakerMenuItem.displayName = 'SpeakerMenuItem'
|
||||
@@ -2,22 +2,12 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import type { ApiError } from '@/api/ApiError'
|
||||
import type { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { RecordingMode } from '../types'
|
||||
import type { RecordingEncoding } from '../hooks/useRecordingEncoding'
|
||||
|
||||
export interface StartRecordingOptions {
|
||||
language?: string
|
||||
transcribe?: boolean
|
||||
collect_metadata?: boolean
|
||||
original_mode?: RecordingMode
|
||||
encoding?: RecordingEncoding
|
||||
}
|
||||
|
||||
export interface StartRecordingParams {
|
||||
id: string
|
||||
mode?: RecordingMode
|
||||
options?: StartRecordingOptions
|
||||
options?: Record<string, string | boolean>
|
||||
}
|
||||
|
||||
const startRecording = ({
|
||||
@@ -38,7 +28,6 @@ export function useStartRecording(
|
||||
options?: UseMutationOptions<ApiRoom, ApiError, StartRecordingParams>
|
||||
) {
|
||||
return useMutation<ApiRoom, ApiError, StartRecordingParams>({
|
||||
mutationKey: [keys.startRecording],
|
||||
mutationFn: startRecording,
|
||||
onSuccess: options?.onSuccess,
|
||||
onError: options?.onError,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Div, H, Text } from '@/primitives'
|
||||
import { Div, H, Text } from '@/primitives'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
|
||||
@@ -22,19 +22,10 @@ import { ControlsButton } from './ControlsButton'
|
||||
import { RowWrapper } from './RowWrapper'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
import { Checkbox } from '@/primitives/Checkbox'
|
||||
import {
|
||||
SettingsDialogExtendedKey,
|
||||
useTranscriptionLanguage,
|
||||
} from '@/features/settings'
|
||||
import { useTranscriptionLanguage } from '@/features/settings'
|
||||
import { useMutateRecording } from '../hooks/useMutateRecording'
|
||||
import { useRecordingEncoding } from '../hooks/useRecordingEncoding'
|
||||
import { openSettingsDialog } from '@/stores/settings'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import {
|
||||
useCanRecord,
|
||||
useIsRecordingOpenToParticipants,
|
||||
} from '../hooks/useCanRecord'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import { LimitDescription } from './LimitDescription'
|
||||
import { captureEvent, reportError } from '@/features/analytics/telemetry'
|
||||
@@ -48,8 +39,6 @@ export const ScreenRecordingSidePanel = () => {
|
||||
const [includeTranscript, setIncludeTranscript] = useState(false)
|
||||
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
const canRecord = useCanRecord()
|
||||
const isRecordingOpenToParticipants = useIsRecordingOpenToParticipants()
|
||||
|
||||
const hasScreenRecordingAccess = useHasRecordingAccess(
|
||||
RecordingMode.ScreenRecording,
|
||||
@@ -64,8 +53,6 @@ export const ScreenRecordingSidePanel = () => {
|
||||
const { selectedLanguageKey, isLanguageSetToAuto } =
|
||||
useTranscriptionLanguage()
|
||||
|
||||
const { encoding, isCustomEncodingEnabled } = useRecordingEncoding()
|
||||
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { startRecording, isPendingToStart, stopRecording, isPendingToStop } =
|
||||
@@ -106,7 +93,6 @@ export const ScreenRecordingSidePanel = () => {
|
||||
language: selectedLanguageKey,
|
||||
}),
|
||||
...(includeTranscript && hasTranscriptAccess && { transcribe: true }),
|
||||
...(encoding && { encoding }),
|
||||
}
|
||||
|
||||
await startRecording({
|
||||
@@ -121,8 +107,6 @@ export const ScreenRecordingSidePanel = () => {
|
||||
captureEvent('screen-recording-started', {
|
||||
includeTranscript: includeTranscript,
|
||||
language: selectedLanguageKey,
|
||||
resolution: encoding?.resolution,
|
||||
profile: encoding?.profile,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -132,13 +116,11 @@ export const ScreenRecordingSidePanel = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!canRecord) {
|
||||
if (!isAdminOrOwner) {
|
||||
return (
|
||||
<NoAccessView
|
||||
i18nKeyPrefix={keyPrefix}
|
||||
i18nKey={
|
||||
isRecordingOpenToParticipants ? 'notLoggedIn' : 'notAdminOrOwner'
|
||||
}
|
||||
i18nKey="notAdminOrOwner"
|
||||
helpArticle={data?.support?.help_article_recording}
|
||||
imagePath="/assets/intro-slider/4.png"
|
||||
handleRequest={handleRequestScreenRecording}
|
||||
@@ -201,41 +183,9 @@ export const ScreenRecordingSidePanel = () => {
|
||||
<RowWrapper iconName="cloud_download" position="first">
|
||||
<Text variant="sm">{t('details.destination')}</Text>
|
||||
</RowWrapper>
|
||||
<RowWrapper
|
||||
iconName="mail"
|
||||
position={isCustomEncodingEnabled ? 'middle' : 'last'}
|
||||
>
|
||||
<RowWrapper iconName="mail" position="last">
|
||||
<Text variant="sm">{t('details.receiver')}</Text>
|
||||
</RowWrapper>
|
||||
{isCustomEncodingEnabled && (
|
||||
<RowWrapper iconName="file_settings_line" position="last">
|
||||
<Text
|
||||
variant="sm"
|
||||
className={css({
|
||||
minWidth: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
})}
|
||||
>
|
||||
{t('details.quality')}
|
||||
</Text>
|
||||
<Text
|
||||
variant="sm"
|
||||
className={css({ flexShrink: 0, whiteSpace: 'nowrap' })}
|
||||
>
|
||||
<Button
|
||||
variant="text"
|
||||
size="xs"
|
||||
onPress={() =>
|
||||
openSettingsDialog(SettingsDialogExtendedKey.RECORDING)
|
||||
}
|
||||
>
|
||||
{t('details.qualityEdit')}
|
||||
</Button>
|
||||
</Text>
|
||||
</RowWrapper>
|
||||
)}
|
||||
{hasTranscriptAccess && (
|
||||
<>
|
||||
<div className={css({ height: '15px' })} />
|
||||
|
||||
@@ -6,8 +6,7 @@ import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
RecordingMode,
|
||||
useHasRecordingAccess,
|
||||
useHasFeatureWithoutRecordingRights,
|
||||
useIsRecordingOpenToParticipants,
|
||||
useHasFeatureWithoutAdminRights,
|
||||
useRecordingStatuses,
|
||||
} from '../index'
|
||||
import { useState } from 'react'
|
||||
@@ -30,7 +29,6 @@ import { NoAccessView } from './NoAccessView'
|
||||
import { ControlsButton } from './ControlsButton'
|
||||
import { RowWrapper } from './RowWrapper'
|
||||
import { useMutateRecording } from '../hooks/useMutateRecording'
|
||||
import { useRecordingEncoding } from '../hooks/useRecordingEncoding'
|
||||
import { useIsMetadataCollectorEnabled } from '../hooks/useMetadataCollectorEnabled'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
@@ -59,18 +57,15 @@ export const TranscriptSidePanel = () => {
|
||||
RecordingMode.ScreenRecording,
|
||||
FeatureFlags.ScreenRecording
|
||||
)
|
||||
const hasFeatureWithoutRecordingRights = useHasFeatureWithoutRecordingRights(
|
||||
const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights(
|
||||
RecordingMode.Transcript,
|
||||
FeatureFlags.Transcript
|
||||
)
|
||||
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
const isRecordingOpenToParticipants = useIsRecordingOpenToParticipants()
|
||||
|
||||
const isMetadataCollectorEnabled = useIsMetadataCollectorEnabled()
|
||||
|
||||
const { encoding } = useRecordingEncoding()
|
||||
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { startRecording, isPendingToStart, stopRecording, isPendingToStop } =
|
||||
@@ -119,7 +114,6 @@ export const TranscriptSidePanel = () => {
|
||||
...(withScreenRecording && {
|
||||
transcribe: true,
|
||||
original_mode: RecordingMode.Transcript,
|
||||
...(encoding && { encoding }),
|
||||
}),
|
||||
collect_metadata: isMetadataCollectorEnabled,
|
||||
}
|
||||
@@ -145,13 +139,11 @@ export const TranscriptSidePanel = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (hasFeatureWithoutRecordingRights) {
|
||||
if (hasFeatureWithoutAdminRights) {
|
||||
return (
|
||||
<NoAccessView
|
||||
i18nKeyPrefix={keyPrefix}
|
||||
i18nKey={
|
||||
isRecordingOpenToParticipants ? 'notLoggedIn' : 'notAdminOrOwner'
|
||||
}
|
||||
i18nKey="notAdminOrOwner"
|
||||
helpArticle={data?.support?.help_article_transcript}
|
||||
imagePath="/assets/intro-slider/3.png"
|
||||
handleRequest={handleRequestTranscription}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
|
||||
// Room access levels on which authenticated participants can record.
|
||||
const OPEN_ACCESS_LEVELS = [ApiAccessLevel.TRUSTED, ApiAccessLevel.PUBLIC]
|
||||
|
||||
export const useIsRecordingOpenToParticipants = () => {
|
||||
const roomData = useRoomData()
|
||||
const { data: config } = useConfig()
|
||||
|
||||
return (
|
||||
config?.recording?.authenticated_participants_enabled === true &&
|
||||
!!roomData?.id &&
|
||||
!!roomData?.access_level &&
|
||||
OPEN_ACCESS_LEVELS.includes(roomData.access_level)
|
||||
)
|
||||
}
|
||||
|
||||
export const useCanRecord = () => {
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
const { isLoggedIn } = useUser()
|
||||
const isRecordingOpenToParticipants = useIsRecordingOpenToParticipants()
|
||||
|
||||
return (
|
||||
isAdminOrOwner || (isLoggedIn === true && isRecordingOpenToParticipants)
|
||||
)
|
||||
}
|
||||
+4
-4
@@ -2,21 +2,21 @@ import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import type { RecordingMode } from '../types'
|
||||
import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled'
|
||||
import { useCanRecord } from './useCanRecord'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import type { FeatureFlags } from '@/features/analytics/enums'
|
||||
|
||||
export const useHasFeatureWithoutRecordingRights = (
|
||||
export const useHasFeatureWithoutAdminRights = (
|
||||
mode: RecordingMode,
|
||||
featureFlag: FeatureFlags
|
||||
) => {
|
||||
const featureEnabled = useFeatureFlagEnabled(featureFlag)
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const isRecordingModeEnabled = useIsRecordingModeEnabled(mode)
|
||||
const canRecord = useCanRecord()
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
return (
|
||||
(featureEnabled || !isAnalyticsEnabled) &&
|
||||
isRecordingModeEnabled &&
|
||||
!canRecord
|
||||
!isAdminOrOwner
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import type { RecordingMode } from '../types'
|
||||
import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled'
|
||||
import { useCanRecord } from './useCanRecord'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import type { FeatureFlags } from '@/features/analytics/enums'
|
||||
|
||||
export const useHasRecordingAccess = (
|
||||
@@ -12,11 +12,11 @@ export const useHasRecordingAccess = (
|
||||
const featureEnabled = useFeatureFlagEnabled(featureFlag)
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const isRecordingModeEnabled = useIsRecordingModeEnabled(mode)
|
||||
const canRecord = useCanRecord()
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
return (
|
||||
(featureEnabled || !isAnalyticsEnabled) &&
|
||||
canRecord &&
|
||||
isAdminOrOwner &&
|
||||
isRecordingModeEnabled
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useIsMutating } from '@tanstack/react-query'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { RecordingMode } from '../types'
|
||||
import { useRecordingStatuses } from './useRecordingStatuses'
|
||||
|
||||
/**
|
||||
* True while a recording is being requested, starting, started or saving.
|
||||
* Recording settings must not change during that window, as they were (or
|
||||
* are about to be) sent to the backend for the current recording.
|
||||
*/
|
||||
export const useIsRecordingLocked = () => {
|
||||
const screenRecording = useRecordingStatuses(RecordingMode.ScreenRecording)
|
||||
const transcript = useRecordingStatuses(RecordingMode.Transcript)
|
||||
const pendingStarts = useIsMutating({ mutationKey: [keys.startRecording] })
|
||||
|
||||
return pendingStarts > 0 || screenRecording.isActive || transcript.isActive
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import {
|
||||
recordingEncodingStore,
|
||||
setRecordingEncodingPreference,
|
||||
} from '@/stores/recordingEncoding'
|
||||
|
||||
export type RecordingEncoding = {
|
||||
resolution?: string
|
||||
profile?: string
|
||||
}
|
||||
|
||||
export const DEFAULT_ENCODING_OPTION = '__default__'
|
||||
|
||||
/**
|
||||
* Pick the stored value if the backend still offers it, otherwise the
|
||||
* backend default, otherwise the "Default" option.
|
||||
*/
|
||||
const resolveValue = (
|
||||
available: string[],
|
||||
stored?: string,
|
||||
fallback?: string | null
|
||||
): string => {
|
||||
if (stored && available.includes(stored)) return stored
|
||||
if (fallback && available.includes(fallback)) return fallback
|
||||
return DEFAULT_ENCODING_OPTION
|
||||
}
|
||||
|
||||
/**
|
||||
* Recording encoding preferences (resolution + profile).
|
||||
*
|
||||
* Values are persisted in localStorage, per browser, and always
|
||||
* reconciled against what the backend currently exposes, so a stale value
|
||||
* (e.g. a profile removed by the operator) never reaches the API.
|
||||
*/
|
||||
export const useRecordingEncoding = () => {
|
||||
const { data } = useConfig()
|
||||
const snap = useSnapshot(recordingEncodingStore)
|
||||
|
||||
const encodingConfig = data?.recording?.encoding
|
||||
const availableResolutions = useMemo(
|
||||
() => encodingConfig?.available_resolutions ?? [],
|
||||
[encodingConfig]
|
||||
)
|
||||
const availableProfiles = useMemo(
|
||||
() => encodingConfig?.available_profiles ?? [],
|
||||
[encodingConfig]
|
||||
)
|
||||
const hasDefaultResolution = availableResolutions.includes(
|
||||
encodingConfig?.default_resolution ?? ''
|
||||
)
|
||||
const hasDefaultProfile = availableProfiles.includes(
|
||||
encodingConfig?.default_profile ?? ''
|
||||
)
|
||||
|
||||
const isCustomEncodingEnabled = !!encodingConfig?.is_custom_enabled
|
||||
|
||||
const resolution = resolveValue(
|
||||
availableResolutions,
|
||||
snap.resolution,
|
||||
encodingConfig?.default_resolution
|
||||
)
|
||||
const profile = resolveValue(
|
||||
availableProfiles,
|
||||
snap.profile,
|
||||
encodingConfig?.default_profile
|
||||
)
|
||||
|
||||
const setResolution = useCallback(
|
||||
(value: string) => {
|
||||
if (
|
||||
value !== DEFAULT_ENCODING_OPTION &&
|
||||
!availableResolutions.includes(value)
|
||||
)
|
||||
return
|
||||
setRecordingEncodingPreference({ resolution: value })
|
||||
},
|
||||
[availableResolutions]
|
||||
)
|
||||
|
||||
const setProfile = useCallback(
|
||||
(value: string) => {
|
||||
if (
|
||||
value !== DEFAULT_ENCODING_OPTION &&
|
||||
!availableProfiles.includes(value)
|
||||
)
|
||||
return
|
||||
setRecordingEncodingPreference({ profile: value })
|
||||
},
|
||||
[availableProfiles]
|
||||
)
|
||||
|
||||
// Payload for the start-recording API. Undefined when per-recording
|
||||
// encoding is disabled, as the backend would reject it.
|
||||
const encoding: RecordingEncoding | undefined = useMemo(() => {
|
||||
// The API requires a resolution whenever the encoding is modified: a
|
||||
// profile cannot be sent alone. So if the resolution is "Default", send
|
||||
// no payload at all and let the backend apply its default encoding.
|
||||
if (!isCustomEncodingEnabled || resolution === DEFAULT_ENCODING_OPTION)
|
||||
return undefined
|
||||
return {
|
||||
resolution,
|
||||
// Omitted when "Default": the backend applies its default profile.
|
||||
...(profile !== DEFAULT_ENCODING_OPTION && { profile }),
|
||||
}
|
||||
}, [isCustomEncodingEnabled, resolution, profile])
|
||||
|
||||
return {
|
||||
isCustomEncodingEnabled,
|
||||
availableResolutions,
|
||||
availableProfiles,
|
||||
hasDefaultResolution,
|
||||
hasDefaultProfile,
|
||||
resolution,
|
||||
profile,
|
||||
setResolution,
|
||||
setProfile,
|
||||
encoding,
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,8 @@
|
||||
// hooks
|
||||
export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled'
|
||||
export { useHasRecordingAccess } from './hooks/useHasRecordingAccess'
|
||||
export { useHasFeatureWithoutRecordingRights } from './hooks/useHasFeatureWithoutRecordingRights'
|
||||
export {
|
||||
useCanRecord,
|
||||
useIsRecordingOpenToParticipants,
|
||||
} from './hooks/useCanRecord'
|
||||
export { useHasFeatureWithoutAdminRights } from './hooks/useHasFeatureWithoutAdminRights'
|
||||
export { useRecordingStatuses } from './hooks/useRecordingStatuses'
|
||||
export {
|
||||
useRecordingEncoding,
|
||||
DEFAULT_ENCODING_OPTION,
|
||||
} from './hooks/useRecordingEncoding'
|
||||
export type { RecordingEncoding } from './hooks/useRecordingEncoding'
|
||||
export { useIsRecordingLocked } from './hooks/useIsRecordingLocked'
|
||||
|
||||
// api
|
||||
export { useStartRecording } from './api/startRecording'
|
||||
|
||||
@@ -13,8 +13,6 @@ export enum ApiLobbyStatus {
|
||||
DENIED = 'denied',
|
||||
TIMEOUT = 'timeout',
|
||||
ACCEPTED = 'accepted',
|
||||
// Client-side only: the room was deleted while waiting
|
||||
DELETED = 'deleted',
|
||||
}
|
||||
|
||||
export interface ApiRequestEntry {
|
||||
|
||||
@@ -45,7 +45,6 @@ import { userStore } from '@/stores/user'
|
||||
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
|
||||
import { MeetDevtools } from '@/features/devtools'
|
||||
import { VOICE_AUDIO_CONSTRAINTS } from '@/features/rooms/livekit/utils/constants'
|
||||
import { resolveAudioOutputDeviceId } from '@/features/rooms/utils/resolveAudioOutputDeviceId'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
@@ -86,7 +85,6 @@ export const Conference = ({
|
||||
const {
|
||||
status: fetchStatus,
|
||||
isError: isFetchError,
|
||||
error: fetchError,
|
||||
data,
|
||||
} = useQuery({
|
||||
queryKey: fetchKey,
|
||||
@@ -100,8 +98,6 @@ export const Conference = ({
|
||||
if (error.statusCode == '404') {
|
||||
createRoom({ slug: roomId, username })
|
||||
}
|
||||
// A deleted room can't be recreated, surface the error instead
|
||||
if (error.statusCode == '410') throw error
|
||||
}),
|
||||
retry: false,
|
||||
})
|
||||
@@ -124,7 +120,7 @@ export const Conference = ({
|
||||
...VOICE_AUDIO_CONSTRAINTS,
|
||||
},
|
||||
audioOutput: {
|
||||
deviceId: resolveAudioOutputDeviceId(userConfig.audioOutputDeviceId),
|
||||
deviceId: userConfig.audioOutputDeviceId ?? undefined,
|
||||
},
|
||||
}
|
||||
// do not rely on the userConfig object directly as its reference may change on every render
|
||||
@@ -202,15 +198,6 @@ export const Conference = ({
|
||||
}, [apiConfig?.livekit])
|
||||
|
||||
const { t } = useTranslation('rooms')
|
||||
if (fetchError?.statusCode == 410) {
|
||||
return (
|
||||
<ErrorScreen
|
||||
title={t('error.deletedRoom.heading')}
|
||||
body={t('error.deletedRoom.body')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isCreateError) {
|
||||
// this error screen should be replaced by a proper waiting room for anonymous user.
|
||||
return (
|
||||
@@ -295,7 +282,6 @@ export const Conference = ({
|
||||
return
|
||||
case DisconnectReason.DUPLICATE_IDENTITY:
|
||||
case DisconnectReason.PARTICIPANT_REMOVED:
|
||||
case DisconnectReason.ROOM_DELETED:
|
||||
navigateTo(
|
||||
'feedback',
|
||||
{},
|
||||
|
||||
@@ -74,9 +74,7 @@ export const Lobby = ({
|
||||
const { openLoginHint } = useLoginHint()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const { data, error } = await refetchRoom()
|
||||
|
||||
if (error?.statusCode == 410) return
|
||||
const { data } = await refetchRoom()
|
||||
|
||||
if (!data?.livekit) {
|
||||
// Display a message to inform the user that by logging in, they won't have to wait for room entry approval.
|
||||
@@ -90,22 +88,7 @@ export const Lobby = ({
|
||||
enterRoom()
|
||||
}
|
||||
|
||||
const isRoomDeleted = isError && error?.statusCode == 410
|
||||
const lobbyStatus = isRoomDeleted ? ApiLobbyStatus.DELETED : status
|
||||
|
||||
switch (lobbyStatus) {
|
||||
case ApiLobbyStatus.DELETED:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false} centered>
|
||||
{t('deleted.title')}
|
||||
</H>
|
||||
<Text as="p" variant="note">
|
||||
{t('deleted.body')}
|
||||
</Text>
|
||||
</VStack>
|
||||
)
|
||||
|
||||
switch (status) {
|
||||
case ApiLobbyStatus.TIMEOUT:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
import {
|
||||
requestEntry,
|
||||
ApiLobbyStatus,
|
||||
@@ -41,33 +40,24 @@ export const useLobby = ({
|
||||
const { data: waitingData } = useQuery({
|
||||
queryKey: [keys.requestEntry, roomId],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await requestEntry({
|
||||
roomId,
|
||||
username,
|
||||
})
|
||||
const response = await requestEntry({
|
||||
roomId,
|
||||
username,
|
||||
})
|
||||
|
||||
if (response.id) {
|
||||
setLobbyParticipantId(roomId, response.id)
|
||||
}
|
||||
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
||||
clearWaitingTimeout()
|
||||
setStatus(ApiLobbyStatus.ACCEPTED)
|
||||
onAccepted(response)
|
||||
} else if (response.status === ApiLobbyStatus.DENIED) {
|
||||
clearWaitingTimeout()
|
||||
setStatus(ApiLobbyStatus.DENIED)
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
// The room was deleted while waiting, stop polling
|
||||
if (error instanceof ApiError && error.statusCode === 410) {
|
||||
clearWaitingTimeout()
|
||||
setStatus(ApiLobbyStatus.DELETED)
|
||||
return { status: ApiLobbyStatus.DELETED }
|
||||
}
|
||||
throw error
|
||||
if (response.id) {
|
||||
setLobbyParticipantId(roomId, response.id)
|
||||
}
|
||||
|
||||
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
||||
clearWaitingTimeout()
|
||||
setStatus(ApiLobbyStatus.ACCEPTED)
|
||||
onAccepted(response)
|
||||
} else if (response.status === ApiLobbyStatus.DENIED) {
|
||||
clearWaitingTimeout()
|
||||
setStatus(ApiLobbyStatus.DENIED)
|
||||
}
|
||||
return response
|
||||
},
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
refetchOnWindowFocus: false,
|
||||
|
||||
+5
-8
@@ -4,7 +4,6 @@ import { RiVolumeUpLine } from '@remixicon/react'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { Button } from '@/primitives'
|
||||
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
|
||||
import { resolveAudioOutputDeviceId } from '@/features/rooms/utils/resolveAudioOutputDeviceId'
|
||||
|
||||
// Speaker test in the audiooutput menu footer (Meet-style UX). Outputs have
|
||||
// no track: the test plays a bundled file through the selected sink, and
|
||||
@@ -67,8 +66,6 @@ export const OutputSoundTester = ({
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
|
||||
const outputId = resolveAudioOutputDeviceId(sinkId)
|
||||
|
||||
const latestSinkIdRef = useRef(sinkId)
|
||||
latestSinkIdRef.current = sinkId
|
||||
|
||||
@@ -82,15 +79,15 @@ export const OutputSoundTester = ({
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!outputId || !canTestAudioOutput()) return
|
||||
audioRef.current?.setSinkId(outputId).catch(() => {
|
||||
if (!sinkId || !canTestAudioOutput()) return
|
||||
audioRef.current?.setSinkId(sinkId).catch(() => {
|
||||
// Re-routing failed (stale or unplugged device): stop the test rather
|
||||
// than keep playing through the previous sink.
|
||||
if (latestSinkIdRef.current === sinkId) {
|
||||
stopPlayback()
|
||||
}
|
||||
})
|
||||
}, [outputId, sinkId, stopPlayback])
|
||||
}, [sinkId, stopPlayback])
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
@@ -111,8 +108,8 @@ export const OutputSoundTester = ({
|
||||
// Confirm routing before starting: a no-op when already routed,
|
||||
// but rejects on a stale device id, so the test never plays
|
||||
// through the wrong sink.
|
||||
if (outputId && canTestAudioOutput()) {
|
||||
await audio.setSinkId(outputId)
|
||||
if (sinkId && canTestAudioOutput()) {
|
||||
await audio.setSinkId(sinkId)
|
||||
}
|
||||
await audio.play()
|
||||
setIsPlaying(true)
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { RoomEvent, Track } from 'livekit-client'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useCallback } from 'react'
|
||||
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
||||
import { useRemoteParticipants } from '@livekit/components-react'
|
||||
import { useUpdateParticipantsPermissions } from '@/features/rooms/api/updateParticipantsPermissions'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
|
||||
import { getParticipantIsRoomAdminOrOwner } from '@/features/rooms/utils/getParticipantIsRoomAdminOrOwner'
|
||||
import {
|
||||
getParticipantIsSpeaker,
|
||||
useParticipantSpeaker,
|
||||
} from '@/features/participants/api/updateParticipantSpeaker'
|
||||
import {
|
||||
useDefaultPublishSources,
|
||||
useRoomPublishSources,
|
||||
} from '@/features/rooms/livekit/hooks/useRoomPublishSources'
|
||||
import Source = Track.Source
|
||||
import {
|
||||
NotificationType,
|
||||
@@ -33,8 +40,9 @@ export const usePublishSourcesManager = () => {
|
||||
const { mutateAsync: patchRoom } = usePatchRoom()
|
||||
|
||||
const data = useRoomData()
|
||||
const { data: configData } = useConfig()
|
||||
const configuration = data?.configuration
|
||||
const currentSources = useRoomPublishSources()
|
||||
const defaultSources = useDefaultPublishSources()
|
||||
|
||||
const { notifyParticipants } = useNotifyParticipants()
|
||||
|
||||
@@ -42,25 +50,18 @@ export const usePublishSourcesManager = () => {
|
||||
const roomId = data?.slug
|
||||
|
||||
const { updateParticipantsPermissions } = useUpdateParticipantsPermissions()
|
||||
const { removeSpeaker } = useParticipantSpeaker()
|
||||
const remoteParticipants = useRemoteParticipants({
|
||||
updateOnlyOn: [RoomEvent.ParticipantAttributesChanged],
|
||||
})
|
||||
|
||||
const unprivilegedRemoteParticipants = remoteParticipants.filter(
|
||||
(participant) => !getParticipantIsRoomAdminOrOwner(participant)
|
||||
const restrictedRemoteParticipants = remoteParticipants.filter(
|
||||
(participant) =>
|
||||
!getParticipantIsRoomAdminOrOwner(participant) &&
|
||||
!getParticipantIsSpeaker(participant)
|
||||
)
|
||||
|
||||
const currentSources = useMemo(() => {
|
||||
const defaultSources = configData?.livekit?.default_sources ?? []
|
||||
|
||||
if (
|
||||
configuration?.can_publish_sources == undefined ||
|
||||
!Array.isArray(configuration?.can_publish_sources)
|
||||
) {
|
||||
return defaultSources
|
||||
}
|
||||
return configuration.can_publish_sources
|
||||
}, [configData, configuration?.can_publish_sources])
|
||||
const speakers = remoteParticipants.filter(getParticipantIsSpeaker)
|
||||
|
||||
const updateSource = useCallback(
|
||||
async (sources: Source[], enabled: boolean) => {
|
||||
@@ -84,10 +85,16 @@ export const usePublishSourcesManager = () => {
|
||||
})
|
||||
|
||||
await updateParticipantsPermissions(
|
||||
unprivilegedRemoteParticipants,
|
||||
restrictedRemoteParticipants,
|
||||
newSources
|
||||
)
|
||||
|
||||
if (isSubsetOf(defaultSources, newSources)) {
|
||||
await Promise.all(
|
||||
speakers.map((speaker) => removeSpeaker(speaker, newSources))
|
||||
)
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
/*
|
||||
* We can't rely solely on the ParticipantPermissionsChanged event here,
|
||||
@@ -96,7 +103,7 @@ export const usePublishSourcesManager = () => {
|
||||
* */
|
||||
await notifyParticipants({
|
||||
type: NotificationType.PermissionsRemoved,
|
||||
destinationIdentities: unprivilegedRemoteParticipants.map(
|
||||
destinationIdentities: restrictedRemoteParticipants.map(
|
||||
(p) => p.identity
|
||||
),
|
||||
additionalData: {
|
||||
@@ -119,9 +126,12 @@ export const usePublishSourcesManager = () => {
|
||||
notifyParticipants,
|
||||
configuration,
|
||||
currentSources,
|
||||
defaultSources,
|
||||
roomId,
|
||||
patchRoom,
|
||||
unprivilegedRemoteParticipants,
|
||||
restrictedRemoteParticipants,
|
||||
speakers,
|
||||
removeSpeaker,
|
||||
updateParticipantsPermissions,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Track } from 'livekit-client'
|
||||
import { useMemo } from 'react'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
|
||||
import Source = Track.Source
|
||||
|
||||
const EMPTY_SOURCES: Source[] = []
|
||||
|
||||
export const useDefaultPublishSources = (): Source[] => {
|
||||
const { data: configData } = useConfig()
|
||||
return configData?.livekit?.default_sources ?? EMPTY_SOURCES
|
||||
}
|
||||
|
||||
export const useRoomPublishSources = (): Source[] => {
|
||||
const data = useRoomData()
|
||||
const defaultSources = useDefaultPublishSources()
|
||||
const roomSources = data?.configuration?.can_publish_sources
|
||||
|
||||
return useMemo(
|
||||
() => (Array.isArray(roomSources) ? roomSources : defaultSources),
|
||||
[defaultSources, roomSources]
|
||||
)
|
||||
}
|
||||
|
||||
export const useHasLockedSource = (): boolean => {
|
||||
const defaultSources = useDefaultPublishSources()
|
||||
const roomSources = useRoomPublishSources()
|
||||
return !isSubsetOf(defaultSources, roomSources)
|
||||
}
|
||||
@@ -31,7 +31,6 @@ const buttonClass = css({
|
||||
enum DisconnectReasonKey {
|
||||
DuplicateIdentity = 'duplicateIdentity',
|
||||
ParticipantRemoved = 'participantRemoved',
|
||||
RoomDeleted = 'roomDeleted',
|
||||
}
|
||||
|
||||
const FeedbackRoute = () => {
|
||||
@@ -47,8 +46,6 @@ const FeedbackRoute = () => {
|
||||
return DisconnectReasonKey.DuplicateIdentity
|
||||
case DisconnectReason.PARTICIPANT_REMOVED:
|
||||
return DisconnectReasonKey.ParticipantRemoved
|
||||
case DisconnectReason.ROOM_DELETED:
|
||||
return DisconnectReasonKey.RoomDeleted
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -59,10 +56,7 @@ const FeedbackRoute = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Rejoining is not possible once removed or once the room is deleted
|
||||
const showBackButton =
|
||||
reasonKey !== DisconnectReasonKey.ParticipantRemoved &&
|
||||
reasonKey !== DisconnectReasonKey.RoomDeleted
|
||||
const showBackButton = reasonKey !== DisconnectReasonKey.ParticipantRemoved
|
||||
|
||||
return (
|
||||
<Screen layout="centered" footer={false}>
|
||||
|
||||
@@ -18,7 +18,6 @@ import { LogLevel, setLogLevel } from 'livekit-client'
|
||||
import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability'
|
||||
import { useWatchDeviceReleased } from '@/features/rooms/hooks/useWatchDeviceReleased'
|
||||
import { useRoomPageTitle } from '@/features/rooms/livekit/hooks/useRoomPageTitle'
|
||||
import { usePageDataAttribute } from '@/navigation/usePageDataAttribute'
|
||||
|
||||
const BaseRoom = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
@@ -39,12 +38,10 @@ const Room = () => {
|
||||
const initialRoomData = history.state?.initialRoomData
|
||||
const mode = isLoggedIn && history.state?.create ? 'create' : 'join'
|
||||
const skipJoinScreen = isLoggedIn && mode === 'create'
|
||||
const isJoinScreen = !hasSubmittedEntry && !skipJoinScreen
|
||||
|
||||
const { data } = useConfig()
|
||||
|
||||
useRoomPageTitle(roomId)
|
||||
usePageDataAttribute(isJoinScreen ? 'join' : 'conference')
|
||||
|
||||
useEffect(() => {
|
||||
const shouldSilenceLogs = data?.silence_livekit_debug_logs || false
|
||||
@@ -78,7 +75,7 @@ const Room = () => {
|
||||
return <ErrorScreen />
|
||||
}
|
||||
|
||||
if (isJoinScreen) {
|
||||
if (!hasSubmittedEntry && !skipJoinScreen) {
|
||||
return (
|
||||
<BaseRoom>
|
||||
<Join enterRoom={() => setHasSubmittedEntry(true)} roomId={roomId} />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user