Compare commits

...

30 Commits

Author SHA1 Message Date
lebaudantoine 493d7b96f1 📝(docs) add missing trailing slash
A trailing slash was missing in the documentation.
Spotted by T. Lemeur when integrating the API.
2025-12-22 09:57:34 +01:00
lebaudantoine c2c478c367 🩹(backend) remove environment prefix from recently introduced settings
The prefix was unintentionally added and wasn’t caught during review.
This change corrects it.
2025-12-21 16:27:11 +01:00
lebaudantoine b5895ccba0 🩹(summary) fix missing f-string
Spotted by code rabbit. Missing F-string was leading
to an unexpected behavior.
2025-12-19 14:29:56 +01:00
lebaudantoine aff87d4953 (summary) add Langfuse observability for LLM API calls
Implement Langfuse tracing integration for LLM service calls to capture
prompts, responses, latency, token usage, and errors, enabling
comprehensive monitoring and debugging of AI model interactions
for performance analysis and cost optimization.
2025-12-19 14:29:56 +01:00
lebaudantoine c81ef38005 ♻️(summary) extract LLMService class into dedicated module
Move LLMService class from existing file into separate dedicated
module to improve code organization.
2025-12-19 14:29:56 +01:00
lebaudantoine 4256eb403d 🔒️(summary) refactor configuration secrets to use Pydantic SecretStr
Replace plain string fields with Pydantic SecretStr class for all
sensitive configuration values in FastAPI settings to prevent accidental
exposure in logs, error messages, or debugging output, following
security best practices for credential handling.
2025-12-19 14:29:56 +01:00
lebaudantoine 43f3e4691b (summmary) add Langfuse to summary service dependencies
Install Langfuse observability client in summary service
to enable LLM tracing, monitoring, and debugging capabilities
for AI-powered summarization workflows,
improving visibility into model performance and behavior.
2025-12-19 14:29:56 +01:00
lebaudantoine 10aac93c36 📝(backend) improve user provisioning documentation
try to make explicit all implicit implementation's details
2025-12-19 13:41:37 +01:00
lebaudantoine 4e6bc157b0 ♻️(backend) standardize error response format in token endpoint
Align error response with the pattern used at other places of the codebase.
2025-12-19 13:41:37 +01:00
lebaudantoine fe83c5fa07 (backend) add unit tests for user provisioning via external API
Add test coverage for provisional user creation through the external API,
including creating users with email-only (no sub)
2025-12-19 13:41:37 +01:00
lebaudantoine 827014c952 ♻️(backend) explicitly enforce sub field immutability
Add OIDC_USER_SUB_FIELD_IMMUTABLE setting to our config and enforce
it in the user viewset. Previously relied on implicit Django
LaSuite defaults.

Makes the sub mutability constraint explicit and ensures it's enforced
at the application level, critical for provisional users where sub is
assigned on first login.
2025-12-19 13:41:37 +01:00
lebaudantoine 9523f52546 📝(docs) clarify sub as optional to support email-only user provisioning
Update the sub field documentation to explicitly reflect its optional nature.
Originally intended to be mandatory, sub became optional due to a code issue.
This change acknowledges and formalizes that behavior as intentional.

The optional sub enables external API integrations to provision users with
only an email address. Full identity (sub) is assigned on first login,
allowing third-party platforms to create users before they authenticate.
2025-12-19 13:41:37 +01:00
lebaudantoine 8348a55f7e (backend) enable user creation via email for external integrations
Allow external platforms using the public API to create provisional users
with email-only identification when the user doesn't yet exist in our
system. This removes a key friction point blocking third-party integrations
from fully provisioning access on behalf of new users.

Provisional users are created with email as the primary identifier. Full
identity reconciliation (sub assignment) occurs on first login, ensuring
reliable user identification is eventually established.

While email-only user creation is not ideal from an identity perspective,
it provides a pragmatic path to unlock integrations and accelerate adoption
through external platforms that are increasingly driving our videoconference
tool's growth.
2025-12-19 13:41:37 +01:00
lebaudantoine a4b76433ab 🧑‍💻(release) introduce a release helper tool
Discussed at lunch with our CTO, enhance tooling
around release preparation. Naive bash script generated
using Claude. Please feel free to enhance it.
2025-12-17 19:55:24 +01:00
lebaudantoine ae863418cd 📝(changelog) reorganize sections to match Keep a Changelog convention
Reorder CHANGELOG section headings to follow standard Keep a Changelog format
(Added, Changed, Deprecated, Removed, Fixed, Security) for consistent structure
that users expect when reviewing release notes.
2025-12-17 18:41:45 +01:00
lebaudantoine dcdae26610 🔖(release) release 1.0.1
Patch several accessibility issues.
2025-12-17 17:36:01 +01:00
Cyril 90c0442d35 (frontend) fix focus scroll jump during side panel animation
preventScroll avoids layout shift that broke the slide-in chat animation

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-17 16:13:25 +01:00
Cyril 9093371d25 (frontend) restore focus on chat close
restore keyboard focus to the triggering element when the chat panel closes.

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-17 16:13:24 +01:00
Cyril 1d45d3aa7c (frontend) focus chat input on panel open
move keyboard focus to the message input when the chat panel opens.

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-17 16:12:44 +01:00
Cyril fcb89c520e ️(frontend) fix heading level in modal to maintain semantic hierarchy
replaced h3 with h2 for accessibility and proper document structure
2025-12-17 16:00:35 +01:00
Cyril 309ce0989d ️(frontend) indicate external link opens in new window on feedback
added title attribute to clarify link behavior for screen reader users
2025-12-17 15:42:30 +01:00
Cyril a6c154374f ️(frontend) change ptt keybinding from space to v
ptt now uses v key to avoid accidental activation when typing
2025-12-17 15:18:46 +01:00
lebaudantoine b0e27b38e2 🔒️(backend) avoid serializing rooms's pin code when restricted
Prevent anonymous users waiting in the lobby, or attacker
to discover the room pin code, that would allow them to join a room.
2025-12-17 10:05:23 +01:00
Cyril 9bdc68f9c9 (frontend) create reusable shortcut tooltip component
extracted tooltip into a component to unify style and ease reuse across ui

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-16 09:41:43 +01:00
Cyril 4545e9fa1e 💄(frontend) update shortcut tooltip position and style for consistency
moved tooltip from left to right to avoid overlap with recording indicator
2025-12-16 09:41:43 +01:00
Cyril 3f1edbf134 ️(frontend) fix SR texts/translations to avoid double announcement
Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-16 09:41:42 +01:00
Cyril 4f2764eef4 ️(frontend) add tooltip and sr hint for f2 shortcut to bottom toolbar
helps keyboard and sr users discover the f2 shortcut for toolbar access

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-11 14:57:51 +01:00
Cyril b11cc6e9da ️(frontend) update blur and focus translations for participants
adds fr/en/de/nl translations for blur and focus accessibility labels

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-11 14:57:40 +01:00
Cyril 0a7eb97c90 ️(frontend) hide avatar initials from sr to avoid duplicate names
prevents screen readers from announcing participant names twice

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-11 14:57:26 +01:00
Cyril db188075af ️(frontend) improve meeting a11y: blur, focus, hover, sr announcements
enhances keyboard nav and screen reader support for meeting interface

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-11 14:56:58 +01:00
44 changed files with 942 additions and 352 deletions
+15 -2
View File
@@ -1,4 +1,3 @@
# Changelog
All notable changes to this project will be documented in this file.
@@ -9,4 +8,18 @@ and this project adheres to
## [Unreleased]
-
### Added
- ✨(backend) enable user creation via email for external integrations
- ✨(summary) add Langfuse observability for LLM API calls
## [1.0.1] - 2025-12-17
### Changed
- ♿(frontend) improve accessibility:
- ♿️(frontend) hover controls, focus, SR #803
- ♿️(frontend) change ptt keybinding from space to v #813
- ♿(frontend) indicate external link opens in new window on feedback #816
- ♿(frontend) fix heading level in modal to maintain semantic hierarchy #815
- ♿️(frontend) Improve focus management when opening and closing chat #807
+160
View File
@@ -0,0 +1,160 @@
#!/bin/bash
set -e # Exit on error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to print colored output
print_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
# Function to update npm package version
update_npm_version() {
local component=$1
print_info "Updating $component version..."
cd "src/$component"
npm version "$VERSION" --no-git-tag-version
cd -
}
# Function to update Python project version in pyproject.toml
update_python_version() {
local component=$1
print_info "Updating $component version..."
cd "src/$component"
if [ ! -f "pyproject.toml" ]; then
print_error "pyproject.toml not found in src/$component!"
exit 1
fi
if grep -q '^version = "' pyproject.toml; then
sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml
rm pyproject.toml.bak
print_info "Updated pyproject.toml version to $VERSION"
else
print_error "Could not find version line in pyproject.toml"
exit 1
fi
cd -
}
# Check if we're in a git repository
if ! git rev-parse --git-dir > /dev/null 2>&1; then
print_error "Not a git repository. Please run this script from the root of your project."
exit 1
fi
# Check if working directory is clean
if ! git diff-index --quiet HEAD --; then
print_error "Working directory is not clean. Please commit or stash your changes first."
exit 1
fi
# Ask user for release version number
echo ""
read -p "Enter release version number (e.g., 1.2.3): " VERSION
# Validate version format (basic semver check)
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
print_error "Invalid version format. Please use semantic versioning (e.g., 1.2.3)"
exit 1
fi
print_info "Release version: $VERSION"
# Check if branch already exists
BRANCH_NAME="release/$VERSION"
if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then
print_error "Branch $BRANCH_NAME already exists!"
exit 1
fi
# Create and checkout new branch
print_info "Creating branch: $BRANCH_NAME"
git checkout -b "$BRANCH_NAME"
# Update frontend
update_npm_version "frontend"
# Update SDK
update_npm_version "sdk"
# Update mail
update_npm_version "mail"
# Update backend pyproject.toml
update_python_version "backend"
# Update summary pyproject.toml
update_python_version "summary"
# Update agents pyproject.toml
update_python_version "agents"
# Update CHANGELOG
print_info "Updating CHANGELOG..."
if [ ! -f "CHANGELOG.md" ]; then
print_error "CHANGELOG.md not found in project root!"
exit 1
fi
# Get current date in YYYY-MM-DD format
CURRENT_DATE=$(date +%Y-%m-%d)
# Replace [Unreleased] with [version number] - YYYY-MM-DD
if grep -q '\[Unreleased\]' CHANGELOG.md; then
sed -i.bak "s/\[Unreleased\]/[$VERSION] - $CURRENT_DATE/" CHANGELOG.md
# Add new [Unreleased] section after the header
# This adds it after the line containing "Semantic Versioning"
sed -i.bak "/Semantic Versioning/a\\
\\
## [Unreleased]
" CHANGELOG.md
rm CHANGELOG.md.bak
print_info "Updated CHANGELOG.md"
else
print_warning "Could not find [Unreleased] section in CHANGELOG.md"
fi
# Summary
echo ""
print_info "Release preparation complete!"
echo ""
echo "Summary:"
echo " - Branch created: $BRANCH_NAME"
echo " - Version updated to: $VERSION"
echo " - Files modified:"
echo " - src/frontend/package.json"
echo " - src/sdk/package.json"
echo " - src/mail/package.json"
echo " - src/backend/pyproject.toml"
echo " - src/summary/pyproject.toml"
echo " - src/agents/pyproject.toml"
echo " - CHANGELOG.md"
echo ""
print_warning "Next steps:"
echo " 1. Review the changes: git status"
echo " 2. Commit the changes: git add . && git commit -m 'Release $VERSION'"
echo " 3. Push the branch: git push origin $BRANCH_NAME"
echo ""
+3 -4
View File
@@ -7,7 +7,7 @@ info:
#### Authentication Flow
1. Exchange application credentials for a JWT token via `/external-api/v1.0/application/token`.
1. Exchange application credentials for a JWT token via `/external-api/v1.0/application/token/`.
2. Use the JWT token in the `Authorization: Bearer <token>` header for all subsequent requests.
3. Tokens are scoped and allow applications to act on behalf of specific users.
@@ -21,7 +21,6 @@ info:
#### Upcoming Features
* **Create rooms for unknown users from the web app:** Support for generating rooms for users who are not yet registered in the system.
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
@@ -40,7 +39,7 @@ tags:
description: Room management operations
paths:
/application/token:
/application/token/:
post:
tags:
- Authentication
@@ -283,7 +282,7 @@ components:
scheme: bearer
bearerFormat: JWT
description: |
JWT token obtained from the `/application/token` endpoint.
JWT token obtained from the `/application/token/` endpoint.
Include in requests as: `Authorization: Bearer <token>`
schemas:
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.0.0"
version = "1.0.1"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.2.18",
+2
View File
@@ -159,6 +159,8 @@ class RoomSerializer(serializers.ModelSerializer):
configuration=configuration,
is_admin_or_owner=is_admin_or_owner,
)
else:
del output["pin_code"]
output["is_administrable"] = is_admin_or_owner
+28 -5
View File
@@ -95,11 +95,34 @@ class ApplicationViewSet(viewsets.GenericViewSet):
try:
user = models.User.objects.get(email=email)
except models.User.DoesNotExist as e:
raise drf_exceptions.NotFound(
{
"error": "User not found.",
}
) from e
if (
settings.APPLICATION_ALLOW_USER_CREATION
and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
and not settings.OIDC_USER_SUB_FIELD_IMMUTABLE
):
# Create a provisional user without `sub`, identified by email only.
#
# This relies on Django LaSuite implicitly updating the `sub` field on the
# user's first successful OIDC authentication. If this stops working,
# check for behavior changes in Django LaSuite.
#
# `OIDC_USER_SUB_FIELD_IMMUTABLE` comes from Django LaSuite and prevents `sub`
# updates. We override its default value to allow setting `sub` for
# provisional users.
user = models.User(
sub=None,
email=email,
)
user.set_unusable_password()
user.save()
logger.info(
"Provisional user created via application: user_id=%s, email=%s, client_id=%s",
user.id,
email,
application.client_id,
)
else:
raise drf_exceptions.NotFound("User not found.") from e
now = datetime.now(timezone.utc)
scope = " ".join(application.scopes or [])
+1 -1
View File
@@ -41,7 +41,7 @@ class Migration(migrations.Migration):
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
+2 -1
View File
@@ -146,7 +146,8 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
"Optional for pending users; required upon account activation. "
"255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
),
max_length=255,
unique=True,
@@ -32,7 +32,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -52,7 +51,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -71,7 +69,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -88,7 +85,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -105,7 +101,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -347,7 +342,6 @@ def test_api_rooms_retrieve_authenticated():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -14,7 +14,7 @@ from core.factories import (
ApplicationFactory,
UserFactory,
)
from core.models import ApplicationScope
from core.models import ApplicationScope, User
pytestmark = pytest.mark.django_db
@@ -232,6 +232,7 @@ def test_api_applications_token_payload_structure(settings):
"""Generated token should have correct payload structure."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
@@ -273,3 +274,119 @@ def test_api_applications_token_payload_structure(settings):
"delegated": True,
"scope": "rooms:list rooms:create",
}
@freeze_time("2023-01-15 12:00:00")
def test_api_applications_token_new_user(settings):
"""Should create a new pending user when creation is allowed and user doesn't exist."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_ALLOW_USER_CREATION = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
assert len(User.objects.all()) == 0
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "unknown@world.com",
},
format="json",
)
# Decode token to verify payload
token = response.data["access_token"]
payload = jwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
user = User.objects.get(email="unknown@world.com")
assert user.sub is None
assert payload == {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"client_id": application.client_id,
"exp": 1673787600,
"iat": 1673784000,
"user_id": str(user.id),
"delegated": True,
"scope": "rooms:list rooms:create",
}
@freeze_time("2023-01-15 12:00:00")
def test_api_applications_token_existing_user(settings):
"""Application should not create a new user when user exist."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
settings.APPLICATION_ALLOW_USER_CREATION = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
assert len(User.objects.all()) == 1
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
# Assert no new user was created
assert len(User.objects.all()) == 1
# Decode token to verify payload
token = response.data["access_token"]
payload = jwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
assert payload == {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"client_id": application.client_id,
"exp": 1673787600,
"iat": 1673784000,
"user_id": str(user.id),
"delegated": True,
"scope": "rooms:list rooms:create",
}
+63 -62
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
"POT-Creation-Date: 2025-12-17 15:12+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -177,61 +177,61 @@ msgstr "Sub"
#: core/models.py:149
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
"Erforderlich. Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ sind "
"erlaubt."
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
#: core/models.py:157
#: core/models.py:158
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:162
#: core/models.py:163
msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:164
#: core/models.py:165
msgid "full name"
msgstr "Vollständiger Name"
#: core/models.py:166
#: core/models.py:167
msgid "short name"
msgstr "Kurzname"
#: core/models.py:172
#: core/models.py:173
msgid "language"
msgstr "Sprache"
#: core/models.py:173
#: core/models.py:174
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:179
#: core/models.py:180
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:182
#: core/models.py:183
msgid "device"
msgstr "Gerät"
#: core/models.py:184
#: core/models.py:185
msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:187
#: core/models.py:188
msgid "staff status"
msgstr "Mitarbeiterstatus"
#: core/models.py:189
#: core/models.py:190
msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:192
#: core/models.py:193
msgid "active"
msgstr "aktiv"
#: core/models.py:195
#: core/models.py:196
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -239,66 +239,66 @@ msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos."
#: core/models.py:208
#: core/models.py:209
msgid "user"
msgstr "Benutzer"
#: core/models.py:209
#: core/models.py:210
msgid "users"
msgstr "Benutzer"
#: core/models.py:268
#: core/models.py:269
msgid "Resource"
msgstr "Ressource"
#: core/models.py:269
#: core/models.py:270
msgid "Resources"
msgstr "Ressourcen"
#: core/models.py:323
#: core/models.py:324
msgid "Resource access"
msgstr "Ressourcenzugriff"
#: core/models.py:324
#: core/models.py:325
msgid "Resource accesses"
msgstr "Ressourcenzugriffe"
#: core/models.py:330
#: core/models.py:331
msgid "Resource access with this User and Resource already exists."
msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits."
#: core/models.py:386
#: core/models.py:387
msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:387
#: core/models.py:388
msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:394
#: core/models.py:395
msgid "Room PIN code"
msgstr "PIN-Code für den Raum"
#: core/models.py:395
#: core/models.py:396
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:401 core/models.py:555
#: core/models.py:402 core/models.py:556
msgid "Room"
msgstr "Raum"
#: core/models.py:402
#: core/models.py:403
msgid "Rooms"
msgstr "Räume"
#: core/models.py:566
#: core/models.py:567
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:568
#: core/models.py:569
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -307,99 +307,100 @@ msgstr ""
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht."
#: core/models.py:576
#: core/models.py:577
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:577
#: core/models.py:578
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:583
#: core/models.py:584
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:584
#: core/models.py:585
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:692
#: core/models.py:693
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:693
#: core/models.py:694
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:699
#: core/models.py:700
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:705
#: core/models.py:706
msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:711
#: core/models.py:712
msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/models.py:728
#: core/models.py:729
msgid "Create rooms"
msgstr "Räume erstellen"
#: core/models.py:729
#: core/models.py:730
msgid "List rooms"
msgstr "Räume auflisten"
#: core/models.py:730
#: core/models.py:731
msgid "Retrieve room details"
msgstr "Raumdetails abrufen"
#: core/models.py:731
#: core/models.py:732
msgid "Update rooms"
msgstr "Räume aktualisieren"
#: core/models.py:732
#: core/models.py:733
msgid "Delete rooms"
msgstr "Räume löschen"
#: core/models.py:745
#: core/models.py:746
msgid "Application name"
msgstr "Anwendungsname"
#: core/models.py:746
#: core/models.py:747
msgid "Descriptive name for this application."
msgstr "Beschreibender Name für diese Anwendung."
#: core/models.py:756
#: core/models.py:757
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
msgstr ""
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
#: core/models.py:767
#: core/models.py:768
msgid "Application"
msgstr "Anwendung"
#: core/models.py:768
#: core/models.py:769
msgid "Applications"
msgstr "Anwendungen"
#: core/models.py:791
#: core/models.py:792
msgid "Enter a valid domain"
msgstr "Geben Sie eine gültige Domain ein"
#: core/models.py:794
#: core/models.py:795
msgid "Domain"
msgstr "Domain"
#: core/models.py:795
#: core/models.py:796
msgid "Email domain this application can act on behalf of."
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
#: core/models.py:807
#: core/models.py:808
msgid "Application domain"
msgstr "Anwendungsdomain"
#: core/models.py:808
#: core/models.py:809
msgid "Application domains"
msgstr "Anwendungsdomains"
@@ -531,18 +532,18 @@ msgstr ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. "
#: meet/settings.py:167
#: meet/settings.py:169
msgid "English"
msgstr "Englisch"
#: meet/settings.py:168
#: meet/settings.py:170
msgid "French"
msgstr "Französisch"
#: meet/settings.py:169
#: meet/settings.py:171
msgid "Dutch"
msgstr "Niederländisch"
#: meet/settings.py:170
#: meet/settings.py:172
msgid "German"
msgstr "Deutsch"
+59 -59
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
"POT-Creation-Date: 2025-12-17 15:12+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -175,61 +175,61 @@ msgstr "sub"
#: core/models.py:149
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:157
#: core/models.py:158
msgid "identity email address"
msgstr "identity email address"
#: core/models.py:162
#: core/models.py:163
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:164
#: core/models.py:165
msgid "full name"
msgstr "full name"
#: core/models.py:166
#: core/models.py:167
msgid "short name"
msgstr "short name"
#: core/models.py:172
#: core/models.py:173
msgid "language"
msgstr "language"
#: core/models.py:173
#: core/models.py:174
msgid "The language in which the user wants to see the interface."
msgstr "The language in which the user wants to see the interface."
#: core/models.py:179
#: core/models.py:180
msgid "The timezone in which the user wants to see times."
msgstr "The timezone in which the user wants to see times."
#: core/models.py:182
#: core/models.py:183
msgid "device"
msgstr "device"
#: core/models.py:184
#: core/models.py:185
msgid "Whether the user is a device or a real user."
msgstr "Whether the user is a device or a real user."
#: core/models.py:187
#: core/models.py:188
msgid "staff status"
msgstr "staff status"
#: core/models.py:189
#: core/models.py:190
msgid "Whether the user can log into this admin site."
msgstr "Whether the user can log into this admin site."
#: core/models.py:192
#: core/models.py:193
msgid "active"
msgstr "active"
#: core/models.py:195
#: core/models.py:196
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -237,63 +237,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:208
#: core/models.py:209
msgid "user"
msgstr "user"
#: core/models.py:209
#: core/models.py:210
msgid "users"
msgstr "users"
#: core/models.py:268
#: core/models.py:269
msgid "Resource"
msgstr "Resource"
#: core/models.py:269
#: core/models.py:270
msgid "Resources"
msgstr "Resources"
#: core/models.py:323
#: core/models.py:324
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:324
#: core/models.py:325
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:330
#: core/models.py:331
msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:386
#: core/models.py:387
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:387
#: core/models.py:388
msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:394
#: core/models.py:395
msgid "Room PIN code"
msgstr "Room PIN code"
#: core/models.py:395
#: core/models.py:396
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unique n-digit code that identifies this room in telephony mode."
#: core/models.py:401 core/models.py:555
#: core/models.py:402 core/models.py:556
msgid "Room"
msgstr "Room"
#: core/models.py:402
#: core/models.py:403
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:566
#: core/models.py:567
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:568
#: core/models.py:569
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -301,103 +301,103 @@ msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:576
#: core/models.py:577
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:577
#: core/models.py:578
msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called."
#: core/models.py:583
#: core/models.py:584
msgid "Recording"
msgstr "Recording"
#: core/models.py:584
#: core/models.py:585
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:692
#: core/models.py:693
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:693
#: core/models.py:694
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:699
#: core/models.py:700
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:705
#: core/models.py:706
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:711
#: core/models.py:712
msgid "Either user or team must be set, not both."
msgstr "Either user or team must be set, not both."
#: core/models.py:728
#: core/models.py:729
#, fuzzy
#| msgid "created on"
msgid "Create rooms"
msgstr "Create rooms"
#: core/models.py:729
#: core/models.py:730
msgid "List rooms"
msgstr "List rooms"
#: core/models.py:730
#: core/models.py:731
msgid "Retrieve room details"
msgstr "Retrieve room details"
#: core/models.py:731
#: core/models.py:732
#, fuzzy
#| msgid "updated on"
msgid "Update rooms"
msgstr "Update rooms"
#: core/models.py:732
#: core/models.py:733
msgid "Delete rooms"
msgstr "Delete rooms"
#: core/models.py:745
#: core/models.py:746
msgid "Application name"
msgstr "Application name"
#: core/models.py:746
#: core/models.py:747
msgid "Descriptive name for this application."
msgstr "Descriptive name for this application."
#: core/models.py:756
#: core/models.py:757
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Hashed on Save. Copy it now if this is a new secret."
#: core/models.py:767
#: core/models.py:768
msgid "Application"
msgstr "Application"
#: core/models.py:768
#: core/models.py:769
msgid "Applications"
msgstr "Applications"
#: core/models.py:791
#: core/models.py:792
msgid "Enter a valid domain"
msgstr "Enter a valid domain"
#: core/models.py:794
#: core/models.py:795
msgid "Domain"
msgstr "Domain"
#: core/models.py:795
#: core/models.py:796
msgid "Email domain this application can act on behalf of."
msgstr "Email domain this application can act on behalf of."
#: core/models.py:807
#: core/models.py:808
msgid "Application domain"
msgstr "Application domain"
#: core/models.py:808
#: core/models.py:809
msgid "Application domains"
msgstr "Application domains"
@@ -529,18 +529,18 @@ msgstr ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
#: meet/settings.py:167
#: meet/settings.py:169
msgid "English"
msgstr "English"
#: meet/settings.py:168
#: meet/settings.py:170
msgid "French"
msgstr "French"
#: meet/settings.py:169
#: meet/settings.py:171
msgid "Dutch"
msgstr "Dutch"
#: meet/settings.py:170
#: meet/settings.py:172
msgid "German"
msgstr "German"
+64 -62
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
"POT-Creation-Date: 2025-12-17 15:12+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -179,61 +179,61 @@ msgstr "sub"
#: core/models.py:149
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
"Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./"
"+/-/_ uniquement."
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
#: core/models.py:157
#: core/models.py:158
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: core/models.py:162
#: core/models.py:163
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:164
#: core/models.py:165
msgid "full name"
msgstr "nom complet"
#: core/models.py:166
#: core/models.py:167
msgid "short name"
msgstr "nom court"
#: core/models.py:172
#: core/models.py:173
msgid "language"
msgstr "langue"
#: core/models.py:173
#: core/models.py:174
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:179
#: core/models.py:180
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:182
#: core/models.py:183
msgid "device"
msgstr "appareil"
#: core/models.py:184
#: core/models.py:185
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:187
#: core/models.py:188
msgid "staff status"
msgstr "statut du personnel"
#: core/models.py:189
#: core/models.py:190
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:192
#: core/models.py:193
msgid "active"
msgstr "actif"
#: core/models.py:195
#: core/models.py:196
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -241,65 +241,65 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:208
#: core/models.py:209
msgid "user"
msgstr "utilisateur"
#: core/models.py:209
#: core/models.py:210
msgid "users"
msgstr "utilisateurs"
#: core/models.py:268
#: core/models.py:269
msgid "Resource"
msgstr "Ressource"
#: core/models.py:269
#: core/models.py:270
msgid "Resources"
msgstr "Ressources"
#: core/models.py:323
#: core/models.py:324
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:324
#: core/models.py:325
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:330
#: core/models.py:331
msgid "Resource access with this User and Resource already exists."
msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:386
#: core/models.py:387
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:387
#: core/models.py:388
msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:394
#: core/models.py:395
msgid "Room PIN code"
msgstr "Code PIN de la salle"
#: core/models.py:395
#: core/models.py:396
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:401 core/models.py:555
#: core/models.py:402 core/models.py:556
msgid "Room"
msgstr "Salle"
#: core/models.py:402
#: core/models.py:403
msgid "Rooms"
msgstr "Salles"
#: core/models.py:566
#: core/models.py:567
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:568
#: core/models.py:569
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -307,99 +307,101 @@ msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
#: core/models.py:576
#: core/models.py:577
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:577
#: core/models.py:578
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:583
#: core/models.py:584
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:584
#: core/models.py:585
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:692
#: core/models.py:693
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:693
#: core/models.py:694
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:699
#: core/models.py:700
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:705
#: core/models.py:706
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:711
#: core/models.py:712
msgid "Either user or team must be set, not both."
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/models.py:728
#: core/models.py:729
msgid "Create rooms"
msgstr "Créer des salles"
#: core/models.py:729
#: core/models.py:730
msgid "List rooms"
msgstr "Lister les salles"
#: core/models.py:730
#: core/models.py:731
msgid "Retrieve room details"
msgstr "Afficher les détails dune salle"
#: core/models.py:731
#: core/models.py:732
msgid "Update rooms"
msgstr "Mettre à jour les salles"
#: core/models.py:732
#: core/models.py:733
msgid "Delete rooms"
msgstr "Supprimer les salles"
#: core/models.py:745
#: core/models.py:746
msgid "Application name"
msgstr "Nom de lapplication"
#: core/models.py:746
#: core/models.py:747
msgid "Descriptive name for this application."
msgstr "Nom descriptif de cette application."
#: core/models.py:756
#: core/models.py:757
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun nouveau secret."
msgstr ""
"Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun "
"nouveau secret."
#: core/models.py:767
#: core/models.py:768
msgid "Application"
msgstr "Application"
#: core/models.py:768
#: core/models.py:769
msgid "Applications"
msgstr "Applications"
#: core/models.py:791
#: core/models.py:792
msgid "Enter a valid domain"
msgstr "Saisissez un domaine valide"
#: core/models.py:794
#: core/models.py:795
msgid "Domain"
msgstr "Domaine"
#: core/models.py:795
#: core/models.py:796
msgid "Email domain this application can act on behalf of."
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
#: core/models.py:807
#: core/models.py:808
msgid "Application domain"
msgstr "Domaine dapplication"
#: core/models.py:808
#: core/models.py:809
msgid "Application domains"
msgstr "Domaines dapplication"
@@ -531,18 +533,18 @@ msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. "
#: meet/settings.py:167
#: meet/settings.py:169
msgid "English"
msgstr "Anglais"
#: meet/settings.py:168
#: meet/settings.py:170
msgid "French"
msgstr "Français"
#: meet/settings.py:169
#: meet/settings.py:171
msgid "Dutch"
msgstr "Néerlandais"
#: meet/settings.py:170
#: meet/settings.py:172
msgid "German"
msgstr "Allemand"
+63 -61
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
"POT-Creation-Date: 2025-12-17 15:12+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -176,60 +176,61 @@ msgstr "sub"
#: core/models.py:149
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
"Vereist. 255 tekens of minder. Alleen letters, cijfers en @/./+/-/_ tekens."
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
#: core/models.py:157
#: core/models.py:158
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:162
#: core/models.py:163
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:164
#: core/models.py:165
msgid "full name"
msgstr "volledige naam"
#: core/models.py:166
#: core/models.py:167
msgid "short name"
msgstr "korte naam"
#: core/models.py:172
#: core/models.py:173
msgid "language"
msgstr "taal"
#: core/models.py:173
#: core/models.py:174
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:179
#: core/models.py:180
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:182
#: core/models.py:183
msgid "device"
msgstr "apparaat"
#: core/models.py:184
#: core/models.py:185
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:187
#: core/models.py:188
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:189
#: core/models.py:190
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:192
#: core/models.py:193
msgid "active"
msgstr "actief"
#: core/models.py:195
#: core/models.py:196
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -237,64 +238,64 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:208
#: core/models.py:209
msgid "user"
msgstr "gebruiker"
#: core/models.py:209
#: core/models.py:210
msgid "users"
msgstr "gebruikers"
#: core/models.py:268
#: core/models.py:269
msgid "Resource"
msgstr "Bron"
#: core/models.py:269
#: core/models.py:270
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:323
#: core/models.py:324
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:324
#: core/models.py:325
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:330
#: core/models.py:331
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:386
#: core/models.py:387
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:387
#: core/models.py:388
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:394
#: core/models.py:395
msgid "Room PIN code"
msgstr "Pincode van de kamer"
#: core/models.py:395
#: core/models.py:396
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:401 core/models.py:555
#: core/models.py:402 core/models.py:556
msgid "Room"
msgstr "Ruimte"
#: core/models.py:402
#: core/models.py:403
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:566
#: core/models.py:567
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:568
#: core/models.py:569
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -302,99 +303,100 @@ msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:576
#: core/models.py:577
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:577
#: core/models.py:578
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:583
#: core/models.py:584
msgid "Recording"
msgstr "Opname"
#: core/models.py:584
#: core/models.py:585
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:692
#: core/models.py:693
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:693
#: core/models.py:694
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:699
#: core/models.py:700
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:705
#: core/models.py:706
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:711
#: core/models.py:712
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:728
#: core/models.py:729
msgid "Create rooms"
msgstr "Ruimtes aanmaken"
#: core/models.py:729
#: core/models.py:730
msgid "List rooms"
msgstr "Ruimtes weergeven"
#: core/models.py:730
#: core/models.py:731
msgid "Retrieve room details"
msgstr "Details van een ruimte ophalen"
#: core/models.py:731
#: core/models.py:732
msgid "Update rooms"
msgstr "Ruimtes bijwerken"
#: core/models.py:732
#: core/models.py:733
msgid "Delete rooms"
msgstr "Ruimtes verwijderen"
#: core/models.py:745
#: core/models.py:746
msgid "Application name"
msgstr "Naam van de applicatie"
#: core/models.py:746
#: core/models.py:747
msgid "Descriptive name for this application."
msgstr "Beschrijvende naam voor deze applicatie."
#: core/models.py:756
#: core/models.py:757
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
msgstr ""
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
#: core/models.py:767
#: core/models.py:768
msgid "Application"
msgstr "Applicatie"
#: core/models.py:768
#: core/models.py:769
msgid "Applications"
msgstr "Applicaties"
#: core/models.py:791
#: core/models.py:792
msgid "Enter a valid domain"
msgstr "Voer een geldig domein in"
#: core/models.py:794
#: core/models.py:795
msgid "Domain"
msgstr "Domein"
#: core/models.py:795
#: core/models.py:796
msgid "Email domain this application can act on behalf of."
msgstr "E-maildomein namens welke deze applicatie kan handelen."
#: core/models.py:807
#: core/models.py:808
msgid "Application domain"
msgstr "Applicatiedomein"
#: core/models.py:808
#: core/models.py:809
msgid "Application domains"
msgstr "Applicatiedomeinen"
@@ -526,18 +528,18 @@ msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. "
#: meet/settings.py:167
#: meet/settings.py:169
msgid "English"
msgstr "Engels"
#: meet/settings.py:168
#: meet/settings.py:170
msgid "French"
msgstr "Frans"
#: meet/settings.py:169
#: meet/settings.py:171
msgid "Dutch"
msgstr "Nederlands"
#: meet/settings.py:170
#: meet/settings.py:172
msgid "German"
msgstr "Duits"
+12
View File
@@ -405,6 +405,10 @@ class Base(Configuration):
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
default=False,
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
environ_prefix=None,
)
OIDC_USER_SUB_FIELD_IMMUTABLE = values.BooleanValue(
default=True, environ_name="OIDC_USER_SUB_FIELD_IMMUTABLE", environ_prefix=None
)
OIDC_TIMEOUT = values.IntegerValue(
5, environ_name="OIDC_TIMEOUT", environ_prefix=None
@@ -770,6 +774,14 @@ class Base(Configuration):
environ_name="APPLICATION_BASE_URL",
environ_prefix=None,
)
# Allows third-party platforms to create users with email-only identification.
# Required for external integrations, but fragile due to deferred user reconciliation
# on sub. Enable it with care /!\
APPLICATION_ALLOW_USER_CREATION = values.BooleanValue(
False,
environ_name="APPLICATION_ALLOW_USER_CREATION",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "1.0.0"
version = "1.0.1"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.0.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.0.0",
"version": "1.0.1",
"dependencies": {
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.0.0",
"version": "1.0.1",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
+1
View File
@@ -68,6 +68,7 @@ export const Avatar = ({
{...props}
>
<span
aria-hidden="true"
className={css({
marginTop: '-0.3rem',
})}
@@ -67,7 +67,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
gap={0}
style={{ maxWidth: '100%', overflow: 'hidden' }}
>
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
{t('heading')}
</Heading>
<Div position="absolute" top="5" right="5">
@@ -0,0 +1,32 @@
import React, { ReactNode } from 'react'
import { css } from '@/styled-system/css'
export interface KeyboardShortcutHintProps {
children: ReactNode
}
/**
* Small reusable bubble used to display and announce keyboard shortcuts,
* typically when an element receives keyboard focus.
*/
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
children,
}) => {
return (
<div
className={css({
position: 'absolute',
top: '0.75rem',
right: '0.75rem',
backgroundColor: 'rgba(0,0,0,0.5)',
color: 'white',
borderRadius: 'calc(var(--lk-border-radius) / 2)',
paddingInline: '0.5rem',
paddingBlock: '0.1rem',
fontSize: '0.875rem',
})}
>
{children}
</div>
)
}
@@ -35,6 +35,7 @@ export const ParticipantName = ({
style={{
paddingBottom: '0.1rem',
}}
aria-hidden="true"
>
{displayedName}
</Text>
@@ -29,6 +29,9 @@ import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './ParticipantTileFocus'
import { FullScreenShareWarning } from './FullScreenShareWarning'
import { ParticipantName } from './ParticipantName'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useTranslation } from 'react-i18next'
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
export function TrackRefContextIfNeeded(
props: React.PropsWithChildren<{
@@ -102,9 +105,31 @@ export const ParticipantTile: (
})
const isScreenShare = trackReference.source != Track.Source.Camera
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
const participantName = getParticipantName(trackReference.participant)
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const interactiveProps = {
...elementProps,
// Ensure the tile is focusable to expose contextual controls to keyboard users.
tabIndex: 0,
'aria-label': t('containerLabel', { name: participantName }),
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
elementProps.onFocus?.(event)
setHasKeyboardFocus(true)
},
onBlur: (event: React.FocusEvent<HTMLDivElement>) => {
elementProps.onBlur?.(event)
const nextTarget = event.relatedTarget as Node | null
if (!event.currentTarget.contains(nextTarget)) {
setHasKeyboardFocus(false)
}
},
}
return (
<div ref={ref} style={{ position: 'relative' }} {...elementProps}>
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
<TrackRefContextIfNeeded trackRef={trackReference}>
<ParticipantContextIfNeeded participant={trackReference.participant}>
<FullScreenShareWarning trackReference={trackReference} />
@@ -195,10 +220,16 @@ export const ParticipantTile: (
</>
)}
{!disableMetadata && (
<ParticipantTileFocus trackRef={trackReference} />
<ParticipantTileFocus
trackRef={trackReference}
hasKeyboardFocus={hasKeyboardFocus}
/>
)}
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
{hasKeyboardFocus && (
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
)}
</div>
)
})
@@ -131,8 +131,10 @@ const MOUSE_IDLE_TIME = 3000
export const ParticipantTileFocus = ({
trackRef,
hasKeyboardFocus,
}: {
trackRef: TrackReferenceOrPlaceholder
hasKeyboardFocus: boolean
}) => {
const [hovered, setHovered] = useState(false)
const [opacity, setOpacity] = useState(0)
@@ -140,8 +142,10 @@ export const ParticipantTileFocus = ({
const idleTimerRef = useRef<number | null>(null)
const [isIdleRef, setIsIdleRef] = useState(false)
const isVisible = hasKeyboardFocus || (hovered && !isIdleRef)
useEffect(() => {
if (hovered && !isIdleRef) {
if (isVisible) {
// Wait for next frame to ensure element is mounted
requestAnimationFrame(() => {
setOpacity(0.6)
@@ -149,7 +153,7 @@ export const ParticipantTileFocus = ({
} else {
setOpacity(0)
}
}, [hovered, isIdleRef])
}, [isVisible])
const handleMouseMove = () => {
if (idleTimerRef.current) {
@@ -180,11 +184,12 @@ export const ParticipantTileFocus = ({
width: '100%',
height: '100%',
})}
aria-hidden={!isVisible}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onMouseMove={handleMouseMove}
>
{hovered && (
{isVisible && (
<div
className={css({
backgroundColor: 'primaryDark.50',
@@ -93,7 +93,7 @@ export const ToggleDevice = <T extends ToggleSource>({
isDisabled: cannotUseDevice,
})
useLongPress({
keyCode: kind === 'audioinput' ? 'Space' : undefined,
keyCode: kind === 'audioinput' ? 'KeyV' : undefined,
onKeyDown,
onKeyUp,
isDisabled: cannotUseDevice,
@@ -1,5 +1,5 @@
import type { ChatMessage, ChatOptions } from '@livekit/components-core'
import * as React from 'react'
import React, { useEffect } from 'react'
import {
formatChatMessageLinks,
useChat,
@@ -36,6 +36,36 @@ export function Chat({ ...props }: ChatProps) {
const { isChatOpen } = useSidePanel()
const chatSnap = useSnapshot(chatStore)
// Keep track of the element that opened the chat so we can restore focus
// when the chat panel is closed.
const prevIsChatOpenRef = React.useRef(false)
const chatTriggerRef = React.useRef<HTMLElement | null>(null)
useEffect(() => {
const wasChatOpen = prevIsChatOpenRef.current
const isChatPanelOpen = isChatOpen
// Chat just opened
if (!wasChatOpen && isChatPanelOpen) {
chatTriggerRef.current = document.activeElement as HTMLElement | null
// Avoid layout "jump" during the side panel slide-in animation.
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
requestAnimationFrame(() => {
inputRef.current?.focus({ preventScroll: true })
})
}
// Chat just closed
if (wasChatOpen && !isChatPanelOpen) {
const trigger = chatTriggerRef.current
if (trigger && document.contains(trigger)) {
trigger.focus({ preventScroll: true })
}
chatTriggerRef.current = null
}
prevIsChatOpenRef.current = isChatPanelOpen
}, [isChatOpen])
// Use useParticipants hook to trigger a re-render when the participant list changes.
const participants = useParticipants()
@@ -45,7 +75,7 @@ export function Chat({ ...props }: ChatProps) {
async function handleSubmit(text: string) {
if (!send || !text) return
await send(text)
inputRef?.current?.focus()
inputRef?.current?.focus({ preventScroll: true })
}
// TEMPORARY: This is a brittle workaround that relies on message count tracking
@@ -11,6 +11,7 @@ import { OptionsButton } from '../../components/controls/Options/OptionsButton'
import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { MoreOptions } from './MoreOptions'
import { useRef } from 'react'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
@@ -19,6 +20,18 @@ export function DesktopControlBar({
}: Readonly<ControlBarAuxProps>) {
const browserSupportsScreenSharing = supportsScreenSharing()
const desktopControlBarEl = useRef<HTMLDivElement>(null)
useRegisterKeyboardShortcut({
shortcut: { key: 'F2' },
handler: () => {
const root = desktopControlBarEl.current
if (!root) return
const firstButton = root.querySelector<HTMLButtonElement>(
'button, [role="button"], [tabindex="0"]'
)
firstButton?.focus()
},
})
return (
<div
ref={desktopControlBarEl}
+1 -1
View File
@@ -7,7 +7,7 @@
},
"feedback": {
"context": "Produkt in Entwicklung — Ihr Feedback ist wichtig!",
"cta": "Teilen Sie uns Ihre Meinung mit"
"cta": "Teilen Sie uns Ihre Meinung mit - neues Fenster"
},
"forbidden": {
"heading": "Zugriff verweigert"
+2
View File
@@ -512,6 +512,8 @@
}
},
"participantTileFocus": {
"containerLabel": "Optionen für {{name}}",
"toolbarHint": "F2: zur Symbolleiste unten.",
"pin": {
"enable": "Anheften",
"disable": "Lösen"
+1 -1
View File
@@ -7,7 +7,7 @@
},
"feedback": {
"context": "Product under development — your input matters!",
"cta": "Share your feedback"
"cta": "Share your feedback - new tab"
},
"forbidden": {
"heading": "You don't have the permission to view this page"
+2
View File
@@ -512,6 +512,8 @@
}
},
"participantTileFocus": {
"containerLabel": "Options for {{name}}",
"toolbarHint": "F2: go to the bottom toolbar.",
"pin": {
"enable": "Pin",
"disable": "Unpin"
+1 -1
View File
@@ -7,7 +7,7 @@
},
"feedback": {
"context": "Produit en cours de développement — votre avis compte !",
"cta": "Partagez votre avis"
"cta": "Partagez votre avis - nouvelle fenêtre"
},
"forbidden": {
"heading": "Accès interdit"
+2
View File
@@ -512,6 +512,8 @@
}
},
"participantTileFocus": {
"containerLabel": "Options pour {{name}}",
"toolbarHint": "F2 : raccourci barre d'outils en bas.",
"pin": {
"enable": "Épingler",
"disable": "Annuler l'épinglage"
+1 -1
View File
@@ -7,7 +7,7 @@
},
"feedback": {
"context": "Product in ontwikkeling - uw input is belangrijk!",
"cta": "Deel uw feedback"
"cta": "Deel uw feedback - nieuw tabblad"
},
"forbidden": {
"heading": "U hebt geen toestemming om deze pagina te bekijken"
+2
View File
@@ -512,6 +512,8 @@
}
},
"participantTileFocus": {
"containerLabel": "Opties voor {{name}}",
"toolbarHint": "F2: naar de werkbalk onderaan.",
"pin": {
"enable": "Pinnen",
"disable": "Losmaken"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.0.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.0.0",
"version": "1.0.1",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.0.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.0.0",
"version": "1.0.1",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.0.0",
"version": "1.0.1",
"author": "",
"license": "ISC",
"description": "",
+2 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.0.0"
version = "1.0.1"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
@@ -15,6 +15,7 @@ dependencies = [
"posthog==6.9.1",
"requests==2.32.5",
"sentry-sdk[fastapi, celery]==2.43.0",
"langfuse==3.10.6"
]
[project.optional-dependencies]
+47 -57
View File
@@ -7,7 +7,7 @@ import os
import tempfile
import time
from pathlib import Path
from typing import Any, Mapping, Optional
from typing import Optional
import openai
import sentry_sdk
@@ -21,6 +21,7 @@ from urllib3.util import Retry
from summary.core.analytics import MetadataManager, get_analytics
from summary.core.config import get_settings
from summary.core.llm_service import LLMException, LLMObservability, LLMService
from summary.core.prompt import (
FORMAT_NEXT_STEPS,
FORMAT_PLAN,
@@ -40,6 +41,7 @@ metadata_manager = MetadataManager()
logger = get_task_logger(__name__)
celery = Celery(
__name__,
broker=settings.celery_broker_url,
@@ -83,50 +85,6 @@ def create_retry_session():
return session
class LLMException(Exception):
"""LLM call failed."""
class LLMService:
"""Service for performing calls to the LLM configured in the settings."""
def __init__(self):
"""Init the LLMService once."""
self._client = openai.OpenAI(
base_url=settings.llm_base_url, api_key=settings.llm_api_key
)
def call(
self,
system_prompt: str,
user_prompt: str,
response_format: Optional[Mapping[str, Any]] = None,
):
"""Call the LLM service.
Takes a system prompt and a user prompt, and returns the LLM's response
Returns None if the call fails.
"""
try:
params: dict[str, Any] = {
"model": settings.llm_model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
}
if response_format is not None:
params["response_format"] = response_format
response = self._client.chat.completions.create(**params)
return response.choices[0].message.content
except Exception as e:
logger.exception("LLM call failed: %s", e)
raise LLMException("LLM call failed: {e}") from e
def format_actions(llm_output: dict) -> str:
"""Format the actions from the LLM output into a markdown list.
@@ -148,7 +106,9 @@ def format_actions(llm_output: dict) -> str:
def post_with_retries(url, data):
"""Send POST request with automatic retries."""
session = create_retry_session()
session.headers.update({"Authorization": f"Bearer {settings.webhook_api_token}"})
session.headers.update(
{"Authorization": f"Bearer {settings.webhook_api_token.get_secret_value()}"}
)
try:
response = session.post(url, json=data)
response.raise_for_status()
@@ -195,7 +155,7 @@ def process_audio_transcribe_summarize_v2(
minio_client = Minio(
settings.aws_s3_endpoint_url,
access_key=settings.aws_s3_access_key_id,
secret_key=settings.aws_s3_secret_access_key,
secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
secure=settings.aws_s3_secure_access,
)
@@ -226,7 +186,7 @@ def process_audio_transcribe_summarize_v2(
logger.info("Initiating WhisperX client")
whisperx_client = openai.OpenAI(
api_key=settings.whisperx_api_key,
api_key=settings.whisperx_api_key.get_secret_value(),
base_url=settings.whisperx_base_url,
max_retries=settings.whisperx_max_retries,
)
@@ -300,7 +260,7 @@ def process_audio_transcribe_summarize_v2(
):
logger.info("Queuing summary generation task.")
summarize_transcription.apply_async(
args=[content, email, sub, title],
args=[owner_id, content, email, sub, title],
queue=settings.summarize_queue,
)
else:
@@ -332,7 +292,9 @@ def task_failure_handler(task_id, exception=None, **kwargs):
max_retries=settings.celery_max_retries,
queue=settings.summarize_queue,
)
def summarize_transcription(self, transcript: str, email: str, sub: str, title: str):
def summarize_transcription(
self, owner_id: str, transcript: str, email: str, sub: str, title: str
):
"""Generate a summary from the provided transcription text.
This Celery task performs the following operations:
@@ -342,16 +304,34 @@ def summarize_transcription(self, transcript: str, email: str, sub: str, title:
4. Generates next steps.
5. Sends the final summary via webhook.
"""
logger.info("Starting summarization task")
logger.info(
"Starting summarization task | Owner: %s",
owner_id,
)
llm_service = LLMService()
user_has_tracing_consent = analytics.is_feature_enabled(
"summary-tracing-consent", distinct_id=owner_id
)
tldr = llm_service.call(PROMPT_SYSTEM_TLDR, transcript)
# NOTE: We must instantiate a new LLMObservability client for each task invocation
# because the masking function needs to be user-specific. The masking function is
# baked into the Langfuse client at initialization time, so we can't reuse
# a singleton client. This is a performance trade-off we accept to ensure per-user
# privacy controls in observability traces.
llm_observability = LLMObservability(
logger=logger,
user_has_tracing_consent=user_has_tracing_consent,
session_id=self.request.id,
user_id=owner_id,
)
llm_service = LLMService(llm_observability=llm_observability, logger=logger)
tldr = llm_service.call(PROMPT_SYSTEM_TLDR, transcript, name="tldr")
logger.info("TLDR generated")
parts = llm_service.call(
PROMPT_SYSTEM_PLAN, transcript, response_format=FORMAT_PLAN
PROMPT_SYSTEM_PLAN, transcript, name="parts", response_format=FORMAT_PLAN
)
logger.info("Plan generated")
@@ -362,21 +342,28 @@ def summarize_transcription(self, transcript: str, email: str, sub: str, title:
for part in parts:
prompt_user_part = PROMPT_USER_PART.format(part=part, transcript=transcript)
logger.info("Summarizing part: %s", part)
parts_summarized.append(llm_service.call(PROMPT_SYSTEM_PART, prompt_user_part))
parts_summarized.append(
llm_service.call(PROMPT_SYSTEM_PART, prompt_user_part, name="part")
)
logger.info("Parts summarized")
raw_summary = "\n\n".join(parts_summarized)
next_steps = llm_service.call(
PROMPT_SYSTEM_NEXT_STEP, transcript, response_format=FORMAT_NEXT_STEPS
PROMPT_SYSTEM_NEXT_STEP,
transcript,
name="next-steps",
response_format=FORMAT_NEXT_STEPS,
)
next_steps = format_actions(json.loads(next_steps))
logger.info("Next steps generated")
cleaned_summary = llm_service.call(PROMPT_SYSTEM_CLEANING, raw_summary)
cleaned_summary = llm_service.call(
PROMPT_SYSTEM_CLEANING, raw_summary, name="cleaning"
)
logger.info("Summary cleaned")
summary = tldr + "\n\n" + cleaned_summary + "\n\n" + next_steps
@@ -396,3 +383,6 @@ def summarize_transcription(self, transcript: str, email: str, sub: str, title:
logger.info("Webhook submitted successfully. Status: %s", response.status_code)
logger.debug("Response body: %s", response.text)
llm_observability.flush()
logger.debug("LLM observability flushed")
+13 -5
View File
@@ -4,6 +4,7 @@ from functools import lru_cache
from typing import Annotated, List, Optional
from fastapi import Depends
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -14,7 +15,7 @@ class Settings(BaseSettings):
app_name: str = "app"
app_api_v1_str: str = "/api/v1"
app_api_token: str
app_api_token: SecretStr
# Audio recordings
recording_max_duration: Optional[int] = None
@@ -31,18 +32,18 @@ class Settings(BaseSettings):
aws_storage_bucket_name: str
aws_s3_endpoint_url: str
aws_s3_access_key_id: str
aws_s3_secret_access_key: str
aws_s3_secret_access_key: SecretStr
aws_s3_secure_access: bool = True
# AI-related settings
whisperx_api_key: str
whisperx_api_key: SecretStr
whisperx_base_url: str = "https://api.openai.com/v1"
whisperx_asr_model: str = "whisper-1"
whisperx_max_retries: int = 0
# ISO 639-1 language code (e.g., "en", "fr", "es")
whisperx_default_language: Optional[str] = None
llm_base_url: str
llm_api_key: str
llm_api_key: SecretStr
llm_model: str
# Transcription processing
@@ -53,7 +54,7 @@ class Settings(BaseSettings):
webhook_max_retries: int = 2
webhook_status_forcelist: List[int] = [502, 503, 504]
webhook_backoff_factor: float = 0.1
webhook_api_token: str
webhook_api_token: SecretStr
webhook_url: str
# Output related settings
@@ -77,6 +78,13 @@ class Settings(BaseSettings):
posthog_event_failure: str = "transcript-failure"
posthog_event_success: str = "transcript-success"
# Langfuse (LLM Observability)
langfuse_enabled: bool = False
langfuse_host: Optional[str] = None
langfuse_public_key: Optional[str] = None
langfuse_secret_key: Optional[SecretStr] = None
langfuse_environment: Optional[str] = "development"
# TaskTracker
task_tracker_redis_url: str = "redis://redis/0"
task_tracker_prefix: str = "task_metadata:"
+144
View File
@@ -0,0 +1,144 @@
"""LLM service to encapsulate LLM's calls."""
from typing import Any, Mapping, Optional
import openai
from langfuse import Langfuse
from summary.core.config import get_settings
settings = get_settings()
class LLMObservability:
"""Manage observability and tracing for LLM calls using Langfuse.
Handles the initialization and configuration of the Langfuse client with
per-user masking rules to enforce privacy controls based on tracing consent.
Also provides an OpenAI client wrapper that integrates with Langfuse tracing
when observability is enabled.
"""
def __init__(
self,
logger,
session_id: str,
user_id: str,
user_has_tracing_consent: bool = False,
):
"""Initialize the LLMObservability client."""
self._logger = logger
self._observability_client: Optional[Langfuse] = None
self.session_id = session_id
self.user_id = user_id
if settings.langfuse_enabled:
def masking_function(data, **kwargs):
if (
user_has_tracing_consent
or settings.langfuse_environment != "production"
):
return data
return "[REDACTED]"
if not settings.langfuse_secret_key:
raise ValueError(
"langfuse_secret_key is not configured. "
"Please set the secret key or disable Langfuse."
)
self._observability_client = Langfuse(
secret_key=settings.langfuse_secret_key.get_secret_value(),
public_key=settings.langfuse_public_key,
host=settings.langfuse_host,
environment=settings.langfuse_environment,
mask=masking_function,
)
@property
def is_enabled(self):
"""Check if observability is enabled."""
return self._observability_client is not None
def get_openai_client(self):
"""Get an OpenAI client configured for observability.
Returns a regular OpenAI client if observability is disabled, or a
Langfuse-wrapped OpenAI client that automatically traces all API calls
to Langfuse for observability when enabled.
"""
base_args = {
"base_url": settings.llm_base_url,
"api_key": settings.llm_api_key.get_secret_value(),
}
if not self.is_enabled:
self._logger.debug("Using regular OpenAI client (observability disabled)")
return openai.OpenAI(**base_args)
# Langfuse's OpenAI wrapper is imported here to avoid triggering client
# init at module load, which would log a warning if LANGFUSE_PUBLIC_KEY
# is missing. Conditional import ensures Langfuse only initializes when enabled.
from langfuse.openai import openai as langfuse_openai # noqa: PLC0415
self._logger.debug("Using LangfuseOpenAI client (observability enabled)")
return langfuse_openai.OpenAI(**base_args)
def flush(self):
"""Flush pending observability traces to Langfuse."""
if self.is_enabled:
self._observability_client.flush()
class LLMException(Exception):
"""LLM call failed."""
class LLMService:
"""Service for performing calls to the LLM configured in the settings."""
def __init__(self, llm_observability, logger):
"""Init the LLMService once."""
self._client = llm_observability.get_openai_client()
self._observability = llm_observability
self._logger = logger
def call(
self,
system_prompt: str,
user_prompt: str,
name: str,
response_format: Optional[Mapping[str, Any]] = None,
):
"""Call the LLM service.
Takes a system prompt and a user prompt, and returns the LLM's response
Returns None if the call fails.
"""
try:
params: dict[str, Any] = {
"model": settings.llm_model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
}
if response_format is not None:
params["response_format"] = response_format
if self._observability.is_enabled:
params["name"] = name
params["metadata"] = {
"user_id": self._observability.user_id,
"langfuse_tags": ["summary"],
"langfuse_session_id": self._observability.session_id,
}
response = self._client.chat.completions.create(**params)
return response.choices[0].message.content
except Exception as e:
self._logger.exception("LLM call failed: %s", e)
raise LLMException(f"LLM call failed: {e}") from e
+1 -1
View File
@@ -14,6 +14,6 @@ def verify_token(
):
"""Verify the bearer token from the Authorization header."""
token = credentials.credentials
if token != settings.app_api_token:
if token != settings.app_api_token.get_secret_value():
raise HTTPException(status_code=401, detail="Invalid token")
return token