Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1282440c37 | |||
| 602bcf3185 | |||
| f5e0ddf692 | |||
| cd0cec78ba | |||
| e647787170 | |||
| d76b4c9b9f | |||
| 09c7edecb8 | |||
| f625df6508 | |||
| ac87980a27 | |||
| 7cab46dc29 | |||
| 259b739160 | |||
| 6f77559633 |
@@ -11,6 +11,7 @@ and this project adheres to
|
||||
### Added
|
||||
|
||||
- 👷(docker) add arm64 platform support for image builds
|
||||
- ✨(summary) add localization support for transcription context text
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -20,6 +21,13 @@ and this project adheres to
|
||||
- ✨(frontend) introduce a shortcut settings tab #975
|
||||
- 🚚(frontend) rename "wellknown" directory to "well-known" #1009
|
||||
- 🌐(frontend) localize SR modifier labels #1010
|
||||
- ⬆️(backend) update python dependencies #1011
|
||||
- ♿️(a11y) fix focus ring on tab container components
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🩹(frontend) fix German language preference update #1021
|
||||
|
||||
|
||||
## [1.8.0] - 2026-02-20
|
||||
|
||||
@@ -44,6 +52,7 @@ and this project adheres to
|
||||
### Changed
|
||||
|
||||
- ✨(frontend) add clickable settings general link in idle modal #974
|
||||
- ♻️(backend) refactor external API token-related items #1006
|
||||
|
||||
## [1.6.0] - 2026-02-10
|
||||
|
||||
|
||||
@@ -23,9 +23,10 @@
|
||||
# ==============================================================================
|
||||
# VARIABLES
|
||||
|
||||
BOLD := \033[1m
|
||||
RESET := \033[0m
|
||||
GREEN := \033[1;32m
|
||||
ESC := $(shell printf '\033')
|
||||
BOLD := $(ESC)[1m
|
||||
RESET := $(ESC)[0m
|
||||
GREEN := $(ESC)[1;32m
|
||||
|
||||
|
||||
# -- Database
|
||||
@@ -85,7 +86,8 @@ bootstrap: \
|
||||
demo \
|
||||
back-i18n-compile \
|
||||
mails-install \
|
||||
mails-build
|
||||
mails-build \
|
||||
run
|
||||
.PHONY: bootstrap
|
||||
|
||||
# -- Docker/compose
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Gitlint extra rule to validate that the message title is of the form
|
||||
"<gitmoji>(<scope>) <subject>"
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import re
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
"dependencyDashboard": true,
|
||||
"labels": ["dependencies", "noChangeLog"],
|
||||
"packageRules": [
|
||||
{
|
||||
"groupName": "js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"schedule": ["on the first day of the month"],
|
||||
"matchPackagePatterns": ["*"],
|
||||
"minimumReleaseAge": "7 days",
|
||||
"internalChecksFilter": "strict"
|
||||
},
|
||||
{
|
||||
"groupName": "python dependencies",
|
||||
"matchManagers": ["setup-cfg", "pep621"],
|
||||
"schedule": ["on the first day of the month"],
|
||||
"matchPackagePatterns": ["*"],
|
||||
"minimumReleaseAge": "7 days"
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"groupName": "ignored python dependencies",
|
||||
@@ -15,6 +30,12 @@
|
||||
"matchPackageNames": ["pylint"],
|
||||
"allowedVersions": "<4.0.0"
|
||||
},
|
||||
{
|
||||
"groupName": "allowed django versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["django"],
|
||||
"allowedVersions": "<6.0.0"
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
|
||||
@@ -1,27 +1,51 @@
|
||||
"""Authentication Backends for external application to the Meet core app."""
|
||||
|
||||
# pylint: disable=R0913,R0917
|
||||
# ruff: noqa: PLR0913
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
import jwt as pyJwt
|
||||
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
|
||||
from rest_framework import authentication, exceptions
|
||||
|
||||
from core.models import Application
|
||||
from core.services import jwt_token
|
||||
|
||||
User = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
||||
"""JWT authentication for application-delegated API access.
|
||||
class BaseJWTAuthentication(authentication.BaseAuthentication):
|
||||
"""Base JWT authentication class."""
|
||||
|
||||
Validates JWT tokens issued to applications that are acting on behalf
|
||||
of users. Tokens must include user_id, client_id, and delegation flag.
|
||||
"""
|
||||
def __init__(
|
||||
self, secret_key, algorithm, issuer, audience, expiration_seconds, token_type
|
||||
):
|
||||
"""Initialize the JWT authentication backend with the given token service configuration.
|
||||
|
||||
Args:
|
||||
secret_key: Secret key for JWT encoding/decoding
|
||||
algorithm: JWT algorithm (e.g. HS256)
|
||||
issuer: Expected token issuer identifier
|
||||
audience: Expected token audience identifier
|
||||
expiration_seconds: Token expiration time in seconds
|
||||
token_type: Token type (e.g. Bearer)
|
||||
"""
|
||||
|
||||
super().__init__()
|
||||
|
||||
self._token_service = jwt_token.JwtTokenService(
|
||||
secret_key=secret_key,
|
||||
algorithm=algorithm,
|
||||
issuer=issuer,
|
||||
audience=audience,
|
||||
expiration_seconds=expiration_seconds,
|
||||
token_type=token_type,
|
||||
)
|
||||
|
||||
def authenticate(self, request):
|
||||
"""Extract and validate JWT from Authorization header.
|
||||
@@ -48,6 +72,78 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
||||
|
||||
return self.authenticate_credentials(token)
|
||||
|
||||
def decode_jwt(self, token):
|
||||
"""Decode and validate JWT token.
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
|
||||
Returns:
|
||||
Decoded payload dict, or None if token is invalid
|
||||
|
||||
Raises:
|
||||
AuthenticationFailed: If token is expired or has invalid issuer/audience
|
||||
"""
|
||||
|
||||
try:
|
||||
payload = self._token_service.decode_jwt(token)
|
||||
return payload
|
||||
except jwt_token.TokenExpiredError as e:
|
||||
logger.warning("Token expired")
|
||||
raise exceptions.AuthenticationFailed("Token expired.") from e
|
||||
except jwt_token.TokenInvalidError as e:
|
||||
logger.warning("Invalid JWT issuer or audience: %s", e)
|
||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||
except jwt_token.TokenDecodeError:
|
||||
# Invalid JWT token - defer to next authentication backend
|
||||
return None
|
||||
|
||||
def validate_payload(self, payload):
|
||||
"""Validate JWT payload claims.
|
||||
|
||||
Override in subclasses to add custom validation.
|
||||
|
||||
Args:
|
||||
payload: Decoded JWT payload
|
||||
|
||||
Raises:
|
||||
AuthenticationFailed: If required claims are missing or invalid
|
||||
"""
|
||||
|
||||
def get_user(self, payload):
|
||||
"""Retrieve and validate user from payload.
|
||||
|
||||
Args:
|
||||
payload: Decoded JWT payload
|
||||
|
||||
Returns:
|
||||
User instance
|
||||
|
||||
Raises:
|
||||
AuthenticationFailed: If user not found or inactive
|
||||
"""
|
||||
user_id = payload.get("user_id")
|
||||
|
||||
if not user_id:
|
||||
logger.warning("Missing 'user_id' in JWT payload")
|
||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist as e:
|
||||
logger.warning("User not found: %s", user_id)
|
||||
raise exceptions.AuthenticationFailed("User not found.") from e
|
||||
|
||||
if not user.is_active:
|
||||
logger.warning("Inactive user attempted authentication: %s", user_id)
|
||||
raise exceptions.AuthenticationFailed("User account is disabled.")
|
||||
|
||||
return user
|
||||
|
||||
def authenticate_header(self, request):
|
||||
"""Return authentication scheme for WWW-Authenticate header."""
|
||||
return "Bearer"
|
||||
|
||||
def authenticate_credentials(self, token):
|
||||
"""Validate JWT token and return authenticated user.
|
||||
|
||||
@@ -62,36 +158,41 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
||||
Raises:
|
||||
AuthenticationFailed: If token is expired, or user not found
|
||||
"""
|
||||
# Decode and validate JWT
|
||||
try:
|
||||
payload = pyJwt.decode(
|
||||
token,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithms=[settings.APPLICATION_JWT_ALG],
|
||||
issuer=settings.APPLICATION_JWT_ISSUER,
|
||||
audience=settings.APPLICATION_JWT_AUDIENCE,
|
||||
)
|
||||
except pyJwt.ExpiredSignatureError as e:
|
||||
logger.warning("Token expired")
|
||||
raise exceptions.AuthenticationFailed("Token expired.") from e
|
||||
except pyJwt.InvalidIssuerError as e:
|
||||
logger.warning("Invalid JWT issuer: %s", e)
|
||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||
except pyJwt.InvalidAudienceError as e:
|
||||
logger.warning("Invalid JWT audience: %s", e)
|
||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||
except pyJwt.InvalidTokenError:
|
||||
# Invalid JWT token - defer to next authentication backend
|
||||
|
||||
payload = self.decode_jwt(token)
|
||||
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
user_id = payload.get("user_id")
|
||||
self.validate_payload(payload)
|
||||
user = self.get_user(payload)
|
||||
|
||||
return (user, payload)
|
||||
|
||||
|
||||
class ApplicationJWTAuthentication(BaseJWTAuthentication):
|
||||
"""JWT authentication for application-delegated API access.
|
||||
|
||||
Validates JWT tokens issued to applications that are acting on behalf
|
||||
of users. Tokens must include user_id, client_id, and delegation flag.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize authentication backend with application JWT settings from Django settings."""
|
||||
super().__init__(
|
||||
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
issuer=settings.APPLICATION_JWT_ISSUER,
|
||||
audience=settings.APPLICATION_JWT_AUDIENCE,
|
||||
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
||||
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
|
||||
)
|
||||
|
||||
def validate_payload(self, payload):
|
||||
"""Validate application-specific claims."""
|
||||
client_id = payload.get("client_id")
|
||||
is_delegated = payload.get("delegated", False)
|
||||
|
||||
if not user_id:
|
||||
logger.warning("Missing 'user_id' in JWT payload")
|
||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||
|
||||
if not client_id:
|
||||
logger.warning("Missing 'client_id' in JWT payload")
|
||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||
@@ -112,22 +213,6 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
||||
logger.warning("Token is not marked as delegated")
|
||||
raise exceptions.AuthenticationFailed("Invalid token type.")
|
||||
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist as e:
|
||||
logger.warning("User not found: %s", user_id)
|
||||
raise exceptions.AuthenticationFailed("User not found.") from e
|
||||
|
||||
if not user.is_active:
|
||||
logger.warning("Inactive user attempted authentication: %s", user_id)
|
||||
raise exceptions.AuthenticationFailed("User account is disabled.")
|
||||
|
||||
return (user, payload)
|
||||
|
||||
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."""
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""External API endpoints"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
@@ -8,7 +7,6 @@ from django.contrib.auth.hashers import check_password
|
||||
from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||
from django.core.validators import validate_email
|
||||
|
||||
import jwt
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
from rest_framework import decorators, mixins, viewsets
|
||||
from rest_framework import (
|
||||
@@ -22,6 +20,7 @@ from rest_framework import (
|
||||
)
|
||||
|
||||
from core import api, models
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
|
||||
from . import authentication, permissions, serializers
|
||||
|
||||
@@ -128,33 +127,28 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
"Multiple user accounts share a common email."
|
||||
) from e
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
scope = " ".join(application.scopes or [])
|
||||
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
|
||||
"client_id": client_id,
|
||||
"scope": scope,
|
||||
"user_id": str(user.id),
|
||||
"delegated": True,
|
||||
}
|
||||
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
token_service = JwtTokenService(
|
||||
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
issuer=settings.APPLICATION_JWT_ISSUER,
|
||||
audience=settings.APPLICATION_JWT_AUDIENCE,
|
||||
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
||||
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
|
||||
)
|
||||
|
||||
data = token_service.generate_jwt(
|
||||
user,
|
||||
scope,
|
||||
{
|
||||
"client_id": client_id,
|
||||
"delegated": True,
|
||||
},
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{
|
||||
"access_token": token,
|
||||
"token_type": settings.APPLICATION_JWT_TOKEN_TYPE,
|
||||
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
||||
"scope": scope,
|
||||
},
|
||||
data,
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
@@ -167,6 +167,7 @@ class NotificationService:
|
||||
owner_access.user.timezone
|
||||
).strftime("%H:%M"),
|
||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
"context_language": owner_access.user.language,
|
||||
}
|
||||
|
||||
headers = {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""JWT token service."""
|
||||
|
||||
# pylint: disable=R0913,R0917
|
||||
# ruff: noqa: PLR0913
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
import jwt
|
||||
|
||||
|
||||
class JWTError(Exception):
|
||||
"""Base exception for all JWT token errors."""
|
||||
|
||||
|
||||
class TokenExpiredError(JWTError):
|
||||
"""Raised when the JWT token has expired."""
|
||||
|
||||
|
||||
class TokenInvalidError(JWTError):
|
||||
"""Raised when the JWT token has an invalid issuer or audience."""
|
||||
|
||||
|
||||
class TokenDecodeError(JWTError):
|
||||
"""Raised for any other unrecoverable JWT decode failure."""
|
||||
|
||||
|
||||
class JwtTokenService:
|
||||
"""Generic JWT token service with configurable settings."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
secret_key: str,
|
||||
algorithm: str,
|
||||
issuer: str,
|
||||
audience: str,
|
||||
expiration_seconds: int,
|
||||
token_type: str,
|
||||
):
|
||||
"""
|
||||
Initialize the token service with custom settings.
|
||||
|
||||
Args:
|
||||
secret_key: Secret key for JWT encoding/decoding
|
||||
algorithm: JWT algorithm
|
||||
issuer: Token issuer identifier
|
||||
audience: Token audience identifier
|
||||
expiration_seconds: Token expiration time in seconds
|
||||
token_type: Token type
|
||||
|
||||
Raises:
|
||||
ImproperlyConfigured: If secret_key is None or empty
|
||||
"""
|
||||
if not secret_key:
|
||||
raise ImproperlyConfigured("Secret key is required.")
|
||||
if not algorithm:
|
||||
raise ImproperlyConfigured("Algorithm is required.")
|
||||
if not token_type:
|
||||
raise ImproperlyConfigured("Token's type is required.")
|
||||
if expiration_seconds is None:
|
||||
raise ImproperlyConfigured("Expiration's seconds is required.")
|
||||
|
||||
self._key = secret_key
|
||||
self._algorithm = algorithm
|
||||
self._issuer = issuer
|
||||
self._audience = audience
|
||||
self._expiration_seconds = expiration_seconds
|
||||
self._token_type = token_type
|
||||
|
||||
def generate_jwt(
|
||||
self, user, scope: str, extra_payload: Optional[dict] = None
|
||||
) -> dict:
|
||||
"""
|
||||
Generate an access token for the given user.
|
||||
|
||||
Note: any extra_payload variables named iat, exp, or user_id will
|
||||
be overwritten by this service
|
||||
|
||||
Args:
|
||||
user: User instance for whom to generate the token
|
||||
scope: Space-separated scope string
|
||||
|
||||
Returns:
|
||||
Dictionary containing access_token, token_type, expires_in, and scope optionally
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
payload = extra_payload.copy() if extra_payload else {}
|
||||
|
||||
payload.update(
|
||||
{
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=self._expiration_seconds),
|
||||
"user_id": str(user.id),
|
||||
}
|
||||
)
|
||||
|
||||
if self._issuer:
|
||||
payload["iss"] = self._issuer
|
||||
if self._audience:
|
||||
payload["aud"] = self._audience
|
||||
if scope:
|
||||
payload["scope"] = scope
|
||||
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
self._key,
|
||||
algorithm=self._algorithm,
|
||||
)
|
||||
|
||||
response = {
|
||||
"access_token": token,
|
||||
"token_type": self._token_type,
|
||||
"expires_in": self._expiration_seconds,
|
||||
}
|
||||
|
||||
if scope:
|
||||
response["scope"] = scope
|
||||
|
||||
return response
|
||||
|
||||
def decode_jwt(self, token):
|
||||
"""Decode and validate JWT token.
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
|
||||
Returns:
|
||||
Decoded payload dict.
|
||||
|
||||
Raises:
|
||||
TokenExpiredError: If the token has expired.
|
||||
TokenInvalidError: If the token has an invalid issuer or audience.
|
||||
TokenDecodeError: If the token is malformed or cannot be decoded.
|
||||
"""
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
self._key,
|
||||
algorithms=[self._algorithm],
|
||||
issuer=self._issuer,
|
||||
audience=self._audience,
|
||||
)
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError as e:
|
||||
raise TokenExpiredError("Token expired.") from e
|
||||
except (jwt.InvalidIssuerError, jwt.InvalidAudienceError) as e:
|
||||
raise TokenInvalidError("Invalid token.") from e
|
||||
except jwt.InvalidTokenError as e:
|
||||
raise TokenDecodeError("Token decode error.") from e
|
||||
@@ -55,9 +55,8 @@ def test_api_rooms_list_requires_authentication():
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_rooms_list_inactive_user(settings):
|
||||
def test_api_rooms_list_inactive_user():
|
||||
"""List should return 401 if user is inactive."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user1 = UserFactory(is_active=False)
|
||||
RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
||||
@@ -72,11 +71,9 @@ def test_api_rooms_list_inactive_user(settings):
|
||||
assert "user account is disabled" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_list_with_valid_token(settings):
|
||||
def test_api_rooms_list_with_valid_token():
|
||||
"""Listing rooms with valid token should succeed."""
|
||||
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
@@ -92,9 +89,8 @@ def test_api_rooms_list_with_valid_token(settings):
|
||||
assert response.data["results"][0]["id"] == str(room.id)
|
||||
|
||||
|
||||
def test_api_rooms_list_with_no_rooms(settings):
|
||||
def test_api_rooms_list_with_no_rooms():
|
||||
"""Listing rooms with a valid token returns an empty list when there are no rooms."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
@@ -112,7 +108,6 @@ def test_api_rooms_list_with_no_rooms(settings):
|
||||
|
||||
def test_api_rooms_list_with_expired_token(settings):
|
||||
"""Listing rooms with expired token should return 401."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
|
||||
|
||||
user = UserFactory()
|
||||
@@ -153,9 +148,8 @@ def test_api_rooms_list_with_invalid_rs_token(settings):
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_api_rooms_list_missing_scope(settings):
|
||||
def test_api_rooms_list_missing_scope():
|
||||
"""Listing rooms without required scope should return 403."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
@@ -173,9 +167,8 @@ def test_api_rooms_list_missing_scope(settings):
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_list_no_scope(settings):
|
||||
def test_api_rooms_list_no_scope():
|
||||
"""Listing rooms without any scope should return 403."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
@@ -190,9 +183,8 @@ def test_api_rooms_list_no_scope(settings):
|
||||
assert "insufficient permissions." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_list_filters_by_user(settings):
|
||||
def test_api_rooms_list_filters_by_user():
|
||||
"""List should only return rooms accessible to the authenticated user."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user1 = UserFactory()
|
||||
user2 = UserFactory()
|
||||
@@ -217,11 +209,9 @@ def test_api_rooms_list_filters_by_user(settings):
|
||||
assert str(room2.id) not in returned_ids
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_requires_authentication(settings):
|
||||
def test_api_rooms_retrieve_requires_authentication():
|
||||
"""Retrieving rooms without authentication should return 401."""
|
||||
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user1 = UserFactory()
|
||||
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
||||
|
||||
@@ -231,9 +221,8 @@ def test_api_rooms_retrieve_requires_authentication(settings):
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_inactive_user(settings):
|
||||
def test_api_rooms_retrieve_inactive_user():
|
||||
"""Retrieve should return 401 if user is inactive."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user1 = UserFactory(is_active=False)
|
||||
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
||||
@@ -250,7 +239,6 @@ def test_api_rooms_retrieve_inactive_user(settings):
|
||||
|
||||
def test_api_rooms_retrieve_with_expired_token(settings):
|
||||
"""Retrieving rooms with expired token should return 401."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
|
||||
|
||||
user = UserFactory()
|
||||
@@ -295,9 +283,8 @@ def test_api_rooms_retrieve_with_invalid_rs_token(settings):
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_requires_scope(settings):
|
||||
def test_api_rooms_retrieve_requires_scope():
|
||||
"""Retrieving a room requires ROOMS_RETRIEVE scope."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
@@ -315,9 +302,8 @@ def test_api_rooms_retrieve_requires_scope(settings):
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_no_scope(settings):
|
||||
def test_api_rooms_retrieve_no_scope():
|
||||
"""Retrieving rooms without any scope should return 403."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
@@ -335,7 +321,6 @@ def test_api_rooms_retrieve_no_scope(settings):
|
||||
|
||||
def test_api_rooms_retrieve_success(settings):
|
||||
"""Retrieving a room with correct scope should succeed."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.APPLICATION_BASE_URL = "http://your-application.com"
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
settings.ROOM_TELEPHONY_PHONE_NUMBER = "+1-555-0100"
|
||||
@@ -367,9 +352,8 @@ def test_api_rooms_retrieve_success(settings):
|
||||
}
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_success_by_user(settings):
|
||||
def test_api_rooms_retrieve_success_by_user():
|
||||
"""Retrieve should only return rooms accessible to the authenticated user."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user1 = UserFactory()
|
||||
user2 = UserFactory()
|
||||
@@ -408,9 +392,8 @@ def test_api_rooms_retrieve_success_by_user(settings):
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_not_found(settings):
|
||||
def test_api_rooms_retrieve_not_found():
|
||||
"""Retrieving a non-existing room with correct scope should return a 404."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||
@@ -423,11 +406,9 @@ def test_api_rooms_retrieve_not_found(settings):
|
||||
assert "no room matches the given query." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_create_requires_authentication(settings):
|
||||
def test_api_rooms_create_requires_authentication():
|
||||
"""Creating rooms without authentication should return 401."""
|
||||
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
client = APIClient()
|
||||
response = client.post("/external-api/v1.0/rooms/")
|
||||
|
||||
@@ -436,7 +417,6 @@ def test_api_rooms_create_requires_authentication(settings):
|
||||
|
||||
def test_api_rooms_create_with_expired_token(settings):
|
||||
"""Creating rooms with expired token should return 401."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
|
||||
|
||||
user = UserFactory()
|
||||
@@ -477,9 +457,8 @@ def test_api_rooms_create_with_invalid_rs_token(settings):
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_api_rooms_create_inactive_user(settings):
|
||||
def test_api_rooms_create_inactive_user():
|
||||
"""Create should return 401 if user is inactive."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user1 = UserFactory(is_active=False)
|
||||
|
||||
@@ -493,9 +472,8 @@ def test_api_rooms_create_inactive_user(settings):
|
||||
assert "user account is disabled" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_create_requires_scope(settings):
|
||||
def test_api_rooms_create_requires_scope():
|
||||
"""Creating a room requires ROOMS_CREATE scope."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
|
||||
# Token without ROOMS_CREATE scope
|
||||
@@ -512,9 +490,8 @@ def test_api_rooms_create_requires_scope(settings):
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_create_no_scope(settings):
|
||||
def test_api_rooms_create_no_scope():
|
||||
"""Creating rooms without any scope should return 403."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
@@ -529,9 +506,8 @@ def test_api_rooms_create_no_scope(settings):
|
||||
assert "insufficient permissions." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_create_success(settings):
|
||||
def test_api_rooms_create_success():
|
||||
"""Creating a room with correct scope should succeed."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
@@ -555,9 +531,8 @@ def test_api_rooms_create_success(settings):
|
||||
assert room.access_level == "trusted"
|
||||
|
||||
|
||||
def test_api_rooms_create_readonly_enforcement(settings):
|
||||
def test_api_rooms_create_readonly_enforcement():
|
||||
"""Creating a room succeeds and any provided read-only fields are ignored."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
@@ -590,9 +565,8 @@ def test_api_rooms_create_readonly_enforcement(settings):
|
||||
assert room.access_level == "trusted"
|
||||
|
||||
|
||||
def test_api_rooms_unknown_actions(settings):
|
||||
def test_api_rooms_unknown_actions():
|
||||
"""Updating or deleting a room are not supported yet."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
@@ -623,7 +597,6 @@ def test_api_rooms_unknown_actions(settings):
|
||||
|
||||
def test_api_rooms_response_no_url(settings):
|
||||
"""Response should not include url field when APPLICATION_BASE_URL is None."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.APPLICATION_BASE_URL = None
|
||||
|
||||
user = UserFactory()
|
||||
@@ -642,7 +615,6 @@ def test_api_rooms_response_no_url(settings):
|
||||
|
||||
def test_api_rooms_response_no_telephony(settings):
|
||||
"""Response should not include telephony field when ROOM_TELEPHONY_ENABLED is False."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
@@ -661,7 +633,6 @@ def test_api_rooms_response_no_telephony(settings):
|
||||
|
||||
def test_api_rooms_token_scope_case_insensitive(settings):
|
||||
"""Token's scope should be case-insensitive."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
application = ApplicationFactory()
|
||||
|
||||
@@ -693,7 +664,6 @@ def test_api_rooms_token_scope_case_insensitive(settings):
|
||||
|
||||
def test_api_rooms_token_without_delegated_flag(settings):
|
||||
"""Token without delegated flag should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
application = ApplicationFactory()
|
||||
|
||||
@@ -726,7 +696,6 @@ def test_api_rooms_token_without_delegated_flag(settings):
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
|
||||
"""Token signed with an invalid key should defer to the next authentication."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
application = ApplicationFactory()
|
||||
|
||||
@@ -759,7 +728,6 @@ def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_api_rooms_token_invalid_alg(mock_rs_authenticate, settings):
|
||||
"""Token signed with an invalid alg should defer to the next authentication."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.APPLICATION_JWT_ALG = "RS256"
|
||||
user = UserFactory()
|
||||
|
||||
@@ -791,7 +759,6 @@ def test_api_rooms_token_invalid_alg(mock_rs_authenticate, settings):
|
||||
|
||||
def test_api_rooms_token_missing_client_id(settings):
|
||||
"""Token without client_id should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -821,7 +788,6 @@ def test_api_rooms_token_missing_client_id(settings):
|
||||
|
||||
def test_api_rooms_token_missing_user_id(settings):
|
||||
"""Token without user_id should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
application = ApplicationFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -851,7 +817,6 @@ def test_api_rooms_token_missing_user_id(settings):
|
||||
|
||||
def test_api_rooms_token_invalid_audience(settings):
|
||||
"""Token with an invalid audience should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
application = ApplicationFactory()
|
||||
|
||||
@@ -882,7 +847,6 @@ def test_api_rooms_token_invalid_audience(settings):
|
||||
|
||||
def test_api_rooms_token_unknown_user(settings):
|
||||
"""Token for unknown user should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
application = ApplicationFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -912,7 +876,6 @@ def test_api_rooms_token_unknown_user(settings):
|
||||
|
||||
def test_api_rooms_token_unknown_application(settings):
|
||||
"""Token for unknown application should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
@@ -941,7 +904,6 @@ def test_api_rooms_token_unknown_application(settings):
|
||||
|
||||
def test_api_rooms_token_inactive_application(settings):
|
||||
"""Token for inactive application should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
application = ApplicationFactory(active=False)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
@@ -21,7 +21,6 @@ pytestmark = pytest.mark.django_db
|
||||
|
||||
def test_api_applications_generate_token_success(settings):
|
||||
"""Valid credentials should return a JWT token."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
UserFactory(email="User.Family@example.com")
|
||||
application = ApplicationFactory(
|
||||
active=True,
|
||||
@@ -173,9 +172,8 @@ def test_api_applications_generate_token_domain_not_authorized():
|
||||
assert "not authorized for this email domain" in str(response.data)
|
||||
|
||||
|
||||
def test_api_applications_generate_token_domain_authorized(settings):
|
||||
def test_api_applications_generate_token_domain_authorized():
|
||||
"""Application with domain authorization should succeed."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory(email="user@allowed.com")
|
||||
application = ApplicationFactory(
|
||||
active=True,
|
||||
@@ -230,7 +228,6 @@ def test_api_applications_generate_token_user_not_found():
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
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(
|
||||
@@ -280,7 +277,6 @@ def test_api_applications_token_payload_structure(settings):
|
||||
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
|
||||
@@ -337,7 +333,6 @@ def test_api_applications_token_new_user(settings):
|
||||
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
|
||||
|
||||
@@ -667,7 +667,7 @@ class Base(Configuration):
|
||||
[],
|
||||
environ_name="BREVO_API_CONTACT_LIST_IDS",
|
||||
environ_prefix=None,
|
||||
converter=lambda x: int(x), # pylint: disable=unnecessary-lambda
|
||||
converter=int,
|
||||
)
|
||||
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
|
||||
BREVO_API_TIMEOUT = values.PositiveIntegerValue(
|
||||
@@ -917,6 +917,9 @@ class Test(Base):
|
||||
USE_SWAGGER = True
|
||||
EXTERNAL_API_ENABLED = True
|
||||
|
||||
APPLICATION_JWT_SECRET_KEY = "devKey" # noqa:S105
|
||||
APPLICATION_JWT_AUDIENCE = "Test inc."
|
||||
|
||||
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
|
||||
|
||||
def __init__(self):
|
||||
|
||||
+21
-21
@@ -25,15 +25,15 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.40.69",
|
||||
"boto3==1.42.49",
|
||||
"Brotli==1.2.0",
|
||||
"brevo-python==1.2.0",
|
||||
"celery[redis]==5.5.3",
|
||||
"celery[redis]==5.6.2",
|
||||
"dj-database-url==3.1.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==8.0.0",
|
||||
"django-lasuite[all]==0.0.19",
|
||||
"django-countries==8.2.0",
|
||||
"django-lasuite[all]==0.0.24",
|
||||
"django-parler==2.3",
|
||||
"redis==5.2.1",
|
||||
"django-redis==6.0.0",
|
||||
@@ -42,21 +42,21 @@ dependencies = [
|
||||
"django==5.2.11",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2024.4.2",
|
||||
"dockerflow==2026.1.26",
|
||||
"easy_thumbnails==2.10.1",
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==23.0.0",
|
||||
"jsonschema==4.25.1",
|
||||
"markdown==3.10",
|
||||
"gunicorn==25.1.0",
|
||||
"jsonschema==4.26.0",
|
||||
"markdown==3.10.2",
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"psycopg[binary]==3.2.12",
|
||||
"PyJWT==2.10.1",
|
||||
"psycopg[binary]==3.3.2",
|
||||
"PyJWT==2.11.0",
|
||||
"python-frontmatter==1.1.0",
|
||||
"requests==2.32.5",
|
||||
"sentry-sdk==2.43.0",
|
||||
"sentry-sdk==2.53.0",
|
||||
"whitenoise==6.11.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"livekit-api==1.0.7",
|
||||
"mozilla-django-oidc==5.0.2",
|
||||
"livekit-api==1.1.0",
|
||||
"aiohttp==3.13.3",
|
||||
]
|
||||
|
||||
@@ -69,21 +69,21 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"django-extensions==4.1",
|
||||
"drf-spectacular-sidecar==2025.10.1",
|
||||
"drf-spectacular-sidecar==2026.1.1",
|
||||
"freezegun==1.5.5",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==9.7.0",
|
||||
"pyfakefs==5.10.2",
|
||||
"pylint-django==2.6.1",
|
||||
"ipython==9.10.0",
|
||||
"pyfakefs==6.1.1",
|
||||
"pylint-django==2.7.0",
|
||||
"pylint<4.0.0",
|
||||
"pytest-cov==7.0.0",
|
||||
"pytest-django==4.11.1",
|
||||
"pytest==9.0.0",
|
||||
"pytest-django==4.12.0",
|
||||
"pytest==9.0.2",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.8.0",
|
||||
"responses==0.25.8",
|
||||
"ruff==0.14.4",
|
||||
"types-requests==2.32.4.20250913",
|
||||
"ruff==0.15.1",
|
||||
"types-requests==2.32.4.20260107",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -41,6 +41,7 @@ const tabListContainerStyle = css({
|
||||
flexDirection: 'column',
|
||||
borderRight: '1px solid lightGray', // fixme poor color management
|
||||
paddingY: '1rem',
|
||||
paddingLeft: '0.2rem',
|
||||
paddingRight: '1.5rem',
|
||||
})
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ const StyledTabs = styled(RACTabs, {
|
||||
flexDirection: 'row',
|
||||
'--vertical': '3px',
|
||||
},
|
||||
'&[data-focus-visible]': {
|
||||
outline: 'none!',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -102,6 +105,9 @@ const StyledTabList = styled(RACTabList, {
|
||||
'&[data-orientation=vertical]': {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
'&[data-focus-visible]': {
|
||||
outline: 'none!',
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
border: {
|
||||
@@ -147,9 +153,8 @@ const StyledTabPanel = styled(RACTabPanel, {
|
||||
base: {
|
||||
marginTop: '4px',
|
||||
borderRadius: '4px',
|
||||
outline: 'none',
|
||||
'&[data-focus-visible]': {
|
||||
outline: '2px solid red',
|
||||
outline: 'none!',
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// Map frontend language codes to backend language codes
|
||||
|
||||
export type BackendLanguage = 'en-us' | 'fr-fr' | 'nl-nl'
|
||||
export type FrontendLanguage = 'en' | 'fr' | 'nl'
|
||||
export type BackendLanguage = 'en-us' | 'fr-fr' | 'nl-nl' | 'de-de'
|
||||
export type FrontendLanguage = 'en' | 'fr' | 'nl' | 'de'
|
||||
|
||||
const frontendToBackendMap: Record<FrontendLanguage, BackendLanguage> = {
|
||||
en: 'en-us',
|
||||
fr: 'fr-fr',
|
||||
nl: 'nl-nl',
|
||||
de: 'de-de',
|
||||
}
|
||||
|
||||
export const convertToBackendLanguage = (
|
||||
|
||||
@@ -15,8 +15,8 @@ from summary.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class TaskCreation(BaseModel):
|
||||
"""Task data."""
|
||||
class TranscribeSummarizeTaskCreation(BaseModel):
|
||||
"""Transcription and summarization parameters."""
|
||||
|
||||
owner_id: str
|
||||
filename: str
|
||||
@@ -28,6 +28,7 @@ class TaskCreation(BaseModel):
|
||||
recording_time: Optional[str]
|
||||
language: Optional[str]
|
||||
download_link: Optional[str]
|
||||
context_language: Optional[str] = None
|
||||
|
||||
@field_validator("language")
|
||||
@classmethod
|
||||
@@ -45,8 +46,8 @@ router = APIRouter(prefix="/tasks")
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_task(request: TaskCreation):
|
||||
"""Create a task."""
|
||||
async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreation):
|
||||
"""Create a transcription and summarization task."""
|
||||
task = process_audio_transcribe_summarize_v2.apply_async(
|
||||
args=[
|
||||
request.owner_id,
|
||||
@@ -59,6 +60,7 @@ async def create_task(request: TaskCreation):
|
||||
request.recording_time,
|
||||
request.language,
|
||||
request.download_link,
|
||||
request.context_language,
|
||||
],
|
||||
queue=settings.transcribe_queue,
|
||||
)
|
||||
|
||||
@@ -112,19 +112,16 @@ class MetadataManager:
|
||||
if self._is_disabled or self.has_task_id(task_id):
|
||||
return
|
||||
|
||||
initial_metadata = {
|
||||
"start_time": time.time(),
|
||||
"asr_model": settings.whisperx_asr_model,
|
||||
"retries": 0,
|
||||
}
|
||||
|
||||
_, filename, email, _, received_at, *_ = task_args
|
||||
|
||||
start_time = time.time()
|
||||
initial_metadata = {
|
||||
**initial_metadata,
|
||||
"start_time": start_time,
|
||||
"asr_model": settings.whisperx_asr_model,
|
||||
"retries": 0,
|
||||
"filename": filename,
|
||||
"email": email,
|
||||
"queuing_time": round(initial_metadata["start_time"] - received_at, 2),
|
||||
"queuing_time": round(start_time - received_at, 2),
|
||||
}
|
||||
|
||||
self._save_metadata(task_id, initial_metadata)
|
||||
|
||||
@@ -10,14 +10,13 @@ import openai
|
||||
import sentry_sdk
|
||||
from celery import Celery, signals
|
||||
from celery.utils.log import get_task_logger
|
||||
from requests import Session, exceptions
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util import Retry
|
||||
from requests import exceptions
|
||||
|
||||
from summary.core.analytics import MetadataManager, get_analytics
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.file_service import FileService, FileServiceException
|
||||
from summary.core.llm_service import LLMException, LLMObservability, LLMService
|
||||
from summary.core.locales import get_locale
|
||||
from summary.core.prompt import (
|
||||
FORMAT_NEXT_STEPS,
|
||||
FORMAT_PLAN,
|
||||
@@ -29,6 +28,7 @@ from summary.core.prompt import (
|
||||
PROMPT_USER_PART,
|
||||
)
|
||||
from summary.core.transcript_formatter import TranscriptFormatter
|
||||
from summary.core.webhook_service import submit_content
|
||||
|
||||
settings = get_settings()
|
||||
analytics = get_analytics()
|
||||
@@ -55,89 +55,17 @@ if settings.sentry_dsn and settings.sentry_is_enabled:
|
||||
sentry_sdk.init(dsn=settings.sentry_dsn, enable_tracing=True)
|
||||
|
||||
|
||||
file_service = FileService(logger=logger)
|
||||
file_service = FileService()
|
||||
|
||||
|
||||
def create_retry_session():
|
||||
"""Create an HTTP session configured with retry logic."""
|
||||
session = Session()
|
||||
retries = Retry(
|
||||
total=settings.webhook_max_retries,
|
||||
backoff_factor=settings.webhook_backoff_factor,
|
||||
status_forcelist=settings.webhook_status_forcelist,
|
||||
allowed_methods={"POST"},
|
||||
)
|
||||
session.mount("https://", HTTPAdapter(max_retries=retries))
|
||||
return session
|
||||
def transcribe_audio(task_id, filename, language):
|
||||
"""Transcribe an audio file using WhisperX.
|
||||
|
||||
Downloads the audio from MinIO, sends it to WhisperX for transcription,
|
||||
and tracks metadata throughout the process.
|
||||
|
||||
def format_actions(llm_output: dict) -> str:
|
||||
"""Format the actions from the LLM output into a markdown list.
|
||||
|
||||
fomat:
|
||||
- [ ] Action title Assignée à : assignee1, assignee2, Échéance : due_date
|
||||
Returns the transcription object, or None if the file could not be retrieved.
|
||||
"""
|
||||
lines = []
|
||||
for action in llm_output.get("actions", []):
|
||||
title = action.get("title", "").strip()
|
||||
assignees = ", ".join(action.get("assignees", [])) or "-"
|
||||
due_date = action.get("due_date") or "-"
|
||||
line = f"- [ ] {title} Assignée à : {assignees}, Échéance : {due_date}"
|
||||
lines.append(line)
|
||||
if lines:
|
||||
return "### Prochaines étapes\n\n" + "\n".join(lines)
|
||||
return ""
|
||||
|
||||
|
||||
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.get_secret_value()}"}
|
||||
)
|
||||
try:
|
||||
response = session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@celery.task(
|
||||
bind=True,
|
||||
autoretry_for=[exceptions.HTTPError],
|
||||
max_retries=settings.celery_max_retries,
|
||||
queue=settings.transcribe_queue,
|
||||
)
|
||||
def process_audio_transcribe_summarize_v2(
|
||||
self,
|
||||
owner_id: str,
|
||||
filename: str,
|
||||
email: str,
|
||||
sub: str,
|
||||
received_at: float,
|
||||
room: Optional[str],
|
||||
recording_date: Optional[str],
|
||||
recording_time: Optional[str],
|
||||
language: Optional[str],
|
||||
download_link: Optional[str],
|
||||
):
|
||||
"""Process an audio file by transcribing it and generating a summary.
|
||||
|
||||
This Celery task performs the following operations:
|
||||
1. Retrieves the audio file from MinIO storage
|
||||
2. Transcribes the audio using WhisperX model
|
||||
3. Sends the results via webhook
|
||||
|
||||
"""
|
||||
logger.info(
|
||||
"Notification received | Owner: %s | Room: %s",
|
||||
owner_id,
|
||||
room,
|
||||
)
|
||||
|
||||
task_id = self.request.id
|
||||
|
||||
logger.info("Initiating WhisperX client")
|
||||
whisperx_client = openai.OpenAI(
|
||||
api_key=settings.whisperx_api_key.get_secret_value(),
|
||||
@@ -145,10 +73,9 @@ def process_audio_transcribe_summarize_v2(
|
||||
max_retries=settings.whisperx_max_retries,
|
||||
)
|
||||
|
||||
# Transcription
|
||||
try:
|
||||
with (
|
||||
file_service.prepare_audio_file(filename) as (audio_file, metadata),
|
||||
):
|
||||
with file_service.prepare_audio_file(filename) as (audio_file, metadata):
|
||||
metadata_manager.track(task_id, {"audio_length": metadata["duration"]})
|
||||
|
||||
if language is None:
|
||||
@@ -179,13 +106,32 @@ def process_audio_transcribe_summarize_v2(
|
||||
|
||||
except FileServiceException:
|
||||
logger.exception("Unexpected error for filename: %s", filename)
|
||||
return
|
||||
return None
|
||||
|
||||
metadata_manager.track_transcription_metadata(task_id, transcription)
|
||||
return transcription
|
||||
|
||||
formatter = TranscriptFormatter()
|
||||
|
||||
content, title = formatter.format(
|
||||
def format_transcript(
|
||||
transcription,
|
||||
context_language,
|
||||
language,
|
||||
room,
|
||||
recording_date,
|
||||
recording_time,
|
||||
download_link,
|
||||
):
|
||||
"""Format a transcription into readable content with a title.
|
||||
|
||||
Resolves the locale from context_language / language, then uses
|
||||
TranscriptFormatter to produce markdown content and a title.
|
||||
|
||||
Returns a (content, title) tuple.
|
||||
"""
|
||||
locale = get_locale(context_language, language)
|
||||
formatter = TranscriptFormatter(locale)
|
||||
|
||||
return formatter.format(
|
||||
transcription,
|
||||
room=room,
|
||||
recording_date=recording_date,
|
||||
@@ -193,34 +139,93 @@ def process_audio_transcribe_summarize_v2(
|
||||
download_link=download_link,
|
||||
)
|
||||
|
||||
data = {
|
||||
"title": title,
|
||||
"content": content,
|
||||
"email": email,
|
||||
"sub": sub,
|
||||
}
|
||||
|
||||
logger.debug("Submitting webhook to %s", settings.webhook_url)
|
||||
logger.debug("Request payload: %s", json.dumps(data, indent=2))
|
||||
def format_actions(llm_output: dict) -> str:
|
||||
"""Format the actions from the LLM output into a markdown list.
|
||||
|
||||
response = post_with_retries(settings.webhook_url, data)
|
||||
fomat:
|
||||
- [ ] Action title Assignée à : assignee1, assignee2, Échéance : due_date
|
||||
"""
|
||||
lines = []
|
||||
for action in llm_output.get("actions", []):
|
||||
title = action.get("title", "").strip()
|
||||
assignees = ", ".join(action.get("assignees", [])) or "-"
|
||||
due_date = action.get("due_date") or "-"
|
||||
line = f"- [ ] {title} Assignée à : {assignees}, Échéance : {due_date}"
|
||||
lines.append(line)
|
||||
if lines:
|
||||
return "### Prochaines étapes\n\n" + "\n".join(lines)
|
||||
return ""
|
||||
|
||||
try:
|
||||
response_data = response.json()
|
||||
document_id = response_data.get("id", "N/A")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
document_id = "Unable to parse response"
|
||||
response_data = response.text
|
||||
|
||||
@celery.task(
|
||||
bind=True,
|
||||
autoretry_for=[exceptions.HTTPError],
|
||||
max_retries=settings.celery_max_retries,
|
||||
queue=settings.transcribe_queue,
|
||||
)
|
||||
def process_audio_transcribe_summarize_v2(
|
||||
self,
|
||||
owner_id: str,
|
||||
filename: str,
|
||||
email: str,
|
||||
sub: str,
|
||||
received_at: float,
|
||||
room: Optional[str],
|
||||
recording_date: Optional[str],
|
||||
recording_time: Optional[str],
|
||||
language: Optional[str],
|
||||
download_link: Optional[str],
|
||||
context_language: Optional[str] = None,
|
||||
):
|
||||
"""Process an audio file by transcribing it and generating a summary.
|
||||
|
||||
This Celery task orchestrates:
|
||||
1. Audio transcription via WhisperX
|
||||
2. Transcript formatting
|
||||
3. Webhook submission
|
||||
4. Conditional summarization queuing
|
||||
|
||||
Args:
|
||||
self: Celery task instance (passed on with bind=True)
|
||||
owner_id: Unique identifier of the recording owner.
|
||||
filename: Name of the audio file in MinIO storage.
|
||||
email: Email address of the recording owner.
|
||||
sub: OIDC subject identifier of the recording owner.
|
||||
received_at: Unix timestamp when the recording was received.
|
||||
room: room name where the recording took place.
|
||||
recording_date: Date of the recording (localized display string).
|
||||
recording_time: Time of the recording (localized display string).
|
||||
language: ISO 639-1 language code for transcription.
|
||||
download_link: URL to download the original recording.
|
||||
context_language: ISO 639-1 language code of the meeting summary context text.
|
||||
"""
|
||||
logger.info(
|
||||
"Webhook success | Document %s submitted (HTTP %s)",
|
||||
document_id,
|
||||
response.status_code,
|
||||
"Notification received | Owner: %s | Room: %s",
|
||||
owner_id,
|
||||
room,
|
||||
)
|
||||
logger.debug("Full response: %s", response_data)
|
||||
|
||||
task_id = self.request.id
|
||||
|
||||
transcription = transcribe_audio(task_id, filename, language)
|
||||
if transcription is None:
|
||||
return
|
||||
|
||||
content, title = format_transcript(
|
||||
transcription,
|
||||
context_language,
|
||||
language,
|
||||
room,
|
||||
recording_date,
|
||||
recording_time,
|
||||
download_link,
|
||||
)
|
||||
|
||||
submit_content(content, title, email, sub)
|
||||
metadata_manager.capture(task_id, settings.posthog_event_success)
|
||||
|
||||
# LLM Summarization
|
||||
if (
|
||||
analytics.is_feature_enabled("summary-enabled", distinct_id=owner_id)
|
||||
and settings.is_summary_enabled
|
||||
@@ -286,12 +291,11 @@ def summarize_transcription(
|
||||
# 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)
|
||||
llm_service = LLMService(llm_observability=llm_observability)
|
||||
|
||||
tldr = llm_service.call(PROMPT_SYSTEM_TLDR, transcript, name="tldr")
|
||||
|
||||
@@ -334,22 +338,9 @@ def summarize_transcription(
|
||||
logger.info("Summary cleaned")
|
||||
|
||||
summary = tldr + "\n\n" + cleaned_summary + "\n\n" + next_steps
|
||||
summary_title = settings.summary_title_template.format(title=title)
|
||||
|
||||
data = {
|
||||
"title": settings.summary_title_template.format(
|
||||
title=title,
|
||||
),
|
||||
"content": summary,
|
||||
"email": email,
|
||||
"sub": sub,
|
||||
}
|
||||
|
||||
logger.debug("Submitting webhook to %s", settings.webhook_url)
|
||||
|
||||
response = post_with_retries(settings.webhook_url, data)
|
||||
|
||||
logger.info("Webhook submitted successfully. Status: %s", response.status_code)
|
||||
logger.debug("Response body: %s", response.text)
|
||||
submit_content(summary, summary_title, email, sub)
|
||||
|
||||
llm_observability.flush()
|
||||
logger.debug("LLM observability flushed")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Application configuration and settings."""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Annotated, List, Optional, Set
|
||||
from typing import Annotated, List, Literal, Optional, Set
|
||||
|
||||
from fastapi import Depends
|
||||
from pydantic import SecretStr
|
||||
@@ -51,7 +51,6 @@ class Settings(BaseSettings):
|
||||
|
||||
# Transcription processing
|
||||
hallucination_patterns: List[str] = ["Vap'n'Roll Thierry"]
|
||||
hallucination_replacement_text: str = "[Texte impossible à transcrire]"
|
||||
|
||||
# Webhook-related settings
|
||||
webhook_max_retries: int = 2
|
||||
@@ -60,11 +59,10 @@ class Settings(BaseSettings):
|
||||
webhook_api_token: SecretStr
|
||||
webhook_url: str
|
||||
|
||||
# Locale
|
||||
default_context_language: Literal["de", "en", "fr", "nl"] = "fr"
|
||||
|
||||
# Output related settings
|
||||
document_default_title: Optional[str] = "Transcription"
|
||||
document_title_template: Optional[str] = (
|
||||
'Réunion "{room}" du {room_recording_date} à {room_recording_time}'
|
||||
)
|
||||
summary_title_template: Optional[str] = "Résumé de {title}"
|
||||
|
||||
# Summary related settings
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""File service to encapsulate files' manipulations."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -15,6 +16,9 @@ from summary.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileServiceException(Exception):
|
||||
"""Base exception for file service operations."""
|
||||
|
||||
@@ -24,10 +28,8 @@ class FileServiceException(Exception):
|
||||
class FileService:
|
||||
"""Service for downloading and preparing files from MinIO storage."""
|
||||
|
||||
def __init__(self, logger):
|
||||
def __init__(self):
|
||||
"""Initialize FileService with MinIO client and configuration."""
|
||||
self._logger = logger
|
||||
|
||||
endpoint = (
|
||||
settings.aws_s3_endpoint_url.removeprefix("https://")
|
||||
.removeprefix("http://")
|
||||
@@ -53,16 +55,16 @@ class FileService:
|
||||
The file is downloaded to a temporary location for local manipulation
|
||||
such as validation, conversion, or processing before being used.
|
||||
"""
|
||||
self._logger.info("Download recording | object_key: %s", remote_object_key)
|
||||
logger.info("Download recording | object_key: %s", remote_object_key)
|
||||
|
||||
if not remote_object_key:
|
||||
self._logger.warning("Invalid object_key '%s'", remote_object_key)
|
||||
logger.warning("Invalid object_key '%s'", remote_object_key)
|
||||
raise ValueError("Invalid object_key")
|
||||
|
||||
extension = Path(remote_object_key).suffix.lower()
|
||||
|
||||
if extension not in self._allowed_extensions:
|
||||
self._logger.warning("Invalid file extension '%s'", extension)
|
||||
logger.warning("Invalid file extension '%s'", extension)
|
||||
raise ValueError(f"Invalid file extension '{extension}'")
|
||||
|
||||
response = None
|
||||
@@ -81,8 +83,8 @@ class FileService:
|
||||
tmp.flush()
|
||||
local_path = Path(tmp.name)
|
||||
|
||||
self._logger.info("Recording successfully downloaded")
|
||||
self._logger.debug("Recording local file path: %s", local_path)
|
||||
logger.info("Recording successfully downloaded")
|
||||
logger.debug("Recording local file path: %s", local_path)
|
||||
|
||||
return local_path
|
||||
|
||||
@@ -100,7 +102,7 @@ class FileService:
|
||||
file_metadata = mutagen.File(local_path).info
|
||||
duration = file_metadata.length
|
||||
|
||||
self._logger.info(
|
||||
logger.info(
|
||||
"Recording file duration: %.2f seconds",
|
||||
duration,
|
||||
)
|
||||
@@ -109,14 +111,14 @@ class FileService:
|
||||
error_msg = "Recording too long. Limit is %.2fs seconds" % (
|
||||
self._max_duration,
|
||||
)
|
||||
self._logger.error(error_msg)
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
return duration
|
||||
|
||||
def _extract_audio_from_video(self, video_path: Path) -> Path:
|
||||
"""Extract audio from video file (e.g., MP4) and save as audio file."""
|
||||
self._logger.info("Extracting audio from video file: %s", video_path)
|
||||
logger.info("Extracting audio from video file: %s", video_path)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".m4a", delete=False, prefix="audio_extract_"
|
||||
@@ -140,16 +142,16 @@ class FileService:
|
||||
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True
|
||||
)
|
||||
|
||||
self._logger.info("Audio successfully extracted to: %s", output_path)
|
||||
logger.info("Audio successfully extracted to: %s", output_path)
|
||||
return output_path
|
||||
|
||||
except FileNotFoundError as e:
|
||||
self._logger.error("ffmpeg not found. Please install ffmpeg.")
|
||||
logger.error("ffmpeg not found. Please install ffmpeg.")
|
||||
if output_path.exists():
|
||||
os.remove(output_path)
|
||||
raise RuntimeError("ffmpeg is not installed or not in PATH") from e
|
||||
except subprocess.CalledProcessError as e:
|
||||
self._logger.error("Audio extraction failed: %s", e.stderr.decode())
|
||||
logger.error("Audio extraction failed: %s", e.stderr.decode())
|
||||
if output_path.exists():
|
||||
os.remove(output_path)
|
||||
raise RuntimeError("Failed to extract audio.") from e
|
||||
@@ -173,7 +175,7 @@ class FileService:
|
||||
extension = downloaded_path.suffix.lower()
|
||||
|
||||
if extension in settings.recording_video_extensions:
|
||||
self._logger.info("Video file detected, extracting audio...")
|
||||
logger.info("Video file detected, extracting audio...")
|
||||
extracted_audio_path = self._extract_audio_from_video(downloaded_path)
|
||||
processed_path = extracted_audio_path
|
||||
else:
|
||||
@@ -194,8 +196,6 @@ class FileService:
|
||||
|
||||
try:
|
||||
os.remove(path)
|
||||
self._logger.debug("Temporary file removed: %s", path)
|
||||
logger.debug("Temporary file removed: %s", path)
|
||||
except OSError as e:
|
||||
self._logger.warning(
|
||||
"Failed to remove temporary file %s: %s", path, e
|
||||
)
|
||||
logger.warning("Failed to remove temporary file %s: %s", path, e)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""LLM service to encapsulate LLM's calls."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
import openai
|
||||
@@ -10,6 +11,9 @@ from summary.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMObservability:
|
||||
"""Manage observability and tracing for LLM calls using Langfuse.
|
||||
|
||||
@@ -21,13 +25,11 @@ class LLMObservability:
|
||||
|
||||
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
|
||||
@@ -75,7 +77,7 @@ class LLMObservability:
|
||||
}
|
||||
|
||||
if not self.is_enabled:
|
||||
self._logger.debug("Using regular OpenAI client (observability disabled)")
|
||||
logger.debug("Using regular OpenAI client (observability disabled)")
|
||||
return openai.OpenAI(**base_args)
|
||||
|
||||
# Langfuse's OpenAI wrapper is imported here to avoid triggering client
|
||||
@@ -83,7 +85,7 @@ class LLMObservability:
|
||||
# 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)")
|
||||
logger.debug("Using LangfuseOpenAI client (observability enabled)")
|
||||
return langfuse_openai.OpenAI(**base_args)
|
||||
|
||||
def flush(self):
|
||||
@@ -99,11 +101,10 @@ class LLMException(Exception):
|
||||
class LLMService:
|
||||
"""Service for performing calls to the LLM configured in the settings."""
|
||||
|
||||
def __init__(self, llm_observability, logger):
|
||||
def __init__(self, llm_observability):
|
||||
"""Init the LLMService once."""
|
||||
self._client = llm_observability.get_openai_client()
|
||||
self._observability = llm_observability
|
||||
self._logger = logger
|
||||
|
||||
def call(
|
||||
self,
|
||||
@@ -140,5 +141,5 @@ class LLMService:
|
||||
return response.choices[0].message.content
|
||||
|
||||
except Exception as e:
|
||||
self._logger.exception("LLM call failed: %s", e)
|
||||
logger.exception("LLM call failed: %s", e)
|
||||
raise LLMException(f"LLM call failed: {e}") from e
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Locale support for the summary service."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.locales import de, en, fr, nl
|
||||
from summary.core.locales.strings import LocaleStrings
|
||||
|
||||
_LOCALES = {"fr": fr, "en": en, "de": de, "nl": nl}
|
||||
|
||||
|
||||
def get_locale(*languages: Optional[str]) -> LocaleStrings:
|
||||
"""Return locale strings for the first matching language candidate.
|
||||
|
||||
Accept language codes in decreasing priority order and return the
|
||||
locale for the first one that matches a known locale.
|
||||
Fall back to the configured default_context_language.
|
||||
"""
|
||||
for lang in languages:
|
||||
if not lang:
|
||||
continue
|
||||
if lang in _LOCALES:
|
||||
return _LOCALES[lang].STRINGS
|
||||
|
||||
# Provide fallback for longer formats of ISO 639-1 (e.g. "en-au" -> "en")
|
||||
base_lang = lang.split("-")[0]
|
||||
if base_lang in _LOCALES:
|
||||
return _LOCALES[base_lang].STRINGS
|
||||
|
||||
return _LOCALES[get_settings().default_context_language].STRINGS
|
||||
@@ -0,0 +1,34 @@
|
||||
"""German locale strings."""
|
||||
|
||||
from summary.core.locales.strings import LocaleStrings
|
||||
|
||||
STRINGS = LocaleStrings(
|
||||
empty_transcription="""
|
||||
**In Ihrer Transkription wurde kein Audioinhalt erkannt.**
|
||||
|
||||
*Wenn Sie glauben, dass es sich um einen Fehler handelt, zögern Sie nicht,
|
||||
unseren technischen Support zu kontaktieren: visio@numerique.gouv.fr*
|
||||
|
||||
.
|
||||
|
||||
.
|
||||
|
||||
.
|
||||
|
||||
Einige Punkte, die wir Ihnen empfehlen zu überprüfen:
|
||||
- War ein Mikrofon aktiviert?
|
||||
- Waren Sie nah genug am Mikrofon?
|
||||
- Ist das Mikrofon von guter Qualität?
|
||||
- Dauert die Aufnahme länger als 30 Sekunden?
|
||||
|
||||
""",
|
||||
download_header_template=(
|
||||
"\n*Laden Sie Ihre Aufnahme herunter, "
|
||||
"indem Sie [diesem Link folgen]({download_link})*\n"
|
||||
),
|
||||
hallucination_replacement_text="[Text konnte nicht transkribiert werden]",
|
||||
document_default_title="Transkription",
|
||||
document_title_template=(
|
||||
'Besprechung "{room}" am {room_recording_date} um {room_recording_time}'
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""English locale strings."""
|
||||
|
||||
from summary.core.locales.strings import LocaleStrings
|
||||
|
||||
STRINGS = LocaleStrings(
|
||||
empty_transcription="""
|
||||
**No audio content was detected in your transcription.**
|
||||
|
||||
*If you believe this is an error, please do not hesitate to contact
|
||||
our technical support: visio@numerique.gouv.fr*
|
||||
|
||||
.
|
||||
|
||||
.
|
||||
|
||||
.
|
||||
|
||||
A few things we recommend you check:
|
||||
- Was a microphone enabled?
|
||||
- Were you close enough to the microphone?
|
||||
- Is the microphone of good quality?
|
||||
- Is the recording longer than 30 seconds?
|
||||
|
||||
""",
|
||||
download_header_template=(
|
||||
"\n*Download your recording by [following this link]({download_link})*\n"
|
||||
),
|
||||
hallucination_replacement_text="[Unable to transcribe text]",
|
||||
document_default_title="Transcription",
|
||||
document_title_template=(
|
||||
'Meeting "{room}" on {room_recording_date} at {room_recording_time}'
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""French locale strings (default)."""
|
||||
|
||||
from summary.core.locales.strings import LocaleStrings
|
||||
|
||||
STRINGS = LocaleStrings(
|
||||
empty_transcription="""
|
||||
**Aucun contenu audio n'a été détecté dans votre transcription.**
|
||||
|
||||
*Si vous pensez qu'il s'agit d'une erreur, n'hésitez pas à contacter
|
||||
notre support technique : visio@numerique.gouv.fr*
|
||||
|
||||
.
|
||||
|
||||
.
|
||||
|
||||
.
|
||||
|
||||
Quelques points que nous vous conseillons de vérifier :
|
||||
- Un micro était-il activé ?
|
||||
- Étiez-vous suffisamment proche ?
|
||||
- Le micro est-il de bonne qualité ?
|
||||
- L'enregistrement dure-t-il plus de 30 secondes ?
|
||||
|
||||
""",
|
||||
download_header_template=(
|
||||
"\n*Télécharger votre enregistrement en [suivant ce lien]({download_link})*\n"
|
||||
),
|
||||
hallucination_replacement_text="[Texte impossible à transcrire]",
|
||||
document_default_title="Transcription",
|
||||
document_title_template=(
|
||||
'Réunion "{room}" du {room_recording_date} à {room_recording_time}'
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Dutch locale strings."""
|
||||
|
||||
from summary.core.locales.strings import LocaleStrings
|
||||
|
||||
STRINGS = LocaleStrings(
|
||||
empty_transcription="""
|
||||
**Er is geen audio-inhoud gedetecteerd in uw transcriptie.**
|
||||
|
||||
*Als u denkt dat dit een fout is, aarzel dan niet om contact op te nemen
|
||||
met onze technische ondersteuning: visio@numerique.gouv.fr*
|
||||
|
||||
.
|
||||
|
||||
.
|
||||
|
||||
.
|
||||
|
||||
Een paar punten die wij u aanraden te controleren:
|
||||
- Was er een microfoon ingeschakeld?
|
||||
- Was u dicht genoeg bij de microfoon?
|
||||
- Is de microfoon van goede kwaliteit?
|
||||
- Duurt de opname langer dan 30 seconden?
|
||||
|
||||
""",
|
||||
download_header_template=(
|
||||
"\n*Download uw opname door [deze link te volgen]({download_link})*\n"
|
||||
),
|
||||
hallucination_replacement_text="[Tekst kon niet worden getranscribeerd]",
|
||||
document_default_title="Transcriptie",
|
||||
document_title_template=(
|
||||
'Vergadering "{room}" op {room_recording_date} om {room_recording_time}'
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Locale types for the summary service."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocaleStrings:
|
||||
"""All translatable output strings for the summary pipeline."""
|
||||
|
||||
# transcript_formatter.py
|
||||
empty_transcription: str
|
||||
download_header_template: str
|
||||
hallucination_replacement_text: str
|
||||
document_default_title: str
|
||||
document_title_template: str
|
||||
@@ -4,34 +4,13 @@ import logging
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.locales import LocaleStrings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_EMPTY_TRANSCRIPTION = """
|
||||
**Aucun contenu audio n’a été détecté dans votre transcription.**
|
||||
|
||||
|
||||
*Si vous pensez qu’il s’agit d’une erreur, n’hésitez pas à contacter
|
||||
notre support technique : visio@numerique.gouv.fr*
|
||||
|
||||
.
|
||||
|
||||
.
|
||||
|
||||
.
|
||||
|
||||
Quelques points que nous vous conseillons de vérifier :
|
||||
- Un micro était-il activé ?
|
||||
- Étiez-vous suffisamment proche ?
|
||||
- Le micro est-il de bonne qualité ?
|
||||
- L’enregistrement dure-t-il plus de 30 secondes ?
|
||||
|
||||
"""
|
||||
|
||||
|
||||
class TranscriptFormatter:
|
||||
"""Formats WhisperX transcription output into readable conversation format.
|
||||
|
||||
@@ -42,12 +21,10 @@ class TranscriptFormatter:
|
||||
- Generating descriptive titles from context
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize formatter with settings."""
|
||||
def __init__(self, locale: LocaleStrings):
|
||||
"""Initialize formatter with settings and locale."""
|
||||
self.hallucination_patterns = settings.hallucination_patterns
|
||||
self.hallucination_replacement_text = settings.hallucination_replacement_text
|
||||
self.default_title = settings.document_default_title
|
||||
self.default_empty_transcription = DEFAULT_EMPTY_TRANSCRIPTION
|
||||
self._locale = locale
|
||||
|
||||
def _get_segments(self, transcription):
|
||||
"""Extract segments from transcription object or dictionary."""
|
||||
@@ -71,7 +48,7 @@ class TranscriptFormatter:
|
||||
segments = self._get_segments(transcription)
|
||||
|
||||
if not segments:
|
||||
content = self.default_empty_transcription
|
||||
content = self._locale.empty_transcription
|
||||
else:
|
||||
content = self._format_speaker(segments)
|
||||
content = self._remove_hallucinations(content)
|
||||
@@ -83,7 +60,7 @@ class TranscriptFormatter:
|
||||
|
||||
def _remove_hallucinations(self, content: str) -> str:
|
||||
"""Remove hallucination patterns from content."""
|
||||
replacement = self.hallucination_replacement_text or ""
|
||||
replacement = self._locale.hallucination_replacement_text or ""
|
||||
|
||||
for pattern in self.hallucination_patterns:
|
||||
content = content.replace(pattern, replacement)
|
||||
@@ -111,9 +88,8 @@ class TranscriptFormatter:
|
||||
if not download_link:
|
||||
return content
|
||||
|
||||
header = (
|
||||
f"\n*Télécharger votre enregistrement "
|
||||
f"en [suivant ce lien]({download_link})*\n"
|
||||
header = self._locale.download_header_template.format(
|
||||
download_link=download_link
|
||||
)
|
||||
content = header + content
|
||||
|
||||
@@ -127,9 +103,9 @@ class TranscriptFormatter:
|
||||
) -> str:
|
||||
"""Generate title from context or return default."""
|
||||
if not room or not recording_date or not recording_time:
|
||||
return self.default_title
|
||||
return self._locale.document_default_title
|
||||
|
||||
return settings.document_title_template.format(
|
||||
return self._locale.document_title_template.format(
|
||||
room=room,
|
||||
room_recording_date=recording_date,
|
||||
room_recording_time=recording_time,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Service for delivering content to external destinations."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from requests import Session
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util import Retry
|
||||
|
||||
from summary.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _create_retry_session():
|
||||
"""Create an HTTP session configured with retry logic."""
|
||||
session = Session()
|
||||
retries = Retry(
|
||||
total=settings.webhook_max_retries,
|
||||
backoff_factor=settings.webhook_backoff_factor,
|
||||
status_forcelist=settings.webhook_status_forcelist,
|
||||
allowed_methods={"POST"},
|
||||
)
|
||||
session.mount("https://", HTTPAdapter(max_retries=retries))
|
||||
return session
|
||||
|
||||
|
||||
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.get_secret_value()}"}
|
||||
)
|
||||
try:
|
||||
response = session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def submit_content(content, title, email, sub):
|
||||
"""Submit content to the configured webhook destination.
|
||||
|
||||
Builds the payload, sends it with retries, and logs the outcome.
|
||||
"""
|
||||
data = {
|
||||
"title": title,
|
||||
"content": content,
|
||||
"email": email,
|
||||
"sub": sub,
|
||||
}
|
||||
|
||||
logger.debug("Submitting to %s", settings.webhook_url)
|
||||
logger.debug("Request payload: %s", json.dumps(data, indent=2))
|
||||
|
||||
response = _post_with_retries(settings.webhook_url, data)
|
||||
|
||||
try:
|
||||
response_data = response.json()
|
||||
document_id = response_data.get("id", "N/A")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
document_id = "Unable to parse response"
|
||||
response_data = response.text
|
||||
|
||||
logger.info(
|
||||
"Delivery success | Document %s submitted (HTTP %s)",
|
||||
document_id,
|
||||
response.status_code,
|
||||
)
|
||||
logger.debug("Full response: %s", response_data)
|
||||
Reference in New Issue
Block a user